Compare commits

...
65 Commits
Author SHA1 Message Date
alientim b54b87ca6f Version 2.8.16: die Ueberwachung sperrt sich nicht mehr selbst aus 2026-09-13 12:22:31 +02:00
alientim 270ba455a8 Version 2.8.15: Probe der Strecke, Grund aus _fail, RFB 3.3 2026-09-13 12:04:50 +02:00
alientim f3b312046f Version 2.8.14: Diagnose in der Bildschirmsitzung, Vorauswahl ohne Passwort 2026-09-13 11:31:41 +02:00
alientim 436b080deb Version 2.8.13: Bildschirmsitzung im Browser (noVNC) 2026-09-13 10:35:12 +02:00
alientim 85c40ccfbd Version 2.8.12 / tesm-core 2.8.4 2026-09-13 10:10:30 +02:00
alientim 0ac651a1b9 Version 2.8.11: die Kachel oeffnet den Bildschirm 2026-09-13 10:09:54 +02:00
alientim 4275c84a6c Version 2.8.10: der Name gilt nur, wenn ihn jemand aufloesen kann 2026-09-13 09:55:36 +02:00
alientim 4f4310bfe4 Version 2.8.9: VNC-Zugang, wo ein VNC-Server antwortet 2026-09-13 09:49:27 +02:00
alientim 1ad2f3f9a5 Version 2.8.8: der Notausgang holt die Gruppe aus dem Papierkorb 2026-09-13 00:27:02 +02:00
alientim 9d5eac1db2 Version 2.8.7: drei Standardgruppen, Module abschaltbar 2026-09-13 00:19:54 +02:00
alientim 2fd78ac26b Verzeichnisanmeldung: Wiederholversuch, weniger Runden, Zeiten 2026-09-12 23:46:51 +02:00
alientim 86c09d8a0f Version 2.8.5: Prompt hinter Steuerzeichen wird erkannt 2026-09-12 23:25:49 +02:00
alientim 70f6b005a9 Version 2.8.4: Konsole meldet jede Stufe, Befehl switch-test 2026-09-12 21:10:39 +02:00
alientim 57eb7764cc Version 2.8.3: Switch-Sitzung wird abgemeldet, Erfolg wird belegt 2026-09-12 20:26:30 +02:00
alientim 30c2c56656 Verzeichniskonfiguration aus 1.2.8 uebernehmen; Sicherungen setzten Schalter falsch 2026-09-12 19:26:41 +02:00
alientim 292ea622a7 Version 2.8.1: das Upgrade von 1.2.8 nimmt die Freigaben mit 2026-09-12 19:06:52 +02:00
alientim 0d99760d89 Version 2.8.0: Rechtekonzept -- eine Zeile je Funktion, Administrator heisst Verwaltung 2026-09-12 13:27:45 +02:00
alientim 713ff12db2 Version 2.7.8: die Vorschau haengt an derselben Kante wie die Datei 2026-09-12 11:37:03 +02:00
alientim 2db7e03154 Version 2.7.7: Schriftgroesse der Vorschau wird gerechnet 2026-09-12 01:34:02 +02:00
alientim 5dcb87d6ec Version 2.7.6: die Vorschau der Unterschrift zeigt das Ergebnis 2026-09-12 01:12:05 +02:00
alientim dc94a8e666 Version 2.7.5: jede Aenderung nennt alt und neu 2026-09-12 00:43:09 +02:00
alientim c639f4f507 Version 2.7.5: die Ordnerzeile sagt, was im Ordner liegt 2026-09-12 00:30:34 +02:00
alientim ad0195bab0 Version 2.7.4: Unterschriften in der Sicherung, unterschrieben ist abgeschlossen 2026-09-12 00:26:22 +02:00
alientim ce22239d78 Version 2.7.3: Zeugenvermerk einmal und in den Dokumenteigenschaften 2026-09-11 23:45:17 +02:00
alientim 92c8abb79b Zahlen im Kommentar geradegerueckt 2026-09-11 23:28:28 +02:00
alientim dcb4f57eeb Version 2.7.2: Umlaute in der Oberflaeche, Einbindungen an der Sitzung 2026-09-11 23:24:28 +02:00
alientim 11a55b20f8 Version 2.7.1: Signaturverzeichnis ueberlebt die Archivierung 2026-09-11 21:36:24 +02:00
alientim ed17bce40a Version 2.7.0: Pruefung beim Oeffnen, Unterschrift in menschlichem Mass 2026-09-11 21:23:11 +02:00
alientim 5282cd8f2d Version 2.6.1: die Speichernzeile fuehrt zur Entscheidung 2026-09-11 21:03:24 +02:00
alientim 754cbea6bb Version 2.6.0: Unterschreiben mit dem Finger, Ablage in eine andere Freigabe 2026-09-11 20:41:47 +02:00
alientim 07ae69e3b7 Version 2.5.2: Wartung ist auch in der Rechtematrix ein eigener Bereich 2026-09-11 20:16:37 +02:00
alientim d3c700283a Version 2.5.1: Sitzungsdauer im Lizenzserver, Umlaute, Umleitung alter Adressen 2026-09-11 19:16:26 +02:00
alientim 61a47debdc Version 2.5.0: englische Adressen entlang der Navigation, sparsameres Protokoll 2026-09-11 18:57:22 +02:00
alientim bec74e6936 Version 2.4.1: die Wiederanmeldung kommt aus dem Rechtemodell 2026-09-10 02:00:27 +02:00
alientim d2679361bc Version 2.4.0: eigene Rechtezeile fuer PDF-Formulare, Download ist Export 2026-09-10 00:08:21 +02:00
alientim 20c7fd8813 Version 2.3.3: weiter ausfuellbar oder abgeschlossen 2026-09-09 23:43:46 +02:00
alientim eaca6f7990 Version 2.3.2: Namen ohne Endung, lesbare Haken, hochkant mehr Hoehe 2026-09-09 23:35:42 +02:00
alientim 4eb1c0c2d8 Version 2.3.1: Formularfelder sitzen richtig, Bildfeld nutzbar 2026-09-09 23:11:49 +02:00
alientim c0a6dd057c Version 2.3.0: Speichern mit Ordner und Namen 2026-09-09 22:41:07 +02:00
alientim 8c2cd10ee4 Version 2.2.1: pdf.js unter .js -- .mjs kennt kein Webserver 2026-09-09 22:06:15 +02:00
alientim 2c3ca4acc1 Version 2.2.0: Vorschau mit Zoom und PDF-Formularen, eine Uploadgrenze 2026-09-09 21:48:05 +02:00
alientim f16ba7d875 Version 2.1.2: Wurzelinstanz auch an der Lizenz erkennen 2026-09-09 00:27:00 +02:00
alientim 7bfe328e4b Version 2.1.1: update.sh mit --yes, ohne Terminal kein stiller Ausstieg 2026-09-09 00:20:30 +02:00
alientim 5b9373bb24 release.sh nimmt den Namen des Repositoriums 2026-09-09 00:12:59 +02:00
alientim f77c8b221c Version 2.1.0 2026-09-09 00:10:39 +02:00
alientim 985deb7a24 README: vier Repositorien, zwei Ausgaben des Lizenzservers 2026-09-09 00:08:32 +02:00
alientim 258e90bf4f Rueckweg bei jedem Fehlschlag, install.sh oben im Paket, eigener Port je Instanz 2026-09-09 00:06:44 +02:00
alientimandClaude Opus 5 337a52cc38 sudoers ohne requiretty, und ein Rueckweg fuer fehlgeschlagene Upgrades
Auf einem frisch aufgesetzten Server brach das Upgrade mitten drin ab:

    /tmp/tmp.XXXX:8:24: syntax error: unknown setting: 'requiretty'
    visudo: invalid sudoers file
     !! sudoers-Datei ist fehlerhaft

"Defaults:<user> !requiretty" stand in beiden sudoers-Dateien. Auf Debian und
Ubuntu ist requiretty nie gesetzt -- die Zeile war von Anfang an wirkungslos --
und ab sudo 1.9.16 kennt visudo die Einstellung nicht mehr und lehnt die ganze
Datei ab. Sie ist entfernt; ein Hygienetest haelt fest, dass sie nicht
zurueckkommt, und prueft gleich mit, dass in diesen Dateien genau eine Regel
steht und die auf den Helfer zeigt.

Schlimmer als der Fehler war die Folge: der alte Dienst war zu diesem Zeitpunkt
gestoppt und deaktiviert, das Verzeichnis teils ersetzt, der neue Dienst nicht
vorhanden. upgbrade.sh faengt den Fehlschlag jetzt ab und dreht den Lauf
zurueck -- und mit "--rollback" auch spaeter noch, aus einer vorhandenen
Sicherung.

Dafuer muss die Sicherung mehr enthalten als das Verzeichnis. Bei einem Upgrade
in dasselbe Verzeichnis schreibt install.sh auch systemd-Unit, nginx-Site,
logrotate und Banner neu und ersetzt das venv; ohne Kopie davon liesse sich der
alte Stand nicht herstellen. Ein Manifest haelt Quelle und Art des Umzugs fest,
damit ein spaeterer Rueckweg nicht raten muss.

Beim Nachstellen des Fehlschlags fiel ein zweiter Fund auf, gefaehrlicher als
der erste: die Liste der "alten Units" war fest verdrahtet -- fuer --app tesm
immer tesm.service. Nach einem rename.sh heisst so aber die *laufende*
Installation. Wer die archivierte 1.x in eine eigene Instanz hochzieht und
--keep-old-running vergisst, haette damit den Produktionsdienst gestoppt und
deaktiviert. Die Units werden jetzt daran erkannt, dass sie in genau das
Quellverzeichnis zeigen; Timer und Hilfsdienste kommen ueber den Namen der so
gefundenen Unit mit.

Am System geprueft, nicht behauptet: Fehlschlag nachgestellt (1.2.8 mit eigener
Unit, eigener Site und erkennbarem venv), Rueckweg lief -- VERSION 1.2.8, das
alte venv und die alte Unit wieder da, die neue Struktur weg, die laufende
Produktion unberuehrt. Danach derselbe Lauf mit heiler sudoers-Datei bis zum
Ende (4 Clients, 2 Zugangsdaten, DHCP), und zuletzt "--rollback" als eigener
Aufruf auf dieselbe Installation.

Version 2.0.10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:13:43 +02:00
alientimandClaude Opus 5 160675e368 upgrade.sh und update.sh genuegen als einzelne Datei
Auf einer 1.2.8 liegt nichts von dieser Fassung -- upgrade.sh verlangte aber
ein install.sh daneben und brach sonst ab. Das war die falsche Voraussetzung
fuer genau den Fall, fuer den das Skript gedacht ist. Beide Skripte holen sich
das Release jetzt selbst:

    curl -fsSLO .../raw/branch/main/deploy/upgrade.sh
    sudo bash upgrade.sh --app tesm

Liegt das Skript in einem entpackten Release, wird dessen install.sh benutzt
statt eines Downloads: wer ein Paket ausgepackt hat, will nicht, dass ungefragt
ein anderes geladen wird. Mit --tag laesst sich eine Version festlegen.

Der Bootstrap steht wortgleich in beiden Dateien -- zwei eigenstaendige
Skripte koennen sich keine Datei teilen, und beide sollen allein genuegen. Ein
Hygienetest vergleicht die Bloecke, ein zweiter prueft, dass upgrade.sh kein
entpacktes Release mehr verlangt, ein dritter fuehrt den Block mit leerem PATH
aus und erwartet, dass er das fehlende Werkzeug benennt.

update.sh ruft install.sh nicht mehr per exec auf: dadurch lief die
Aufraeumfalle nie, und jedes Update liess ein entpacktes Release unter /tmp
liegen.

Ausserdem "opus" aus allen Beispielen genommen. Das war der Name der
Testinstanz neben der Fassung 1.x; seit dem Umbenennen heissen die
Installationen wie ihre Anwendung, und in einer Hilfe, die jemand anders liest,
ist "opus" ein Raetsel. In BETRIEB.md stand dabei zweimal /opt statt /srv.

Geprueft am System, nicht behauptet: die einzelne upgrade.sh hat eine echte
1.2.8 (Kopie des Archivs) **in dasselbe Verzeichnis** auf 2.0.8 gebracht --
Release geladen, 4 Clients, 2 Zugangsdaten und die DHCP-Konfiguration
uebernommen, Dienst laeuft, /login 200. Die Sicherung enthaelt die 1.2.8 samt
sqlite.db und fernet.key (Verzeichnis 0700, Passphrase 0600), und /tmp bleibt
leer. Danach dieselbe Probe mit der einzelnen update.sh.

Version 2.0.9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:18:52 +02:00
alientimandClaude Opus 5 3bbddd7d29 upgrade.sh findet die Fassung 1.x auch im Archiv
Nach einem Umbenennen liegt unter /srv/<app> die laufende Installation und die
Fassung 1.x unter /srv/<app>-sonnet5 -- dorthin verschiebt rename.sh sie. Die
Vorgabe von upgrade.sh zeigte danach auf die neue Installation und brach ab
(sauber, aber unbrauchbar). Gesucht wird jetzt an beiden Orten; erkannt wird
eine 1.x an der sqlite.db im Wurzelverzeichnis.

Den Weg selbst habe ich gegen die echten Altdaten geprueft, nicht behauptet:
aus /srv/tesm-sonnet5 (Version 1.2.8) kamen 4 Clients, 2 Zugangsdaten und die
DHCP-Konfiguration in eine Probeinstanz, aus /srv/tesm-license-sonnet5
(Version 1.2.7) 2 Kunden, 2 Tickets und 2 neu signierte Lizenzen samt
uebernommenem Signaturschluessel und Anbieterblock. Die laufenden
Installationen blieben unberuehrt, die Probeinstanzen sind abgeraeumt.

Zwei Tests halten die beiden Skripte zusammen: derselbe Namenszusatz, und die
Suche laeuft ueber die echten Zeilen aus upgrade.sh.

Version 2.0.8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:53:36 +02:00
alientimandClaude Opus 5 7407f4bc00 Anmeldeseite: Wortmarke fuer beide, Karte ohne Wiederholung
* Die Wortmarke stand nur bei TESM -- sie hing an der Funktionszeile, und die
  hatte der Lizenzserver nicht. Jetzt zeigen beide die Marke; die Zeile
  darunter ist optional und beim Lizenzserver "LIZENZIEREN".
* Der Kartenkopf ist weg: eine Kachel mit zwei Buchstaben, die Ueberschrift
  "Anmelden" und der Anwendungsname darunter sagten dasselbe wie die Marke
  darueber und der Knopf darunter. Die Ueberschrift bleibt fuer die
  Titelzeile des Browsers.
* Marke und Karte stehen jetzt uebereinander im Fluss statt uebereinander
  gestapelt: auf einem flachen Fenster schnitt die Karte mitten durch den
  Schriftzug.
* Die Marke ist kuerzer und symmetrisch um die Mitte aufgebaut -- Schriftzug
  ueber text-anchor, Zacken spiegelbildlich, Linie links so lang wie der Punkt
  rechts. Volle Deckkraft, weil sie kein Wasserzeichen hinter der Karte mehr
  ist.
* Funktionszeile gekuerzt auf "UEBERWACHEN . BOOTEN . ANBINDEN" -- mit echtem
  Umlaut. "(PXE)" stand fuer etwas, das es noch nicht gibt.
* Der Farbverlauf im Hintergrund hat wieder Geometrie und Staerke der Fassung
  1.x. Der zweite Kreis lag auf --info und 10 %: ein kaltes Blau, das man auf
  dunklem Grund nicht sah und das nicht zur Marke gehoert.
* "Bitte melden Sie sich an." stand auf einer Seite aus Anmeldefeld,
  Passwortfeld und einem Knopf "Anmelden". Statt dessen erfaehrt jetzt der,
  dessen Sitzung gerade ablief, warum er wieder hier ist.

Dabei drei Dinge gefunden, die lautlos nichts taten:

* __version__ stand fest verdrahtet in drei __init__.py. Die Fusszeile zeigte
  vier Releases lang 2.0.2, waehrend die Anwendung 2.0.6 war. Die Nummer kommt
  jetzt aus den Metadaten des installierten Pakets; Quelle ist pyproject.toml.
* "gap: var(--sp-7)" -- die Skala kennt kein --sp-7 (1 bis 6, dann 8, 10, 12,
  16). Eine undefinierte Variable verwirft die ganze Deklaration ohne Meldung:
  der Abstand war "normal", und Marke und Karte stiessen auf den Pixel
  aneinander.
* Ein Test dagegen fand sofort ein zweites: --surface-0 wird dreimal benutzt
  und nirgends definiert. Die Schrift auf der Kachel "kritisch" blieb
  dunkelgrau auf Rot, die Dateivorschau hatte keinen Grund, und das Feld
  "Recht nicht vorhanden" in der Rechtematrix war durchsichtig -- also so hell
  wie die Karte, obwohl es dunkler sein sollte.

Version 2.0.7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:44:32 +02:00
alientimandClaude Opus 5 635722089e rename.sh: aus einer Instanz die Hauptinstallation machen
Die laufenden Installationen hiessen "tesm-opus" und "tesm-license-opus" --
ein Instanzname aus der Zeit, in der sie neben der Fassung 1.x getestet wurden.
Jetzt heissen sie "tesm" und "tesm-license".

Umbenennen ist mehr als ein verschobenes Verzeichnis, und drei Stellen sind
leicht zu uebersehen:

* SITE_KEY in der Umgebungsdatei -- daraus entsteht der Name des
  Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
  Instanz: unauffaellig und falsch.
* web_cert_path/web_key_path/web_acme_webroot in der Datenbank enthalten den
  Namen. Ein Zertifikat unter dem alten Pfad findet nginx nicht mehr.
* server_name der nginx-Site. install.sh schreibt die Datei aus seiner Vorlage
  neu, sobald sich die alias-Pfade aendern -- und die aendern sich hier
  zwangslaeufig. Ohne den Namen aus der Datenbank stehen danach zwei Sites mit
  "server_name _" auf Port 80, und die erste gewinnt. web-setup braucht dafuer
  --apply; ohne ihn speichert es nur.

Zwei Fehler hat erst der Lauf am System gezeigt:

* Das venv wandert nicht mit. Seine Konsolenskripte tragen den absoluten Pfad
  im Shebang, und danach zeigt venv/bin/gunicorn auf einen Interpreter, den es
  nicht mehr gibt -- systemd meldet "No such file or directory" fuer eine
  Datei, die sichtbar da ist. Es wird jetzt verworfen und neu gebaut.
* Den Zielnamen belegt nicht nur /srv/<name>, sondern auch /var/log/<name>,
  der ACME-Pfad und die logrotate-Datei. Der Umzug brach mitten drin ab. Jetzt
  wandert alles, was den Namen belegt, in ein Archiv -- geloescht wird nichts,
  die Units und die Site der alten Fassung liegen darin unter systemd-alt/.

Ein Test vergleicht die Pfade, die install.sh unter dem Namen anlegt, mit
denen, die rename.sh kennt. Genau diese Luecke war der zweite Fehler; ohne den
Test faellt der naechste erst wieder auf einem Server auf.

Version 2.0.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:12:40 +02:00
alientimandClaude Opus 5 6dff1ed137 Banner: Einzahl, wo eines gemeint ist
"1 Clients online" liest man jeden Tag einmal zu oft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:48:32 +02:00
alientimandClaude Opus 5 b4529a0e31 Anmeldebanner wieder in Betrieb -- ohne die Anwendung zu starten
Das Banner der Vorgaengerfassung hing an /srv/tesm/motd_banner.py und
importierte dafuer die ganze Flask-Anwendung samt deren venv. Nach dem
Umstieg zeigte es damit die Zahlen einer Datenbank, die niemand mehr fuellt --
eingefroren, aber unverdaechtig. Aufgefallen ist es erst, als der alte
Ueberwachungsdienst abgeschaltet wurde.

deploy/tesm-motd loest das anders: **nur Standardbibliothek**, kein venv, kein
Import der Anwendung, die Datenbank wird nur lesend geoeffnet (mode=ro). Ein
Lauf dauert Millisekunden statt eines Flask-Starts. install.sh legt das Skript
nach /usr/local/lib/tesm/ und schreibt /etc/update-motd.d/50-<name>, abgesichert
mit "timeout 3" und "|| true" -- ein Banner darf eine Anmeldung nie blockieren.
Ein Banner der Vorgaengerfassung wird entfernt, aber nur, wenn es genau auf
motd_banner.py zeigt.

Damit es nicht zwei Wahrheiten ueber "online" gibt, steht die Zaehlung der
Statusuebersicht jetzt als Konstante DASHBOARD_COUNT_SQL in monitor.py; das
Banner traegt sie wortgleich, und ein Hygienetest vergleicht beide. Dasselbe
fuer die Zeile unter der Wortmarke. Ein weiterer Test verbietet dem Banner
jeden Import ausserhalb der Standardbibliothek -- genau dieser Import war die
Ursache.

Der Lizenzserver bekommt sein eigenes Banner (Kunden und ausgestellte
Lizenzen). Eine Tagline hat er bewusst nicht: die Funktionsliste von TESM waere
dort falsch, und eine erfundene waere schlimmer als keine.

Version 2.0.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:44:21 +02:00
alientimandClaude Opus 5 313eb4bbf9 Leerlaufabmeldung: die zweite Schreibstelle und ein Tab, der das alte Skript faehrt
Die Abmeldung hat weiterhin nicht ausgeloest. Zwei Gruende, beide fuer sich
ausreichend:

* **save_session** schreibt ``idle_expires_at`` bei jeder Anfrage, die die
  Sitzung anfasst -- ein neu erzeugtes CSRF-Token genuegt. Die Pruefung in
  ``open_session`` lief damit ins Leere. Sitzungsdaten werden weiter
  gespeichert, nur die Fristen bleiben stehen. Dieselbe Stelle benutzte
  ausserdem ``idle_minutes`` statt der einstellbaren Zeit; bei der Vorgabe von
  dreissig Minuten fiel das nicht auf.
* **Der Kopf allein reicht nicht.** Er setzt voraus, dass der Browser das
  aktuelle Skript faehrt -- ein Tab, der seit gestern Abend offen ist, faehrt
  das alte und schickt ihn nicht. Genau dieser Tab ist der Fall, um den es
  geht. Der Server weiss es ohne Zutun des Browsers: die Endpunkte, die eine
  Kachel oder ein Protokoll nachlaedt, sind als Aktualisierungsausschnitt
  gekennzeichnet (``@fragment``) und gelten damit immer als automatisch.
  ``request.endpoint`` steht in ``open_session`` noch nicht -- Flask oeffnet
  die Sitzung vor dem Routing --, deshalb sieht die Erkennung dort selbst nach.

Dass es auf dem Lizenzserver zuverlaessig funktionierte, war der Hinweis: dort
pollt keine Kachel.

Dazu ein Kopf ``X-Session-Expired`` fuer Seiten, die auch oeffentlich
antworten. Die Statusuebersicht liefert nach dem Ablauf eine 200 mit den
oeffentlichen Kacheln -- am Status ist der Verlust der Sitzung also nicht zu
erkennen, und der offene Tab zeigte weiter eine Oberflaeche, als sei alles in
Ordnung. Jetzt laedt er neu und zeigt die Anmeldemaske.

Version 2.0.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:21:36 +02:00
alientimandClaude Opus 5 51ae39dd53 Upgrade von 1.x, Leerlaufabmeldung, Mitschrift im Audit
upgrade.sh -- ein Weg vom Stand SONNET5 auf diese Fassung, mit Daten. Der
Ablauf braucht keine Schalter: alte Installation sichern, abschalten, neu
installieren, exportieren, uebersetzen, einspielen. Die Sicherung steht
bewusst vor dem Export -- ohne --instance installiert install.sh in dasselbe
Verzeichnis, und wer erst exportiert und dann sichert, sichert im
schlechtesten Fall nichts mehr. Gelesen wird nur aus der Kopie.

Dafuer zwei neue Befehle: "legacy-export" liest eine alte Installation
unmittelbar (der Vorgaenger kann nur ueber die Weboberflaeche exportieren --
fuer ein Upgrade der falsche Weg, und unmoeglich, wenn die alte Anwendung nicht
mehr startet) und schreibt eine Datei im alten Format, die deshalb auch als
Rueckweg taugt. "legacy-import" liest eine Datei im neuen Format ein, ueber
transfer.apply_import -- dieselbe Funktion wie die Seite "Sicherung", kein
zweiter Schreibpfad.

Am Lizenzserver hat der Lauf gegen echte Altdaten zwei Fehler gezeigt, die
jede Uebernahme unmoeglich machten:

* Der alte Signaturschluessel ist URL-sicher kodiert (- und _). Das neue
  Protokoll prueft Base64 streng -- richtig so, aber beim Lesen einer alten
  Datei brach die Uebernahme mit einem Traceback ueber "kein gueltiges Base64"
  ab. Umkodiert wird jetzt an der Grenze zum Altformat; der kanonische Decoder
  bleibt streng.
* Der Anbieterblock wurde aus den Einstellungen der *neuen* Installation
  gebaut, und ohne Endpunkt brach die Uebernahme ab. Bei einem Upgrade ist die
  neue Installation frisch -- dort steht nichts. Endpunkt und Anbieterdaten
  kommen jetzt aus der alten Installation, aber nur dort, wo neu nichts steht.
  Der alte Signaturschluessel wird uebernommen: sonst muesste jeder Kunde
  einen Ausstellerwechsel bestaetigen.

Leerlaufabmeldung: sie hat nie ausgeloest. Ein offener Tab fragt seine
Kacheln alle 15 Sekunden neu ab, und jede Abfrage verlaengerte das
Leerlauffenster -- nachweisbar an einer Sitzung, deren last_seen_at eine
Minute alt war, obwohl seit zehn Stunden niemand am Rechner sass. Der Client
kennzeichnet automatische Anfragen jetzt mit einem Kopf, der Server
verlaengert daraufhin nichts, und last_seen_at bleibt, was der Name sagt.
Faellt die Sitzung waehrend des Pollens, antwortet der Server mit 401 statt
mit einer Umleitung: der Tab laedt neu und zeigt die Anmeldemaske, statt
stumm mit alten Zahlen stehenzubleiben.

Im Audit ist der Name der Mitschrift jetzt ein Verweis auf die Datei -- es ist
derselbe Weg wie zuvor, nur einen Klick weit. Verlinkt wird nur, wenn die
Datei existiert und der Betrachter Protokolle lesen darf; der Rest des
Eintrags wird maskiert, denn ein Auditeintrag enthaelt Fremdeingaben.

Ausserdem: Version 2.0.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 11:43:02 +02:00
alientimandClaude Opus 5 a8bd5763fb Aktualisierung: zwei Pruefungen, die nie zugegriffen haben
Der Lauf gegen die echte Installation hat zwei Fehler gezeigt, die den
Aktualisierungsweg fuer jede Version unbrauchbar machten:

* Die Suche nach install.sh im entpackten Paket lief mit "-maxdepth 2".
  release.sh packt mit dem Praefix "<name>/", das Skript liegt also drei
  Ebenen tief -- gefunden wurde nie etwas, und die Meldung schob es auf den
  Download ("Im Paket fehlt deploy/install.sh").
* Die CRLF-Pruefung rief "file" auf. Das Paket gehoert nicht zur
  Grundinstallation eines Servers; fehlt es, endete die Pipeline in "command
  not found", grep bekam nichts zu sehen, der if-Zweig wurde nicht betreten --
  und genau der Fehler, den die Pruefung abfangen soll, ging unbemerkt durch.
  Der Wagenruecklauf wird jetzt ohne Zusatzpaket gesucht, mit "-U": unter Linux
  ohne Wirkung, auf einer Windows-Buildmaschine entscheidend, weil grep dort
  den Wagenruecklauf beim Lesen entfernt.

Beide Pruefungen fuehren die echten Zeilen aus dem Skript aus -- die eine
gegen den Verzeichnisaufbau, den release.sh erzeugt, die andere gegen zwei
Dateien mit und ohne Wagenruecklauf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 00:16:59 +02:00
alientimandClaude Opus 5 d6ba98782e Release-Pakete gehoeren nicht ins Repository
Der gebaute Tarball lag im Arbeitsbaum und wanderte in den Commit; das
naechste Paket enthielt daraufhin das vorige -- doppelte Groesse,
verschachtelter Inhalt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 00:12:05 +02:00
alientimandClaude Opus 5 47ab97a0ac Aktualisierungsweg gangbar machen
Drei Fehler, die erst beim Aktualisieren auffallen -- also genau dann, wenn
niemand sie sehen will:

* update.sh zeigte auf "gitea.example.invalid" und den Besitzer "wis". Auf
  keinem Host konnte das funktionieren, und die Doku erwaehnte die Variablen
  nicht, mit denen man es haette umlenken koennen. Die Vorgaben zeigen jetzt
  dorthin, wo die Releases liegen; das Repository heisst wie die Anwendung.
* update.sh gab nichts an install.sh weiter. Eine benannte Instanz war damit
  nicht aktualisierbar: ohne --instance entsteht neben "tesm-opus" eine
  zweite Installation namens "tesm", mit eigener Datenbank und eigener
  nginx-Site.
* install.sh setzte bei einer Instanz die Ports auf 8080/8443 zurueck, auch
  wenn sie seit der Installation auf Port 80 lief. Ein Reverse Proxy davor
  haette danach ins Leere gezeigt. Bestehende Ports werden jetzt aus
  Umgebungsdatei und nginx-Site uebernommen -- dieselbe Regel wie beim
  Secure-Flag: eine Aktualisierung nimmt nichts weg, was nicht ausdruecklich
  neu gesetzt wird.

Zwei Tests fuehren dazu die echten Zeilen aus den Skripten aus, statt sie
nachzubauen. Beide fallen ohne die Korrektur durch.

Ausserdem: die vier subprocess-Aufrufe im Textmodus nageln die Kodierung auf
UTF-8 fest. "text=True" allein nimmt die Locale des Prozesses -- unter systemd
haeufig C/ASCII --, und das Lesen bricht ab, sobald ein Werkzeug einen Umlaut
ausgibt. Der Fehler entstand im Leser-Thread von subprocess und damit weit weg
von seiner Ursache; im Testlauf war er als Warnung sichtbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 00:11:21 +02:00
alientimandClaude Opus 5 fbbf8eb87b Umlaute in der Oberflaeche, Sitzungskopplung, Statusanzeigen
Echte Umlaute in allen Texten der Oberflaeche und in den Markdown-Dateien.
Die Einbindung von Dateifreigaben haengt jetzt an der Sitzung: wird sie
widerrufen oder laeuft sie ab, verschwinden die Freigaben mit ihr. Neu
einstellbar ist die automatische Abmeldung bei Leerlauf (Vorgabe 30 Minuten).
Ein Geraet, das nicht antwortet, bietet nur noch "Einschalten (PoE)" an --
Neustart ueber SSH oder RPC braucht ein erreichbares Geraet. Der Zeitgeber der
naechsten Pruefung und die Restlaufzeit der Lizenz stehen jetzt oben in der
Kopfzeile; der doppelte Knopf darunter ist weg. Millisekunden werden ab einer
Sekunde als Sekunden und ab einer Minute als m:ss dargestellt.

Ausserdem: Release-Tarball nicht mehr im Repository -- er haengt am Release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 23:54:33 +02:00
alientimandClaude Opus 5 9a760274f8 Herkunft eines gepinnten Ausstellers in Klartext
In der Spalte "Quelle" stand das Kuerzel des Protokolls: "tofu". Das ist
Fachjargon (Trust On First Use) und als Wert in einer Tabelle unbrauchbar --
es sieht aus wie ein Tippfehler.

Jetzt: "Erstkontakt" (beim ersten Einspielen automatisch uebernommen) und
"Bestaetigt" (Ausstellerwechsel von einem Administrator freigegeben).

Die Uebersetzung liegt in tesm_core.ui als ISSUER_SOURCES mit zwei
Jinja-Filtern, nicht in zwei Vorlagen: der Lizenzserver zeigt dieselbe Angabe
unter "Eigene Lizenz", und zwei Texte fuer denselben Sachverhalt laufen
auseinander. Unbekannte Werte bleiben unveraendert sichtbar statt hinter einem
Platzhalter zu verschwinden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 22:26:42 +02:00
alientimandClaude Opus 5 cf16809ee2 Kurze Hostnamen in Freigaben aufloesen, Protokolle am Ende oeffnen
Freigaben mit kurzem Hostnamen (\S2025\Share) scheiterten mit "could not
resolve address for S2025". Ursache: dieser Server hat keine Suchdomaene
(search . in resolv.conf), ein Windows-Client hat eine. Der Name ist also
richtig, nur hier nicht auflösbar. Die AD-Domaene ist aus den Einstellungen
des Verzeichnisdienstes bekannt und wird jetzt zum Vervollstaendigen benutzt;
laesst sich der Name gar nicht aufloesen, sagt die Meldung was zu tun ist
statt "Unknown error". Auf POETEST gegen echtes DNS geprueft:
\S2025\LDAP_Share -> \S2025.ad.eertmoed.net\LDAP_Share, und dieser Pfad
bindet ein.

Protokollansichten standen beim Oeffnen oben. Die neueste Zeile steht aber
unten, und weil die Aktualisierung nur mitzieht, wenn man ohnehin am Ende ist,
zog sie danach nie mit -- es sah aus wie ein kaputter Autoscroll, war aber nur
die falsche Anfangsposition. Die Protokollansichten sind jetzt per data-tail
markiert und springen beim Laden nach unten. Konfigurationsvorschauen (nginx,
Kea) und der Offline-Code sind bewusst nicht markiert: die beginnen oben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 22:21:42 +02:00
alientimandClaude Opus 5 d8368fc200 Windows-Konto: ein Backslash im Hinweis, toleranter Split
Der Hinweistext empfahl "DOMAENE\benutzer" mit zwei Backslashes. Genau so
wurde es dann eingetragen -- und der einfache split() schickte daraufhin
"\benutzer" an Windows. Die Anmeldung scheiterte mit LOGON_FAILURE bei formal
richtigen Zugangsdaten, und die Meldung zeigte auf die falsche Ursache. Der
Fehler in der Anleitung wurde damit zum Fehler in den Daten.

Behoben an beiden Enden: der Hinweis nennt jetzt ausdruecklich *einen*
Backslash, und der Split fasst mehrfache Backslashes zusammen, damit ein
bereits falsch eingetragenes Konto trotzdem funktioniert.

Dazu Tests, die die Anmeldedatei pruefen statt nur den Code: ein Backslash,
zwei Backslashes, Konto ohne Domaene, und die beiden Hinweistexte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 22:03:32 +02:00
alientimandClaude Opus 5 e343aee026 Dateifeld sichtbar machen, RPC-Fehler verstaendlich melden
Der Knopf im Dateifeld kommt vom Browser und ignoriert jede Aussenfarbe: im
dunklen Thema ein hellgraues Kaestchen mit dunkler Schrift, kaum zu lesen und
passend zu nichts. Jetzt explizit wie ein Sekundaerknopf gestaltet -- einmal
zentral, denn es betrifft vier Felder (Sicherung, Lizenz, Profilbild,
Dateifreigaben).

Dazu zwei neue Hinweise fuer den Windows-Neustart. "Could not initialise pipe
winreg / NT_STATUS_OBJECT_NAME_NOT_FOUND" liest sich wie ein technisches
Detail, hat aber fast immer eine Ursache: das Konto ist auf dem Zielgeraet kein
Administrator. An einem echten Fall nachgemessen -- die Anmeldung gelang, aber
schon "net rpc info" und der Dienstmanager antworteten mit ACCESS_DENIED. Ein
Standardkonto bekommt die winreg-Pipe gar nicht zu sehen. Die Meldung sagt das
jetzt und nennt den Pruefbefehl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:58:02 +02:00
alientimandClaude Opus 5 4065b3586b Freigaben einbinden, apt-Installation und RPC-Neustart korrigiert
Drei Fehler, alle drei gemessen statt vermutet.

1. Freigaben wurden nie eingebunden ("mount error(1): Operation not
   permitted"). Ursache war NICHT der Mount-Namensraum, sondern
   SystemCallFilter=@system-service: darin fehlen mount/umount2, und
   SystemCallErrorNumber=EPERM macht daraus genau diese Meldung. Nachgewiesen
   in einem nachgebauten Sandkasten: mit "@mount" gelingt die Einbindung,
   ohne nicht. Die Unit von TESM erlaubt jetzt "@system-service @mount"; alles
   andere an der Haertung bleibt. Der zuvor eingebaute Umweg ueber nsenter ist
   zurueckgenommen -- RestrictNamespaces=yes verbietet setns, er konnte nie
   funktionieren.

   Eingebunden wird damit im Namensraum des Webprozesses. Deshalb ist die
   Aufraeumaufgabe fuer abgelaufene Einbindungen aus dem Ueberwachungsdienst in
   den Webprozess gewandert: ein anderer Dienst sieht diese Einbindungen nicht
   und haette Datenbankzeilen als geloest markiert, waehrend die Freigabe
   eingebunden blieb.

2. apt-Installationen aus der Oberflaeche scheiterten mit "dpkg returned an
   error code (1)". Der Sandkasten wird an dpkg und dessen Maintainer-Skripte
   weitervererbt; mit ProtectKernelLogs=yes liefert "dmesg" ein EPERM und ein
   Postinst-Skript bricht daran ab. apt laeuft jetzt ueber systemd-run, also
   von PID 1 gestartet und damit ohne unsere Haertung.

3. Der RPC-Neustart loeste SSH aus. run_reboot entschied den Weg anhand von
   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 ein
   Windows-Geraet mit leerer Geraetekategorie an, wurde fuer Linux gehalten und
   ueber SSH auf Port 22 angesprochen, bis das Zeitlimit griff. Der Weg wird
   jetzt uebergeben -- entschieden wird er ohnehin schon vorher in
   restart.resolve. Ausserdem zeigte die Neustart-Historie alles ausser "ssh"
   als "PoE-Port"; RPC hat jetzt einen eigenen Eintrag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:49:40 +02:00
261 changed files with 42895 additions and 2155 deletions

No files matched your search

+6
View File
@@ -22,3 +22,9 @@ dist/
.pytest_cache/ .pytest_cache/
.ruff_cache/ .ruff_cache/
.mypy_cache/ .mypy_cache/
# 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
+62 -37
View File
@@ -1,12 +1,12 @@
# TESM # TESM
Verwaltung von PoE-Geraeten und Aruba-Switchen: Inventar, Zustandsueberwachung, Verwaltung von PoE-Geräten und Aruba-Switchen: Inventar, Zustandsüberwachung,
Portsteuerung, Neustart (PoE, SSH, RPC), DHCP ueber Kea, Dateifreigaben, Portsteuerung, Neustart (PoE, SSH, RPC), DHCP über Kea, Dateifreigaben,
Wartung ueber SSH, Protokolle, Papierkorb, verschluesselte Ein- und Ausfuhr und Wartung über SSH, Protokolle, Papierkorb, verschlüsselte Ein- und Ausfuhr und
ein SSH-Terminal im Browser. ein SSH-Terminal im Browser.
**Dieses Repository enthaelt genau das, was zum Installieren und Betreiben von **Dieses Repository enthält genau das, was zum Installieren und Betreiben von
TESM gebraucht wird** -- nichts darueber hinaus: TESM gebraucht wird** -- nichts darüber hinaus:
``` ```
apps/tesm die Anwendung apps/tesm die Anwendung
@@ -16,14 +16,15 @@ deploy/ install.sh, update.sh, release.sh, systemd, privileg
docs/ Betrieb und Sicherheit docs/ Betrieb und Sicherheit
``` ```
Die Serverhaelfte des Lizenzprotokolls (`tesm-licensing-server`: ausstellen, Die Serverhälfte des Lizenzprotokolls (`tesm-licensing-server`: ausstellen,
erneuern, Antworten signieren) liegt **nicht** hier. TESM verifiziert Lizenzen erneuern, Antworten signieren) liegt **nicht** hier. TESM verifiziert Lizenzen
und stellt Anfragen; den Code zum Ausstellen soll ein Client nicht einmal und stellt Anfragen; den Code zum Ausstellen soll ein Client nicht einmal
mitbringen. mitbringen.
| Wo sonst noch | | | Wo sonst noch | |
|---|---| |---|---|
| [alientim/tesm-license](https://gitea.int.eertmoed.net/alientim/tesm-license) | der Lizenzserver | | [alientim/tesm-license](https://gitea.int.eertmoed.net/alientim/tesm-license) | der Lizenzserver (Sub-Instanzen) |
| [alientim/tesm-license-master](https://gitea.int.eertmoed.net/alientim/tesm-license-master) | Lizenzserver, Master-Ausgabe (privat) |
| [alientim/TESM-DEV](https://gitea.int.eertmoed.net/alientim/TESM-DEV) | Entwicklung: beide Anwendungen, Tests, Architekturdoku | | [alientim/TESM-DEV](https://gitea.int.eertmoed.net/alientim/TESM-DEV) | Entwicklung: beide Anwendungen, Tests, Architekturdoku |
## Zweige ## Zweige
@@ -31,21 +32,29 @@ mitbringen.
| Zweig | Inhalt | | Zweig | Inhalt |
|---|---| |---|---|
| `main` | dieser Neubau (Version 2.0.0) | | `main` | dieser Neubau (Version 2.0.0) |
| `SONNET5` | der Stand davor, unveraendert aufbewahrt | | `SONNET5` | der Stand davor, unverändert aufbewahrt |
--- ---
## Installation ## Installation
```bash ```bash
sudo bash deploy/install.sh --app tesm --https sudo bash install.sh --app tesm --https
sudo -u tesm tesm-admin create-admin sudo -u tesm tesm-admin create-admin
``` ```
Danach `https://<host>/`. Ohne `--domain` entsteht ein selbstsigniertes `install.sh` liegt oben im Paket und genügt auch **allein**: ohne Paket
Zertifikat; der Browser warnt einmal, die Verbindung ist verschluesselt. drumherum lädt es sich das aktuelle Release selbst.
Mit oeffentlichem Namen und Let's Encrypt stattdessen: ```bash
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/install.sh
sudo bash install.sh --app tesm --https
```
Danach `https://<host>/`. Ohne `--domain` entsteht ein selbstsigniertes
Zertifikat; der Browser warnt einmal, die Verbindung ist verschlüsselt.
Mit öffentlichem Namen und Let's Encrypt stattdessen:
```bash ```bash
sudo bash deploy/install.sh --app tesm --https --domain tesm.firma.de --acme-email it@firma.de sudo bash deploy/install.sh --app tesm --https --domain tesm.firma.de --acme-email it@firma.de
@@ -54,16 +63,16 @@ sudo bash deploy/install.sh --app tesm --https --domain tesm.firma.de --acme-ema
Zielzustand: **Port 443 mit TLS, Port 80 leitet dorthin um** -- ausser dem Zielzustand: **Port 443 mit TLS, Port 80 leitet dorthin um** -- ausser dem
ACME-Pfad, der erreichbar bleiben muss, sonst kann Let's Encrypt nie erneuern. ACME-Pfad, der erreichbar bleiben muss, sonst kann Let's Encrypt nie erneuern.
Sitzt ein eigener Reverse Proxy davor, der TLS beendet, laeuft TESM ohne Sitzt ein eigener Reverse Proxy davor, der TLS beendet, läuft TESM ohne
`--https` schlicht auf Port 80. `--https` schlicht auf Port 80.
Eine zweite Installation zum Testen stoert die laufende nicht: Eine zweite Installation zum Testen stört die laufende nicht:
```bash ```bash
sudo bash deploy/install.sh --app tesm --instance test --port 5100 --http-port 8080 sudo bash deploy/install.sh --app tesm --instance test --port 5100 --http-port 8080
``` ```
Jede Instanz bekommt eigene Datenbank, eigene Schluessel, eigene nginx-Site und Jede Instanz bekommt eigene Datenbank, eigene Schlüssel, eigene nginx-Site und
ein eigenes Sitzungscookie. ein eigenes Sitzungscookie.
### Aktualisieren ### Aktualisieren
@@ -72,9 +81,9 @@ ein eigenes Sitzungscookie.
sudo bash deploy/update.sh --app tesm sudo bash deploy/update.sh --app tesm
``` ```
Das laedt ein Release, entpackt es und ruft dessen `install.sh` auf. Vor jedem Das lädt ein Release, entpackt es und ruft dessen `install.sh` auf. Vor jedem
Update entsteht ein vollstaendiges Backup -- unabhaengig davon, ob sich das Update entsteht ein vollständiges Backup -- unabhängig davon, ob sich das
Schema geaendert hat -- und danach laeuft ein Health-Check mit automatischem Schema geändert hat -- und danach läuft ein Health-Check mit automatischem
Rollback. Rollback.
--- ---
@@ -83,15 +92,15 @@ Rollback.
```bash ```bash
sudo -u tesm tesm-admin status # Zustand der Installation sudo -u tesm tesm-admin status # Zustand der Installation
sudo -u tesm tesm-admin create-admin # Konto anlegen oder zuruecksetzen sudo -u tesm tesm-admin create-admin # Konto anlegen oder zurücksetzen
sudo -u tesm tesm-admin check-now # Ueberwachungsdurchlauf sofort sudo -u tesm tesm-admin check-now # Überwachungsdurchlauf sofort
sudo -u tesm tesm-admin housekeeping # Sitzungen und Protokolle aufraeumen sudo -u tesm tesm-admin housekeeping # Sitzungen und Protokolle aufräumen
sudo -u tesm tesm-admin web-setup --https # nginx und TLS einrichten sudo -u tesm tesm-admin web-setup --https # nginx und TLS einrichten
``` ```
### Umzug aus dem alten TESM ### Umzug aus dem alten TESM
Ein Export des Vorgaengers laesst sich uebersetzen und dann ueber den normalen Ein Export des Vorgängers lässt sich übersetzen und dann über den normalen
Import einlesen -- Clients, Switche, Zugangsdaten und die DHCP-Konfiguration: Import einlesen -- Clients, Switche, Zugangsdaten und die DHCP-Konfiguration:
```bash ```bash
@@ -100,11 +109,11 @@ sudo -u tesm tesm-admin convert-legacy tesm_export_20260813_195455.json --dry-ru
`--dry-run` zeigt nur den Bericht. Ohne die Option entsteht eine Datei im `--dry-run` zeigt nur den Bericht. Ohne die Option entsteht eine Datei im
neuen Format, die unter *Verwaltung -> Sicherung* eingelesen wird. Der Bericht neuen Format, die unter *Verwaltung -> Sicherung* eingelesen wird. Der Bericht
nennt ausdruecklich, was **nicht** uebersetzt werden kann (lokale Konten, nennt ausdrücklich, was **nicht** übersetzt werden kann (lokale Konten,
Rechte, Verzeichnisdienst, Webserver) -- das ist von Hand nachzuziehen. Rechte, Verzeichnisdienst, Webserver) -- das ist von Hand nachzuziehen.
Die Passphrase gehoert an die verdeckte Eingabeaufforderung. `--passphrase` Die Passphrase gehört an die verdeckte Eingabeaufforderung. `--passphrase`
existiert nur fuer Automatisierung: es schreibt sie in Shell-Historie und existiert nur für Automatisierung: es schreibt sie in Shell-Historie und
Prozessliste. Prozessliste.
--- ---
@@ -135,20 +144,36 @@ Entwicklungsrepository, ebenso die Tests.
## Was diese Fassung anders macht ## Was diese Fassung anders macht
Ein Neubau. Die Fehler des Vorgaengers sind bekannt; an den Stellen, an denen Ein Neubau. Die Fehler des Vorgängers sind bekannt; an den Stellen, an denen
sie entstanden sind, steht jetzt ein Kommentar, der erklaert, warum es hier sie entstanden sind, steht jetzt ein Kommentar, der erklärt, warum es hier
anders geloest ist. Die wichtigsten: anders gelöst ist. Die wichtigsten:
* **Kein root fuer die Anwendung.** Ein einziger allowlisted Helfer mit * **Kein root für die Anwendung.** Ein einziger allowlisted Helfer mit
Argumentpruefung statt eines Webprozesses als `root`. Argumentprüfung statt eines Webprozesses als `root`.
* **CSRF-Schutz ueberhaupt.** Der Vorgaenger hatte keinen. * **CSRF-Schutz überhaupt.** Der Vorgänger hatte keinen.
* **Rechte werden serverseitig geprueft**, nicht nur im Template ausgeblendet. * **Rechte werden serverseitig geprüft**, nicht nur im Template ausgeblendet.
* **Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert.** Ein * **Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert.** Ein
falscher `alias`-Pfad ueberlebte im Vorgaenger jahrelang jedes Update. falscher `alias`-Pfad überlebte im Vorgänger jahrelang jedes Update.
* **Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft** -- nginx wie * **Jede erzeugte Konfiguration wird vor dem Übernehmen geprüft** -- nginx wie
Kea. Eine fehlerhafte Datei faellt beim Schreiben auf, nicht erst beim Kea. Eine fehlerhafte Datei fällt beim Schreiben auf, nicht erst beim
naechsten Start des Dienstes. nächsten Start des Dienstes.
* **Kein Hostname im Lizenz-Fingerabdruck.** Umbenennen brach die Bindung. * **Kein Hostname im Lizenz-Fingerabdruck.** Umbenennen brach die Bindung.
* **Zwei Installationen stoeren sich nicht** -- eigener `SITE_KEY` fuer * **Zwei Installationen stören sich nicht** -- eigener `SITE_KEY` für
nginx-Site, netplan, logrotate, Zertifikate und Sitzungscookie. nginx-Site, netplan, logrotate, Zertifikate und Sitzungscookie.
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock. * **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
---
## Von einer Installation der Fassung 1.x
Eine einzelne Datei genügt -- sie lädt das Release selbst:
```bash
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
sudo bash upgrade.sh --app tesm
```
Sichert die alte Installation, schaltet sie ab, installiert diese Fassung und
übernimmt die Daten. Einzelheiten und was **nicht** mitkommt:
[docs/BETRIEB.md](docs/BETRIEB.md).
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm" name = "tesm"
version = "2.0.0" version = "2.8.16"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+119 -17
View File
@@ -19,11 +19,24 @@ from tesm_core.extension import core
from tesm_core.views import trash as trash_views from tesm_core.views import trash as trash_views
from .navigation import NAVIGATION from .navigation import NAVIGATION
from .permissions import PERMISSIONS, ROLE_PRESETS from .permissions import DHCP_PAGE, PERMISSIONS, ROLE_PRESETS
from .schema import TESM_MIGRATIONS from .schema import TESM_MIGRATIONS
from .settings_spec import TESM_SETTINGS from .settings_spec import TESM_SETTINGS
__version__ = "2.0.0" #: 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_KEY = "tesm"
APP_NAME = "TESM" APP_NAME = "TESM"
@@ -32,10 +45,12 @@ APP_EDITION = "Geraeteverwaltung"
#: Funktionszeile unter der Wortmarke auf der Anmeldeseite. Uebernommen vom #: Funktionszeile unter der Wortmarke auf der Anmeldeseite. Uebernommen vom
#: Vorgaenger, wo sie Teil des Hintergrundmotivs war -- sie sagt in einer Zeile, #: Vorgaenger, wo sie Teil des Hintergrundmotivs war -- sie sagt in einer Zeile,
#: was die Anwendung tut, und das gehoert auf die erste Seite. #: was die Anwendung tut, und das gehoert auf die erste Seite.
AUTH_TAGLINE = ( #: Zeile unter der Wortmarke auf den Anmeldeseiten. Kurz gehalten: sie ist
"UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · " #: eine Marke, keine Funktionsliste. "(PXE)" stand ausserdem fuer etwas, das
"BETRIEBSSYSTEM GEBEN (PXE)" #: 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_" ENV_PREFIX = "TESM_"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -85,6 +100,7 @@ def _register_blueprints(app: Flask) -> None:
settings, settings,
switches, switches,
hostkeys, hostkeys,
screen,
terminal, terminal,
) )
@@ -101,6 +117,7 @@ def _register_blueprints(app: Flask) -> None:
app.register_blueprint(license_bp.bp) app.register_blueprint(license_bp.bp)
app.register_blueprint(backup.bp) app.register_blueprint(backup.bp)
terminal.init_app(app) terminal.init_app(app)
screen.init_app(app)
# Die Statusuebersicht ist die Startseite; ``index`` bleibt als Endpunkt # Die Statusuebersicht ist die Startseite; ``index`` bleibt als Endpunkt
# bestehen, weil der Kern (Weiterleitungen, Fehlerseiten) darauf verweist. # bestehen, weil der Kern (Weiterleitungen, Fehlerseiten) darauf verweist.
@@ -149,7 +166,7 @@ def _mount_shares_after_login(conn: Any, user: Any, secret: str, group_dns: tupl
group_dns=group_dns, group_dns=group_dns,
) )
for message in messages: for message in messages:
current_app.logger.info("Dateifreigabe fuer %s -- %s", user.username, message) current_app.logger.info("Dateifreigabe für %s | %s", user.username, message)
def _release_shares_on_logout(conn: Any, user: Any) -> None: def _release_shares_on_logout(conn: Any, user: Any) -> None:
@@ -159,14 +176,19 @@ def _release_shares_on_logout(conn: Any, user: Any) -> None:
dem Abmelden bestehen, waere sein Zugriff weiter offen -- bis irgendwann dem Abmelden bestehen, waere sein Zugriff weiter offen -- bis irgendwann
die Zeitgrenze greift. Das ist zu lange. die Zeitgrenze greift. Das ist zu lange.
""" """
from flask import session
from .services import fileshare from .services import fileshare
extension = core() 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): with extension.database.transaction(conn):
released = fileshare.release_all_for_user(conn, user.id) released = fileshare.release_all_for_user(conn, user.id, sitzung)
if released: if released:
current_app.logger.info( current_app.logger.info(
"Dateifreigaben fuer %s geloest: %d", user.username, released "Dateifreigaben für %s gelöst: %d", user.username, released
) )
@@ -181,7 +203,31 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
extension = core() extension = core()
pills: list[dict[str, str]] = [] pills: list[dict[str, str]] = []
counts = monitor.dashboard_counts(db()) 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"]: if counts["offline"]:
pills.append( pills.append(
{ {
@@ -189,11 +235,11 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
"css_class": "status-pill--danger", "css_class": "status-pill--danger",
"dot": "1", "dot": "1",
"dot_class": "status-pill__dot--pulse", "dot_class": "status-pill__dot--pulse",
"title": "Geraete, die beim letzten Durchlauf nicht geantwortet haben.", "title": "Geräte, die beim letzten Durchlauf nicht geantwortet haben.",
} }
) )
if user.has_any(("dhcp.view", "dhcp.edit")) and extension.license is not None: if user.has_any(DHCP_PAGE) and extension.license is not None:
if extension.license.module_active("dhcp") and extension.sysops is not None: if extension.license.module_active("dhcp") and extension.sysops is not None:
state = extension.sysops.service_state(kea.SERVICE_UNIT) state = extension.sysops.service_state(kea.SERVICE_UNIT)
if state["installed"] == "ja": if state["installed"] == "ja":
@@ -226,7 +272,7 @@ def _register_trash_providers(app: Flask) -> None:
conn, "devices", int(ident), unique_column="mac" conn, "devices", int(ident), unique_column="mac"
), ),
purge_fn=lambda conn, ident: inventory.purge_row(conn, "devices", int(ident)), purge_fn=lambda conn, ident: inventory.purge_row(conn, "devices", int(ident)),
hint="Die MAC-Adresse bleibt reserviert, solange das Geraet hier liegt.", hint="Die MAC-Adresse bleibt reserviert, solange das Gerät hier liegt.",
) )
) )
trash_views.register( trash_views.register(
@@ -255,7 +301,7 @@ def _register_trash_providers(app: Flask) -> None:
conn, "credentials", int(ident), unique_column="name" conn, "credentials", int(ident), unique_column="name"
), ),
purge_fn=lambda conn, ident: inventory.purge_row(conn, "credentials", int(ident)), purge_fn=lambda conn, ident: inventory.purge_row(conn, "credentials", int(ident)),
hint="Endgueltiges Loeschen entfernt auch das gespeicherte Passwort.", hint="Endgültiges Löschen entfernt auch das gespeicherte Passwort.",
) )
) )
@@ -271,22 +317,78 @@ def _startup_tasks(app: Flask) -> None:
extension = core(app) extension = core(app)
if not extension.config.web_process: if not extension.config.web_process:
logger.info( logger.info(
"Startaufgaben uebersprungen -- dieser Prozess ist nicht als Webprozess markiert." "Startaufgaben übersprungen – dieser Prozess ist nicht als Webprozess markiert."
) )
return return
from .services import maintenance from .services import fileshare, maintenance
with extension.database.session() as conn, extension.database.transaction(conn): with extension.database.session() as conn, extension.database.transaction(conn):
orphaned = maintenance.mark_orphaned_jobs(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: if orphaned:
logger.warning("%s unterbrochene Wartungsauftraege als unklar markiert", 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: if extension.license is not None:
import socket import socket
extension.license.start_heartbeat(hostname=socket.gethostname()) 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`` # Fuer ``gunicorn tesm:app``
def _lazy_app() -> Flask: # pragma: no cover - nur vom WSGI-Server benutzt def _lazy_app() -> Flask: # pragma: no cover - nur vom WSGI-Server benutzt
+24 -24
View File
@@ -13,7 +13,7 @@ from tesm_core.licensing_client import LicenseError
from ..services import transfer from ..services import transfer
bp = Blueprint("backup", __name__, url_prefix="/sicherung") bp = Blueprint("backup", __name__, url_prefix="/administration/backup")
MAX_IMPORT_BYTES = 64 * 1024 * 1024 MAX_IMPORT_BYTES = 64 * 1024 * 1024
PREVIEW_SESSION_KEY = "_import_token" PREVIEW_SESSION_KEY = "_import_token"
@@ -37,14 +37,14 @@ def _allowed_categories() -> list[transfer.Category]:
for category in transfer.CATEGORIES: for category in transfer.CATEGORIES:
if category.admin_only and not current_user.is_admin: if category.admin_only and not current_user.is_admin:
continue continue
if category.sensitive and not current_user.has("backup.secrets"): if category.sensitive and not current_user.has("backup_export.secrets"):
continue continue
allowed.append(category) allowed.append(category)
return allowed return allowed
@bp.get("/") @bp.get("/")
@permission_required("backup.view", "backup.export", "backup.execute", mode="any") @permission_required("backup_export.read", "backup_import.read", mode="any")
def overview(): def overview():
extension = core() extension = core()
conn = db() conn = db()
@@ -72,13 +72,13 @@ def overview():
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH, min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
preview_ttl=transfer.PREVIEW_TTL_MINUTES, preview_ttl=transfer.PREVIEW_TTL_MINUTES,
license_hint=license_hint, license_hint=license_hint,
can_secrets=current_user.has("backup.secrets"), can_secrets=current_user.has("backup_export.secrets"),
) )
@bp.post("/export") @bp.post("/export")
@permission_required("backup.export") @permission_required("backup_export.export")
@reauth_required # Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def export(): def export():
extension = core() extension = core()
conn = db() conn = db()
@@ -92,16 +92,16 @@ def export():
allowed = {category.key for category in _allowed_categories()} allowed = {category.key for category in _allowed_categories()}
chosen = [key for key in request.form.getlist("categories") if key in allowed] chosen = [key for key in request.form.getlist("categories") if key in allowed]
if not chosen: if not chosen:
flash("Es wurde keine Kategorie ausgewaehlt.", "warning") flash("Es wurde keine Kategorie ausgewählt.", "warning")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
include_secrets = ( include_secrets = (
request.form.get("include_secrets") == "1" and current_user.has("backup.secrets") request.form.get("include_secrets") == "1" and current_user.has("backup_export.secrets")
) )
passphrase = request.form.get("passphrase") or "" passphrase = request.form.get("passphrase") or ""
repeat = request.form.get("passphrase_repeat") or "" repeat = request.form.get("passphrase_repeat") or ""
if passphrase != repeat: if passphrase != repeat:
flash("Die beiden Passphrasen stimmen nicht ueberein.", "danger") flash("Die beiden Passphrasen stimmen nicht überein.", "danger")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
try: try:
@@ -129,18 +129,18 @@ def export():
) )
@bp.post("/import/vorschau") @bp.post("/import/preview")
@permission_required("backup.execute") @permission_required("backup_import.execute")
@reauth_required @reauth_required
def import_preview(): def import_preview():
conn = db() conn = db()
upload = request.files.get("backup_file") upload = request.files.get("backup_file")
if upload is None or not upload.filename: if upload is None or not upload.filename:
flash("Keine Datei ausgewaehlt.", "danger") flash("Keine Datei ausgewählt.", "danger")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
raw = upload.stream.read(MAX_IMPORT_BYTES + 1) raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
if len(raw) > MAX_IMPORT_BYTES: if len(raw) > MAX_IMPORT_BYTES:
flash("Die Datei ist zu gross.", "danger") flash("Die Datei ist zu groß.", "danger")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
try: try:
@@ -154,28 +154,28 @@ def import_preview():
session[PREVIEW_SESSION_KEY] = token session[PREVIEW_SESSION_KEY] = token
_audit("backup.import_previewed", f"Erstellt {header.get('created_at')}", severity="notice") _audit("backup.import_previewed", f"Erstellt {header.get('created_at')}", severity="notice")
flash( flash(
f"Datei geoeffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die " f"Datei geöffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
"Uebernahme zu bestaetigen.", "Übernahme zu bestätigen.",
"success", "success",
) )
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
@bp.post("/import/uebernehmen") @bp.post("/import/apply")
@permission_required("backup.execute") @permission_required("backup_import.execute")
@reauth_required @reauth_required
def import_apply(): def import_apply():
extension = core() extension = core()
conn = db() conn = db()
token = session.get(PREVIEW_SESSION_KEY, "") token = session.get(PREVIEW_SESSION_KEY, "")
if not token: if not token:
flash("Es liegt keine geoeffnete Sicherung vor.", "danger") flash("Es liegt keine geöffnete Sicherung vor.", "danger")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
allowed = {category.key for category in _allowed_categories()} allowed = {category.key for category in _allowed_categories()}
chosen = [key for key in request.form.getlist("categories") if key in allowed] chosen = [key for key in request.form.getlist("categories") if key in allowed]
if not chosen: if not chosen:
flash("Es wurde keine Kategorie ausgewaehlt.", "warning") flash("Es wurde keine Kategorie ausgewählt.", "warning")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
try: try:
@@ -206,7 +206,7 @@ def import_apply():
action="backup.imported", action="backup.imported",
detail="; ".join( detail="; ".join(
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, " f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
f"{counts['skipped']} uebersprungen" f"{counts['skipped']} übersprungen"
for key, counts in report.items() for key, counts in report.items()
), ),
ip=client_ip(), ip=client_ip(),
@@ -217,16 +217,16 @@ def import_apply():
lines = [ lines = [
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, " f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
f"{counts['updated']} aktualisiert" f"{counts['updated']} aktualisiert"
+ (f", {counts['skipped']} uebersprungen" if counts["skipped"] else "") + (f", {counts['skipped']} übersprungen" if counts["skipped"] else "")
for key, counts in report.items() for key, counts in report.items()
if key in transfer.CATEGORY_BY_KEY if key in transfer.CATEGORY_BY_KEY
] ]
flash("Uebernahme abgeschlossen. " + " | ".join(lines), "success") flash("Übernahme abgeschlossen. " + " – ".join(lines), "success")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
@bp.post("/import/verwerfen") @bp.post("/import/discard")
@permission_required("backup.execute") @permission_required("backup_import.execute")
def import_discard(): def import_discard():
token = session.pop(PREVIEW_SESSION_KEY, "") token = session.pop(PREVIEW_SESSION_KEY, "")
if token: if token:
+9 -9
View File
@@ -6,13 +6,13 @@ from typing import Any
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from tesm_core import audit from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required, reauth_required from tesm_core.auth.models import current_user, permission_required
from tesm_core.auth.service import client_ip from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction from tesm_core.extension import core, db, transaction
from ..services import inventory from ..services import inventory
bp = Blueprint("credentials", __name__, url_prefix="/zugangsdaten") bp = Blueprint("credentials", __name__, url_prefix="/monitoring/credentials")
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None: def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
@@ -29,7 +29,7 @@ def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str
@bp.get("/") @bp.get("/")
@permission_required("credentials.view") @permission_required("credentials.read")
def overview(): def overview():
conn = db() conn = db()
return render_template( return render_template(
@@ -39,7 +39,7 @@ def overview():
) )
@bp.post("/neu") @bp.post("/new")
@permission_required("credentials.create") @permission_required("credentials.create")
def create(): def create():
try: try:
@@ -62,7 +62,7 @@ def create():
@bp.post("/<int:credential_id>") @bp.post("/<int:credential_id>")
@permission_required("credentials.edit") @permission_required("credentials.write")
def update(credential_id: int): def update(credential_id: int):
try: try:
with transaction() as conn: with transaction() as conn:
@@ -85,13 +85,13 @@ def update(credential_id: int):
"; ".join(changes), "; ".join(changes),
severity="notice", severity="notice",
) )
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info") flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
except inventory.ValidationError as exc: except inventory.ValidationError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("credentials.overview")) return redirect(url_for("credentials.overview"))
@bp.post("/<int:credential_id>/loeschen") @bp.post("/<int:credential_id>/delete")
@permission_required("credentials.delete") @permission_required("credentials.delete")
def delete(credential_id: int): def delete(credential_id: int):
try: try:
@@ -109,9 +109,9 @@ def delete(credential_id: int):
return redirect(url_for("credentials.overview")) return redirect(url_for("credentials.overview"))
@bp.post("/<int:credential_id>/anzeigen") @bp.post("/<int:credential_id>/reveal")
@permission_required("credentials.secrets") @permission_required("credentials.secrets")
@reauth_required # Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def reveal(credential_id: int): def reveal(credential_id: int):
"""Zeigt ein gespeichertes Passwort im Klartext. """Zeigt ein gespeichertes Passwort im Klartext.
+33 -6
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from flask import Blueprint, abort, render_template, request from flask import Blueprint, abort, render_template, request
from tesm_core.security.sessions import fragment
from tesm_core.extension import core, db from tesm_core.extension import core, db
from ..services import monitor from ..services import monitor
@@ -38,11 +39,21 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
from ..services import inventory, restart from ..services import inventory, restart
from ..services import vnc as vnc_dienst
conn = db() conn = db()
counts = monitor.dashboard_counts(conn) counts = monitor.dashboard_counts(conn)
rows = inventory.list_devices(conn, only_active=not authenticated) rows = inventory.list_devices(conn, only_active=not authenticated)
detailed = authenticated and current_user.has("devices.view") 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]] = [] tiles: list[dict[str, Any]] = []
for row in rows: for row in rows:
entry: dict[str, Any] = { entry: dict[str, Any] = {
@@ -66,9 +77,24 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
"credential_category": str(row["credential_category"] or ""), "credential_category": str(row["credential_category"] or ""),
# Nicht nur PoE: ein Geraet kann auch ueber SSH oder RPC # Nicht nur PoE: ein Geraet kann auch ueber SSH oder RPC
# neu startbar sein. Die Kachel zeigt den bevorzugten Weg. # 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( "restart_methods": restart.available(
row, maintenance_active=_maintenance_active() 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) tiles.append(entry)
@@ -79,7 +105,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
# erwartbar weg. Wer die Uebersicht ansieht, will genau das zuerst # erwartbar weg. Wer die Uebersicht ansieht, will genau das zuerst
# wissen -- sonst haelt er einen laufenden Neustart fuer einen Ausfall. # wissen -- sonst haelt er einen laufenden Neustart fuer einen Ausfall.
"key": "restarting", "key": "restarting",
"label": "Neustart laeuft", "label": "Neustart läuft",
"kind": "warning", "kind": "warning",
"tiles": [tile for tile in tiles if tile["state"] == "restarting"], "tiles": [tile for tile in tiles if tile["state"] == "restarting"],
}, },
@@ -97,7 +123,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
}, },
{ {
"key": "unknown", "key": "unknown",
"label": "Noch nicht geprueft", "label": "Noch nicht geprüft",
"kind": "warning", "kind": "warning",
"tiles": [tile for tile in tiles if tile["state"] == "unknown"], "tiles": [tile for tile in tiles if tile["state"] == "unknown"],
}, },
@@ -120,7 +146,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
} }
@bp.get("/uebersicht") @bp.get("/overview")
def index(): def index():
from tesm_core.auth.models import current_user from tesm_core.auth.models import current_user
@@ -137,7 +163,8 @@ def index_view():
return index() return index()
@bp.get("/uebersicht/kacheln") @bp.get("/overview/tiles")
@fragment
def tiles(): def tiles():
"""Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment.""" """Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment."""
from tesm_core.auth.models import current_user from tesm_core.auth.models import current_user
+132 -35
View File
@@ -11,16 +11,25 @@ from __future__ import annotations
from typing import Any from typing import Any
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for from flask import (
Blueprint,
Response,
abort,
flash,
redirect,
render_template,
request,
url_for,
)
from tesm_core import audit from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required, reauth_required from tesm_core.auth.models import current_user, permission_required, reauth_required
from tesm_core.auth.service import client_ip from tesm_core.auth.service import client_ip
from tesm_core.db import Database from tesm_core.db import Database
from tesm_core.extension import core, db, transaction from tesm_core.extension import core, db, transaction
from ..services import inventory, monitor, restart, ssh from ..services import inventory, monitor, restart, ssh, vnc
bp = Blueprint("devices", __name__, url_prefix="/clients") bp = Blueprint("devices", __name__, url_prefix="/monitoring/clients")
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None: def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
@@ -60,7 +69,7 @@ def _form_values() -> dict[str, Any]:
@bp.get("/") @bp.get("/")
@permission_required("devices.view") @permission_required("devices.read")
def overview(): def overview():
conn = db() conn = db()
devices = inventory.list_devices(conn) devices = inventory.list_devices(conn)
@@ -100,7 +109,7 @@ def _credential_from_form(conn: Any) -> int | None:
) )
name = (request.form.get("new_credential_name") or "").strip() name = (request.form.get("new_credential_name") or "").strip()
if not name: if not name:
raise inventory.ValidationError("Fuer neue Zugangsdaten wird eine Bezeichnung gebraucht.") raise inventory.ValidationError("Für neue Zugangsdaten wird eine Bezeichnung gebraucht.")
return inventory.create_credential( return inventory.create_credential(
conn, conn,
core().keystore, core().keystore,
@@ -108,12 +117,12 @@ def _credential_from_form(conn: Any) -> int | None:
username=(request.form.get("new_credential_username") or "").strip(), username=(request.form.get("new_credential_username") or "").strip(),
secret=request.form.get("new_credential_secret") or "", secret=request.form.get("new_credential_secret") or "",
category=request.form.get("new_credential_category", "linux"), category=request.form.get("new_credential_category", "linux"),
description="Beim Anlegen eines Geraets erfasst.", description="Beim Anlegen eines Geräts erfasst.",
actor=current_user.username, actor=current_user.username,
) )
@bp.post("/neu") @bp.post("/new")
@permission_required("devices.create") @permission_required("devices.create")
def create(): def create():
try: try:
@@ -130,7 +139,7 @@ def create():
f"MAC {request.form.get('mac')}, IP {request.form.get('ip')}", f"MAC {request.form.get('mac')}, IP {request.form.get('ip')}",
severity="notice", severity="notice",
) )
flash("Geraet angelegt.", "success") flash("Gerät angelegt.", "success")
return redirect(url_for("devices.overview", highlight=device_id)) return redirect(url_for("devices.overview", highlight=device_id))
except inventory.ValidationError as exc: except inventory.ValidationError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
@@ -138,7 +147,7 @@ def create():
@bp.post("/<int:device_id>") @bp.post("/<int:device_id>")
@permission_required("devices.edit") @permission_required("devices.write")
def update(device_id: int): def update(device_id: int):
try: try:
with transaction() as conn: with transaction() as conn:
@@ -155,14 +164,14 @@ def update(device_id: int):
"; ".join(changes), "; ".join(changes),
severity="notice", severity="notice",
) )
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info") flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
except inventory.ValidationError as exc: except inventory.ValidationError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("devices.overview")) return redirect(url_for("devices.overview"))
@bp.post("/<int:device_id>/aktiv") @bp.post("/<int:device_id>/active")
@permission_required("devices.edit") @permission_required("devices.write")
def toggle_active(device_id: int): def toggle_active(device_id: int):
active = request.form.get("active") == "1" active = request.form.get("active") == "1"
with transaction() as conn: with transaction() as conn:
@@ -176,11 +185,11 @@ def toggle_active(device_id: int):
str(device["name"]), str(device["name"]),
severity="notice", severity="notice",
) )
flash("Geraet aktiviert." if active else "Geraet deaktiviert.", "success") flash("Gerät aktiviert." if active else "Gerät deaktiviert.", "success")
return redirect(request.referrer or url_for("devices.overview")) return redirect(request.referrer or url_for("devices.overview"))
@bp.post("/<int:device_id>/loeschen") @bp.post("/<int:device_id>/delete")
@permission_required("devices.delete") @permission_required("devices.delete")
def delete(device_id: int): def delete(device_id: int):
with transaction() as conn: with transaction() as conn:
@@ -189,12 +198,17 @@ def delete(device_id: int):
abort(404) abort(404)
inventory.delete_device(conn, device_id) inventory.delete_device(conn, device_id)
_audit(conn, "device.deleted", str(device["name"]), severity="warning") _audit(conn, "device.deleted", str(device["name"]), severity="warning")
flash("Geraet in den Papierkorb verschoben.", "success") flash("Gerät in den Papierkorb verschoben.", "success")
return redirect(url_for("devices.overview")) return redirect(url_for("devices.overview"))
@bp.post("/<int:device_id>/neustart") @bp.post("/<int:device_id>/restart")
@permission_required("devices.execute") # 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): def restart_device(device_id: int):
"""Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde. """Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde.
@@ -214,19 +228,22 @@ def restart_device(device_id: int):
dict(device), dict(device),
(request.form.get("method") or "").strip(), (request.form.get("method") or "").strip(),
maintenance_active=maintenance_active, maintenance_active=maintenance_active,
darf=current_user.has,
) )
if method is None: if method is None:
options = restart.available(dict(device), maintenance_active=maintenance_active) options = restart.available(
dict(device), maintenance_active=maintenance_active, darf=current_user.has
)
if options: if options:
flash( flash(
"Der gewaehlte Weg steht fuer dieses Geraet nicht zur Verfuegung. " "Der gewählte Weg steht für dieses Gerät nicht zur Verfügung. "
"Moeglich waere: " + ", ".join(entry.label for entry in options), "Möglich wäre: " + ", ".join(entry.label for entry in options),
"warning", "warning",
) )
else: else:
flash( flash(
f"Fuer {device['name']} ist kein Neustart moeglich: es fehlen Switch und Port " f"Für {device['name']} ist kein Neustart möglich: es fehlen Switch und Port "
"fuer den PoE-Weg und passende Zugangsdaten fuer SSH oder RPC.", "für den PoE-Weg und passende Zugangsdaten für SSH oder RPC.",
"warning", "warning",
) )
return redirect(request.referrer or url_for("devices.overview")) return redirect(request.referrer or url_for("devices.overview"))
@@ -262,7 +279,7 @@ def _restart_over_poe(extension: Any, conn: Any, device: Any): # type: ignore[n
return redirect(request.referrer or url_for("devices.overview")) return redirect(request.referrer or url_for("devices.overview"))
flash( flash(
f"PoE-Neustart fuer {device['name']} durchgefuehrt." f"PoE-Neustart für {device['name']} durchgeführt."
if outcome["ok"] if outcome["ok"]
else f"PoE-Neustart fehlgeschlagen: {outcome['message']}", else f"PoE-Neustart fehlgeschlagen: {outcome['message']}",
"success" if outcome["ok"] else "danger", "success" if outcome["ok"] else "danger",
@@ -298,6 +315,12 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ
severity="notice", 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.start_background(
maintenance_service.run_reboot, maintenance_service.run_reboot,
database=extension.database, database=extension.database,
@@ -307,16 +330,20 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ
password=password, password=password,
wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300), wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300),
actor=current_user.username, 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( flash(
f"{method.label} fuer {device['name']} gestartet. Der Verlauf steht unter Wartung.", f"{method.label} für {device['name']} gestartet. Der Verlauf steht unter Wartung.",
"success", "success",
) )
return redirect(request.referrer or url_for("devices.overview")) return redirect(request.referrer or url_for("devices.overview"))
@bp.get("/<int:device_id>") @bp.get("/<int:device_id>")
@permission_required("devices.view") @permission_required("devices.read")
def detail(device_id: int): def detail(device_id: int):
conn = db() conn = db()
device = inventory.get_device(conn, device_id) device = inventory.get_device(conn, device_id)
@@ -341,7 +368,77 @@ def detail(device_id: int):
categories=inventory.DEVICE_CATEGORIES, categories=inventory.DEVICE_CATEGORIES,
host_keys=host_keys, host_keys=host_keys,
method_labels=monitor.METHOD_LABELS, method_labels=monitor.METHOD_LABELS,
terminal_enabled=bool(extension.settings.get(conn, "terminal_enabled")), 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 -- frisch gefragt, nicht geraten.
Die Kacheln der Statusuebersicht nehmen den Stand aus dem letzten
Ueberwachungsdurchlauf; hier geht es um **ein** Geraet, also fragt die Seite
selbst nach (ein halbe Sekunde Zeitlimit, und nur wenn der Client gerade
erreichbar ist). Das Ergebnis wird gleich festgehalten -- dann stimmt auch
die Kachel, ohne auf den naechsten Durchlauf zu warten.
"""
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)
online = str(device.get("state") or "") == "online" and not device.get("deleted_at")
zugang, frisch = vnc.zugang_gemerkt(device, port=port, pruefen=online)
if frisch:
with transaction():
monitor.merke_vnc(conn, int(device["id"]), zugang.erreichbar, zugang.ziel)
monitor.merke_verfahren(
conn, int(device["id"]), zugang.verfahren, zugang.absage
)
return zugang
@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",
},
) )
@@ -350,8 +447,8 @@ def detail(device_id: int):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/<int:device_id>/hostkey/pruefen") @bp.post("/<int:device_id>/host-key/probe")
@permission_required("devices.edit") @permission_required("devices.write")
def probe_host_key(device_id: int): def probe_host_key(device_id: int):
"""Liest den Host-Schluessel des Clients, ohne sich anzumelden. """Liest den Host-Schluessel des Clients, ohne sich anzumelden.
@@ -380,8 +477,8 @@ def probe_host_key(device_id: int):
) )
@bp.post("/<int:device_id>/hostkey/freigeben") @bp.post("/<int:device_id>/host-key/trust")
@permission_required("devices.edit") @permission_required("devices.write")
@reauth_required @reauth_required
def trust_host_key(device_id: int): def trust_host_key(device_id: int):
conn = db() conn = db()
@@ -391,7 +488,7 @@ def trust_host_key(device_id: int):
key_type = (request.form.get("key_type") or "").strip() key_type = (request.form.get("key_type") or "").strip()
fingerprint = (request.form.get("fingerprint") or "").strip() fingerprint = (request.form.get("fingerprint") or "").strip()
if not key_type or not ssh.valid_fingerprint(fingerprint): if not key_type or not ssh.valid_fingerprint(fingerprint):
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger") flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
return redirect(url_for("devices.detail", device_id=device_id)) return redirect(url_for("devices.detail", device_id=device_id))
with transaction(): with transaction():
@@ -410,12 +507,12 @@ def trust_host_key(device_id: int):
f"{key_type} {fingerprint}", f"{key_type} {fingerprint}",
severity="notice", severity="notice",
) )
flash("Host-Schluessel freigegeben.", "success") flash("Host-Schlüssel freigegeben.", "success")
return redirect(url_for("devices.detail", device_id=device_id)) return redirect(url_for("devices.detail", device_id=device_id))
@bp.post("/<int:device_id>/hostkey/verwerfen") @bp.post("/<int:device_id>/host-key/forget")
@permission_required("devices.edit") @permission_required("devices.write")
def forget_host_key(device_id: int): def forget_host_key(device_id: int):
conn = db() conn = db()
device = inventory.get_device(conn, device_id) device = inventory.get_device(conn, device_id)
@@ -424,5 +521,5 @@ def forget_host_key(device_id: int):
with transaction(): with transaction():
ssh.forget_key(conn, str(device["ip"]), int(device["ssh_port"] or 22)) ssh.forget_key(conn, str(device["ip"]), int(device["ssh_port"] or 22))
_audit(conn, "device.host_key_forgotten", str(device["name"]), severity="warning") _audit(conn, "device.host_key_forgotten", str(device["name"]), severity="warning")
flash("Gespeicherte Host-Schluessel entfernt.", "info") flash("Gespeicherte Host-Schlüssel entfernt.", "info")
return redirect(url_for("devices.detail", device_id=device_id)) return redirect(url_for("devices.detail", device_id=device_id))
+72 -37
View File
@@ -21,6 +21,7 @@ from tesm_core.db import Database
from tesm_core.extension import core, db, transaction from tesm_core.extension import core, db, transaction
from ..services import kea from ..services import kea
from ..permissions import DHCP_PAGE
bp = Blueprint("dhcp", __name__, url_prefix="/dhcp") bp = Blueprint("dhcp", __name__, url_prefix="/dhcp")
@@ -53,7 +54,7 @@ def _settings_values() -> dict[str, Any]:
@bp.get("/") @bp.get("/")
@permission_required("dhcp.view", "dhcp.edit", "dhcp.execute", mode="any") @permission_required(*DHCP_PAGE, mode="any")
def overview(): def overview():
extension = core() extension = core()
conn = db() conn = db()
@@ -105,8 +106,8 @@ def overview():
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/einstellungen") @bp.post("/settings")
@permission_required("dhcp.edit") @permission_required("dhcp_subnets.write")
def save_settings(): def save_settings():
extension = core() extension = core()
conn = db() conn = db()
@@ -118,12 +119,12 @@ def save_settings():
with transaction(): with transaction():
changed = extension.settings.set_many(conn, values, actor=current_user.username) changed = extension.settings.set_many(conn, values, actor=current_user.username)
_audit("dhcp.settings_saved", detail=", ".join(changed), severity="notice") _audit("dhcp.settings_saved", detail=", ".join(changed), severity="notice")
flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.", "success" if changed else "info") flash("Einstellungen gespeichert." if changed else "Keine Änderungen.", "success" if changed else "info")
return _write_and_redirect() return _write_and_redirect()
@bp.post("/subnetz") @bp.post("/subnet")
@permission_required("dhcp.edit") @permission_required("dhcp_subnets.create")
def add_subnet(): def add_subnet():
extension = core() extension = core()
conn = db() conn = db()
@@ -158,8 +159,8 @@ def add_subnet():
return _write_and_redirect() return _write_and_redirect()
@bp.post("/subnetz/<int:subnet_id>") @bp.post("/subnet/<int:subnet_id>")
@permission_required("dhcp.edit") @permission_required("dhcp_subnets.write")
def update_subnet(subnet_id: int): def update_subnet(subnet_id: int):
extension = core() extension = core()
conn = db() conn = db()
@@ -173,6 +174,9 @@ def update_subnet(subnet_id: int):
flash(problem, "danger") flash(problem, "danger")
return redirect(url_for("dhcp.overview")) 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(): with transaction():
conn.execute( conn.execute(
"UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, " "UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, "
@@ -189,13 +193,32 @@ def update_subnet(subnet_id: int):
subnet_id, subnet_id,
), ),
) )
_audit("dhcp.subnet_updated", str(subnet_id), severity="notice") 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") flash("Subnetz gespeichert.", "success")
return _write_and_redirect() return _write_and_redirect()
@bp.post("/subnetz/<int:subnet_id>/loeschen") @bp.post("/subnet/<int:subnet_id>/delete")
@permission_required("dhcp.edit") @permission_required("dhcp_subnets.delete")
def delete_subnet(subnet_id: int): def delete_subnet(subnet_id: int):
with transaction() as conn: with transaction() as conn:
conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,)) conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,))
@@ -209,8 +232,8 @@ def delete_subnet(subnet_id: int):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/option/wert") @bp.post("/option/value")
@permission_required("dhcp.edit") @permission_required("dhcp_options.write")
def set_option_value(): def set_option_value():
conn = db() conn = db()
option_id = int(request.form.get("option_id") or 0) option_id = int(request.form.get("option_id") or 0)
@@ -248,8 +271,8 @@ def _device_option_values(conn) -> dict[int, list[dict[str, Any]]]: # type: ign
return gruppen return gruppen
@bp.post("/option/geraet") @bp.post("/option/device")
@permission_required("dhcp.edit") @permission_required("dhcp_options.write")
def set_device_option_value(): def set_device_option_value():
"""Setzt eine Option fuer ein einzelnes Geraet. """Setzt eine Option fuer ein einzelnes Geraet.
@@ -289,15 +312,15 @@ def set_device_option_value():
value or "(geleert)", value or "(geleert)",
) )
flash( flash(
f"Option {option['name']} fuer {device['name']} " f"Option {option['name']} für {device['name']} "
f"{'gespeichert' if value else 'entfernt'}.", f"{'gespeichert' if value else 'entfernt'}.",
"success", "success",
) )
return _write_and_redirect() return _write_and_redirect()
@bp.post("/option/neu") @bp.post("/option/new")
@permission_required("dhcp.edit") @permission_required("dhcp_options.write")
def add_custom_option(): def add_custom_option():
conn = db() conn = db()
try: try:
@@ -327,8 +350,8 @@ def add_custom_option():
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
@bp.post("/option/<int:option_id>/loeschen") @bp.post("/option/<int:option_id>/delete")
@permission_required("dhcp.edit") @permission_required("dhcp_options.write")
def delete_custom_option(option_id: int): def delete_custom_option(option_id: int):
conn = db() conn = db()
option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,)) option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,))
@@ -336,7 +359,7 @@ def delete_custom_option(option_id: int):
abort(404) abort(404)
if int(option["is_standard"] or 0): if int(option["is_standard"] or 0):
flash( flash(
"Standardoptionen koennen nicht geloescht werden -- nur ihr Wert lasst sich leeren.", "Standardoptionen können nicht gelöscht werden – nur ihr Wert lässt sich leeren.",
"danger", "danger",
) )
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
@@ -352,14 +375,14 @@ def delete_custom_option(option_id: int):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/reservierung") @bp.post("/reservation")
@permission_required("dhcp.edit") @permission_required("dhcp_reservations.create")
def add_reservation(): def add_reservation():
conn = db() conn = db()
mac = kea.normalize_mac(request.form.get("mac", "")) mac = kea.normalize_mac(request.form.get("mac", ""))
ip = (request.form.get("ip") or "").strip() ip = (request.form.get("ip") or "").strip()
if not mac: if not mac:
flash("Die MAC-Adresse ist ungueltig.", "danger") flash("Die MAC-Adresse ist ungültig.", "danger")
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
# Eine Reservierung ausserhalb aller eingerichteten Bereiche liefert Kea nie # Eine Reservierung ausserhalb aller eingerichteten Bereiche liefert Kea nie
@@ -367,7 +390,7 @@ def add_reservation():
# schlechteste der drei Moeglichkeiten -- deshalb hier ablehnen. # schlechteste der drei Moeglichkeiten -- deshalb hier ablehnen.
if not Database.value(conn, "SELECT COUNT(*) FROM dhcp_subnets WHERE enabled=1"): if not Database.value(conn, "SELECT COUNT(*) FROM dhcp_subnets WHERE enabled=1"):
flash( flash(
"Es ist noch kein Bereich eingerichtet. Legen Sie zuerst ein Subnetz an -- " "Es ist noch kein Bereich eingerichtet. Legen Sie zuerst ein Subnetz an – "
"ohne eines wird keine Reservierung ausgeliefert.", "ohne eines wird keine Reservierung ausgeliefert.",
"danger", "danger",
) )
@@ -380,7 +403,7 @@ def add_reservation():
# keine Reservierung mehr moeglich. Stattdessen benennen, was ungeprueft # keine Reservierung mehr moeglich. Stattdessen benennen, was ungeprueft
# bleibt. # bleibt.
flash( flash(
"Der Bereich konnte nicht geprueft werden -- ohne Systemhelfer ist das Netz " "Der Bereich konnte nicht geprüft werden – ohne Systemhelfer ist das Netz "
"dieses Servers nicht feststellbar.", "dieses Servers nicht feststellbar.",
"warning", "warning",
) )
@@ -390,11 +413,12 @@ def add_reservation():
bereiche = ", ".join(str(netz) for netz in netze) bereiche = ", ".join(str(netz) for netz in netze)
flash( flash(
f"{ip or 'Diese Adresse'} liegt in keinem eingerichteten Bereich " f"{ip or 'Diese Adresse'} liegt in keinem eingerichteten Bereich "
f"({bereiche}). Die Reservierung waere ohne Wirkung.", f"({bereiche}). Die Reservierung wäre ohne Wirkung.",
"danger", "danger",
) )
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
vorher = Database.one(conn, "SELECT * FROM dhcp_reservations WHERE mac=?", (mac,))
with transaction(): with transaction():
conn.execute( conn.execute(
"INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) " "INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) "
@@ -407,13 +431,24 @@ def add_reservation():
(request.form.get("comment") or "").strip(), (request.form.get("comment") or "").strip(),
), ),
) )
_audit("dhcp.reservation_saved", f"{mac} -> {ip}", severity="notice") 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") flash("Reservierung gespeichert.", "success")
return _write_and_redirect() return _write_and_redirect()
@bp.post("/reservierung/<int:reservation_id>/loeschen") @bp.post("/reservation/<int:reservation_id>/delete")
@permission_required("dhcp.edit") @permission_required("dhcp_reservations.delete")
def delete_reservation(reservation_id: int): def delete_reservation(reservation_id: int):
with transaction() as conn: with transaction() as conn:
conn.execute("DELETE FROM dhcp_reservations WHERE id=?", (reservation_id,)) conn.execute("DELETE FROM dhcp_reservations WHERE id=?", (reservation_id,))
@@ -437,14 +472,14 @@ def _write_config() -> dict[str, Any]:
sysops = extension.sysops sysops = extension.sysops
conn = db() conn = db()
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
return {"ok": False, "message": "Systemhelfer nicht verfuegbar."} return {"ok": False, "message": "Systemhelfer nicht verfügbar."}
networks = kea.detect_networks(sysops) networks = kea.detect_networks(sysops)
config, warnings = kea.render_config(conn, networks=networks, settings=_settings_values()) config, warnings = kea.render_config(conn, networks=networks, settings=_settings_values())
if not config["Dhcp4"]["subnet4"]: if not config["Dhcp4"]["subnet4"]:
return { return {
"ok": False, "ok": False,
"message": "Es ist kein gueltiges Subnetz konfiguriert -- die Datei wurde nicht geschrieben.", "message": "Es ist kein gültiges Subnetz konfiguriert – die Datei wurde nicht geschrieben.",
"warnings": warnings, "warnings": warnings,
} }
result = sysops.write_kea_config(kea.config_to_text(config)) result = sysops.write_kea_config(kea.config_to_text(config))
@@ -480,14 +515,14 @@ def _write_and_redirect():
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
@bp.post("/schreiben") @bp.post("/write")
@permission_required("dhcp.edit") @permission_required("dhcp_config.execute")
def write_config(): def write_config():
return _write_and_redirect() return _write_and_redirect()
@bp.post("/dienst/<action>") @bp.post("/service/<action>")
@permission_required("dhcp.execute") @permission_required("dhcp_service.execute")
def service_action(action: str): def service_action(action: str):
allowed = {"install", "start", "stop"} allowed = {"install", "start", "stop"}
if action not in allowed: if action not in allowed:
@@ -495,7 +530,7 @@ def service_action(action: str):
extension = core() extension = core()
sysops = extension.sysops sysops = extension.sysops
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
if action == "install": if action == "install":
+339 -46
View File
@@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import io import io
import json
from datetime import datetime, timezone
from flask import ( from flask import (
Blueprint, Blueprint,
@@ -18,11 +21,12 @@ from flask import (
from tesm_core import audit from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required from tesm_core.auth.models import current_user, permission_required
from tesm_core.auth.service import client_ip from tesm_core.auth.service import client_ip
from tesm_core import uploads
from tesm_core.extension import core, db, transaction from tesm_core.extension import core, db, transaction
from ..services import fileshare as service from ..services import fileshare as service
bp = Blueprint("fileshare", __name__, url_prefix="/dateifreigaben") bp = Blueprint("fileshare", __name__, url_prefix="/fileshares")
@bp.before_request @bp.before_request
@@ -46,8 +50,33 @@ def _audit(action: str, target: str, detail: str = "", severity: str = "info") -
) )
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: def _mount_or_404(token: str) -> service.Mount:
mounts = service.active_mounts(db(), current_user.id) conn = db()
mounts = service.doppelte_zusammenfassen(
conn, service.active_mounts(conn, current_user.id), service.aktuelle_sitzung()
)
for mount in mounts: for mount in mounts:
if mount.token == token: if mount.token == token:
return mount return mount
@@ -55,12 +84,14 @@ def _mount_or_404(token: str) -> service.Mount:
@bp.get("/") @bp.get("/")
@permission_required("fileshare.view") @permission_required("fileshare.read")
def browse(): def browse():
conn = db() conn = db()
mounts = service.active_mounts(conn, current_user.id) mounts = service.doppelte_zusammenfassen(
token = request.args.get("freigabe") or (mounts[0].token if mounts else "") conn, service.active_mounts(conn, current_user.id), service.aktuelle_sitzung()
relative = request.args.get("pfad") or "" )
token = request.args.get("share") or (mounts[0].token if mounts else "")
relative = request.args.get("path") or ""
listing = None listing = None
error = "" error = ""
if token: if token:
@@ -75,15 +106,27 @@ def browse():
token=token, token=token,
listing=listing, listing=listing,
error=error, error=error,
max_upload_mb=int(core().settings.get(conn, "fileshare_upload_max_mb") or 512), # 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>/herunterladen") @bp.get("/<token>/download")
@permission_required("fileshare.view") # 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): def download(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.args.get("pfad") or "" relative = request.args.get("path") or ""
try: try:
path = service.resolve_path(mount, relative) path = service.resolve_path(mount, relative)
except service.FileshareError as exc: except service.FileshareError as exc:
@@ -95,15 +138,20 @@ def download(token: str):
path, path,
as_attachment=True, as_attachment=True,
download_name=path.name, download_name=path.name,
mimetype=service.download_mime(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>/vorschau") @bp.get("/<token>/preview")
@permission_required("fileshare.view") @permission_required("fileshare.read")
def preview(token: str): def preview(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.args.get("pfad") or "" relative = request.args.get("path") or ""
try: try:
path = service.resolve_path(mount, relative) path = service.resolve_path(mount, relative)
mimetype = service.preview_mime(path.name) mimetype = service.preview_mime(path.name)
@@ -119,21 +167,36 @@ def preview(token: str):
headers={ headers={
"Content-Disposition": f'inline; filename="{path.name}"', "Content-Disposition": f'inline; filename="{path.name}"',
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
# Die Vorschau laeuft in einem Rahmen auf der Freigabeseite. Die
# anwendungsweite CSP verbietet mit "frame-ancestors 'none'" jedes
# Einrahmen -- auch das durch die eigene Seite. Beides wird hier
# deshalb ausdruecklich auf "gleicher Ursprung" gesetzt; die Header
# der Anwendung werden per setdefault gesetzt und treten damit
# zurueck.
#
# Bewusst nur die Rahmen-Direktiven und nicht die ganze strenge CSP:
# was hier ausgeliefert wird, ist eine Datei mit engem, erlaubtem
# MIME-Typ (kein HTML, kein SVG) und "nosniff" -- Skript kann darin
# nicht ausgefuehrt werden. Eine vollstaendige CSP auf einer
# PDF-Antwort riskiert dagegen, die eingebaute Anzeige des Browsers
# zu blockieren.
"Content-Security-Policy": "frame-ancestors 'self'",
"X-Frame-Options": "SAMEORIGIN",
}, },
) )
@bp.post("/<token>/hochladen") @bp.post("/<token>/upload")
@permission_required("fileshare.create") @permission_required("fileshare.create")
def upload(token: str): def upload(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.form.get("pfad") or "" relative = request.form.get("path") or ""
files = request.files.getlist("files") files = request.files.getlist("files")
if not files: if not files:
flash("Keine Datei ausgewaehlt.", "danger") flash("Keine Datei ausgewählt.", "danger")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) return redirect(url_for("fileshare.browse", share=token, path=relative))
limit = int(core().settings.get(db(), "fileshare_upload_max_mb") or 512) * 1024 * 1024 limit = uploads.limit_bytes(db())
stored = 0 stored = 0
try: try:
target_dir = service.resolve_path(mount, relative) target_dir = service.resolve_path(mount, relative)
@@ -143,58 +206,239 @@ def upload(token: str):
name = service.safe_filename(upload_file.filename) name = service.safe_filename(upload_file.filename)
data = upload_file.stream.read(limit + 1) data = upload_file.stream.read(limit + 1)
if len(data) > limit: if len(data) > limit:
flash(f"{name}: zu gross (Grenze {limit // 1024 // 1024} MB).", "danger") flash(f"{name}: zu groß (Grenze {limit // 1024 // 1024} MB).", "danger")
continue continue
(target_dir / name).write_bytes(data) (target_dir / name).write_bytes(data)
stored += 1 stored += 1
except service.FileshareError as exc: except service.FileshareError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) return redirect(url_for("fileshare.browse", share=token, path=relative))
if stored: if stored:
_audit("fileshare.uploaded", f"{mount.label}:{relative}", f"{stored} Datei(en)") _audit("fileshare.uploaded", f"{mount.label}:{relative}", f"{stored} Datei(en)")
flash(f"{stored} Datei(en) hochgeladen.", "success") flash(f"{stored} Datei(en) hochgeladen.", "success")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) return redirect(url_for("fileshare.browse", share=token, path=relative))
@bp.post("/<token>/ordner") @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") @permission_required("fileshare.create")
def make_directory(token: str): def make_directory(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.form.get("pfad") or "" relative = request.form.get("path") or ""
try: try:
service.make_directory(mount, relative, request.form.get("name", "")) service.make_directory(mount, relative, request.form.get("name", ""))
except (service.FileshareError, OSError) as exc: except (service.FileshareError, OSError) as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) return redirect(url_for("fileshare.browse", share=token, path=relative))
_audit("fileshare.directory_created", f"{mount.label}:{relative}/{request.form.get('name')}") _audit("fileshare.directory_created", f"{mount.label}:{relative}/{request.form.get('name')}")
flash("Ordner angelegt.", "success") flash("Ordner angelegt.", "success")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) return redirect(url_for("fileshare.browse", share=token, path=relative))
@bp.post("/<token>/umbenennen") @bp.post("/<token>/rename")
@permission_required("fileshare.edit") @permission_required("fileshare.write")
def rename(token: str): def rename(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.form.get("pfad") or "" relative = request.form.get("path") or ""
parent = request.form.get("parent") or "" parent = request.form.get("parent") or ""
try: try:
service.rename_entry(mount, relative, request.form.get("name", "")) service.rename_entry(mount, relative, request.form.get("name", ""))
except (service.FileshareError, OSError) as exc: except (service.FileshareError, OSError) as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent)) return redirect(url_for("fileshare.browse", share=token, path=parent))
_audit("fileshare.renamed", f"{mount.label}:{relative}") _audit("fileshare.renamed", f"{mount.label}:{relative}")
flash("Umbenannt.", "success") flash("Umbenannt.", "success")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent)) return redirect(url_for("fileshare.browse", share=token, path=parent))
@bp.post("/<token>/loeschen") @bp.post("/<token>/delete")
@permission_required("fileshare.delete") @permission_required("fileshare.delete")
def delete(token: str): def delete(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
parent = request.form.get("parent") or "" parent = request.form.get("parent") or ""
removed = 0 removed = 0
for relative in request.form.getlist("pfade"): for relative in request.form.getlist("paths"):
try: try:
kind = service.delete_entry(mount, relative) kind = service.delete_entry(mount, relative)
_audit("fileshare.deleted", f"{mount.label}:{relative}", kind, severity="warning") _audit("fileshare.deleted", f"{mount.label}:{relative}", kind, severity="warning")
@@ -202,18 +446,18 @@ def delete(token: str):
except (service.FileshareError, OSError) as exc: except (service.FileshareError, OSError) as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
if removed: if removed:
flash(f"{removed} Eintrag/Eintraege geloescht.", "success") flash(f"{removed} Eintrag/Einträge gelöscht.", "success")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent)) return redirect(url_for("fileshare.browse", share=token, path=parent))
@bp.post("/<token>/freigeben") @bp.post("/<token>/release")
@permission_required("fileshare.view") @permission_required("fileshare.read")
def release(token: str): def release(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
with transaction() as conn: with transaction() as conn:
service.release_mount(conn, mount.id) service.release_mount(conn, mount.id)
_audit("fileshare.released", mount.label) _audit("fileshare.released", mount.label)
flash("Einbindung geloest.", "success") flash("Einbindung gelöst.", "success")
return redirect(url_for("fileshare.browse")) return redirect(url_for("fileshare.browse"))
@@ -222,8 +466,8 @@ def release(token: str):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/zuordnungen") @bp.get("/mappings")
@permission_required("fileshare.edit") @permission_required("fileshare_mappings.read")
def mappings(): def mappings():
from tesm_core.auth import directory from tesm_core.auth import directory
@@ -236,8 +480,8 @@ def mappings():
) )
@bp.post("/zuordnungen") @bp.post("/mappings")
@permission_required("fileshare.edit") @permission_required("fileshare_mappings.create")
def mapping_add(): def mapping_add():
try: try:
with transaction() as conn: with transaction() as conn:
@@ -248,18 +492,67 @@ def mapping_add():
label=request.form.get("label", ""), label=request.form.get("label", ""),
unc=request.form.get("unc", ""), unc=request.form.get("unc", ""),
) )
_audit("fileshare.mapping_added", request.form.get("label", ""), severity="notice") _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") flash("Zuordnung gespeichert.", "success")
except service.FileshareError as exc: except service.FileshareError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("fileshare.mappings")) return redirect(url_for("fileshare.mappings"))
@bp.post("/zuordnungen/<int:mapping_id>/loeschen") @bp.post("/mappings/<int:mapping_id>")
@permission_required("fileshare.edit") @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): def mapping_delete(mapping_id: int):
with transaction() as conn: with transaction() as conn:
entfernt = service.get_mapping(conn, mapping_id)
service.delete_mapping(conn, mapping_id) service.delete_mapping(conn, mapping_id)
_audit("fileshare.mapping_deleted", str(mapping_id), severity="notice") # 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") flash("Zuordnung entfernt.", "success")
return redirect(url_for("fileshare.mappings")) return redirect(url_for("fileshare.mappings"))
+22 -18
View File
@@ -31,13 +31,17 @@ from tesm_core.extension import core, db, transaction
from ..services import inventory, ssh from ..services import inventory, ssh
bp = Blueprint("hostkeys", __name__, url_prefix="/hostschluessel") bp = Blueprint("hostkeys", __name__, url_prefix="/monitoring/host-keys")
KIND_DEVICE = "client" KIND_DEVICE = "client"
KIND_SWITCH = "switch" KIND_SWITCH = "switch"
#: Welches Recht welche Art von Ziel freigibt. #: Freigeben ist eine eigene Zeile: es war das Recht fuer Stammdaten
EDIT_RIGHT = {KIND_DEVICE: "devices.edit", KIND_SWITCH: "switches.edit"} #: ("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: def _audit(action: str, target: str, detail: str = "", severity: str = "info") -> None:
@@ -112,7 +116,7 @@ def _orphans(conn: Any, targets: list[dict[str, Any]]) -> list[dict[str, Any]]:
@bp.get("/") @bp.get("/")
@permission_required("devices.view", "switches.view", mode="any") @permission_required("hostkeys.read")
def overview(): def overview():
conn = db() conn = db()
targets = _targets(conn) targets = _targets(conn)
@@ -142,8 +146,8 @@ def _require(kind: str) -> None:
abort(403) abort(403)
@bp.post("/<kind>/<int:target_id>/pruefen") @bp.post("/<kind>/<int:target_id>/probe")
@permission_required("devices.view", "switches.view", mode="any") @permission_required("hostkeys.read")
def probe(kind: str, target_id: int): def probe(kind: str, target_id: int):
conn = db() conn = db()
_require(kind) _require(kind)
@@ -169,8 +173,8 @@ def probe(kind: str, target_id: int):
) )
@bp.post("/<kind>/<int:target_id>/freigeben") @bp.post("/<kind>/<int:target_id>/trust")
@permission_required("devices.view", "switches.view", mode="any") @permission_required("hostkeys.read")
@reauth_required @reauth_required
def trust(kind: str, target_id: int): def trust(kind: str, target_id: int):
conn = db() conn = db()
@@ -181,7 +185,7 @@ def trust(kind: str, target_id: int):
key_type = (request.form.get("key_type") or "").strip() key_type = (request.form.get("key_type") or "").strip()
fingerprint = (request.form.get("fingerprint") or "").strip() fingerprint = (request.form.get("fingerprint") or "").strip()
if not key_type or not ssh.valid_fingerprint(fingerprint): if not key_type or not ssh.valid_fingerprint(fingerprint):
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger") flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
with transaction(): with transaction():
@@ -199,12 +203,12 @@ def trust(kind: str, target_id: int):
f"{key_type} {fingerprint}", f"{key_type} {fingerprint}",
severity="notice", severity="notice",
) )
flash(f"Host-Schluessel von {target['label']} freigegeben.", "success") flash(f"Host-Schlüssel von {target['label']} freigegeben.", "success")
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
@bp.post("/<kind>/<int:target_id>/verwerfen") @bp.post("/<kind>/<int:target_id>/forget")
@permission_required("devices.view", "switches.view", mode="any") @permission_required("hostkeys.read")
def forget(kind: str, target_id: int): def forget(kind: str, target_id: int):
conn = db() conn = db()
_require(kind) _require(kind)
@@ -218,12 +222,12 @@ def forget(kind: str, target_id: int):
target["label"], target["label"],
severity="warning", severity="warning",
) )
flash(f"Gespeicherte Schluessel von {target['label']} entfernt.", "info") flash(f"Gespeicherte Schlüssel von {target['label']} entfernt.", "info")
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
@bp.post("/verwaist/verwerfen") @bp.post("/orphaned/forget")
@permission_required("devices.edit", "switches.edit", mode="any") @permission_required("hostkeys.execute")
def forget_orphan(): def forget_orphan():
"""Entfernt eine Freigabe, zu der es kein Ziel mehr gibt.""" """Entfernt eine Freigabe, zu der es kein Ziel mehr gibt."""
host = (request.form.get("host") or "").strip() host = (request.form.get("host") or "").strip()
@@ -232,18 +236,18 @@ def forget_orphan():
except ValueError: except ValueError:
port = 0 port = 0
if not host or not 1 <= port <= 65535: if not host or not 1 <= port <= 65535:
flash("Ungueltiges Ziel.", "danger") flash("Ungültiges Ziel.", "danger")
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
conn = db() conn = db()
if (host, port) in {(row["host"], row["port"]) for row in _targets(conn)}: 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 -- # Ein Ziel, das es noch gibt, wird ueber seine eigene Zeile verworfen --
# sonst umginge dieser Weg die Rechtepruefung je Objektart. # sonst umginge dieser Weg die Rechtepruefung je Objektart.
flash("Zu diesem Eintrag gibt es ein Ziel -- bitte dort verwerfen.", "warning") flash("Zu diesem Eintrag gibt es ein Ziel – bitte dort verwerfen.", "warning")
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
with transaction(): with transaction():
ssh.forget_key(conn, host, port) ssh.forget_key(conn, host, port)
_audit("host_key.orphan_forgotten", f"{host}:{port}", severity="warning") _audit("host_key.orphan_forgotten", f"{host}:{port}", severity="warning")
flash(f"Verwaiste Freigabe fuer {host}:{port} entfernt.", "info") flash(f"Verwaiste Freigabe für {host}:{port} entfernt.", "info")
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
+20 -20
View File
@@ -23,7 +23,7 @@ from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError
import tesm_licensing as lic import tesm_licensing as lic
bp = Blueprint("license", __name__, url_prefix="/lizenz") bp = Blueprint("license", __name__, url_prefix="/administration/license")
MAX_UPLOAD_BYTES = 256 * 1024 MAX_UPLOAD_BYTES = 256 * 1024
@@ -43,7 +43,7 @@ def _audit(action: str, target: str = "", detail: str = "", severity: str = "inf
@bp.get("/") @bp.get("/")
@permission_required("settings_license.view") @permission_required("settings_license.read")
def overview(): def overview():
extension = core() extension = core()
conn = db() conn = db()
@@ -69,8 +69,8 @@ def overview():
) )
@bp.post("/einspielen") @bp.post("/install")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
def upload(): def upload():
extension = core() extension = core()
manager = extension.license manager = extension.license
@@ -80,11 +80,11 @@ def upload():
upload_file = request.files.get("license_file") upload_file = request.files.get("license_file")
if upload_file is None or not upload_file.filename: if upload_file is None or not upload_file.filename:
flash("Keine Datei ausgewaehlt.", "danger") flash("Keine Datei ausgewählt.", "danger")
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1) raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
if len(raw) > MAX_UPLOAD_BYTES: if len(raw) > MAX_UPLOAD_BYTES:
flash("Die Datei ist zu gross fuer eine Lizenzdatei.", "danger") flash("Die Datei ist zu groß für eine Lizenzdatei.", "danger")
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
allow_new_issuer = request.form.get("allow_new_issuer") == "1" and current_user.is_admin allow_new_issuer = request.form.get("allow_new_issuer") == "1" and current_user.is_admin
@@ -104,20 +104,20 @@ def upload():
if state.status and state.status.role != "customer": if state.status and state.status.role != "customer":
flash( flash(
f"Achtung: diese Lizenz hat die Rolle {state.status.role!r}. " f"Achtung: diese Lizenz hat die Rolle {state.status.role!r}. "
"In einer TESM-Kundeninstanz ist das ungewoehnlich.", "In einer TESM-Kundeninstanz ist das ungewöhnlich.",
"warning", "warning",
) )
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
@bp.post("/aktivieren") @bp.post("/activate")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
def activate(): def activate():
return _handshake("activate") return _handshake("activate")
@bp.post("/deaktivieren") @bp.post("/deactivate")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
@reauth_required @reauth_required
def deactivate(): def deactivate():
"""Gibt die Lizenz frei, damit sie auf einer anderen Maschine genutzt werden kann. """Gibt die Lizenz frei, damit sie auf einer anderen Maschine genutzt werden kann.
@@ -129,7 +129,7 @@ def deactivate():
@bp.post("/heartbeat") @bp.post("/heartbeat")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
def heartbeat(): def heartbeat():
return _handshake("heartbeat") return _handshake("heartbeat")
@@ -158,8 +158,8 @@ def _handshake(action: str):
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
@bp.post("/offline-antwort") @bp.post("/offline-response")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
def offline_response(): def offline_response():
extension = core() extension = core()
manager = extension.license manager = extension.license
@@ -177,8 +177,8 @@ def offline_response():
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
@bp.post("/serverzertifikat") @bp.post("/server-certificate")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
def server_certificate(): def server_certificate():
"""Hinterlegt das Zertifikat des Lizenzservers. """Hinterlegt das Zertifikat des Lizenzservers.
@@ -214,15 +214,15 @@ def server_certificate():
severity="notice", severity="notice",
) )
flash( flash(
"Zertifikat hinterlegt. Der naechste Versuch laeuft wieder ueber den Online-Weg." "Zertifikat hinterlegt. Der nächste Versuch läuft wieder über den Online-Weg."
if pem if pem
else "Zertifikat entfernt -- es gelten wieder die Systemanker.", else "Zertifikat entfernt – es gelten wieder die Systemanker.",
"success", "success",
) )
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
@bp.post("/aussteller/<key_id>/entfernen") @bp.post("/issuer/<key_id>/remove")
@admin_required @admin_required
@reauth_required @reauth_required
def remove_trusted_key(key_id: str): def remove_trusted_key(key_id: str):
@@ -246,5 +246,5 @@ def remove_trusted_key(key_id: str):
if manager is not None: if manager is not None:
with core().database.session() as conn: with core().database.session() as conn:
manager.reload(conn) manager.reload(conn)
flash("Aussteller-Schluessel entfernt.", "info") flash("Aussteller-Schlüssel entfernt.", "info")
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
+7 -7
View File
@@ -13,13 +13,13 @@ from tesm_core.extension import core, db
from ..services import kea, monitor from ..services import kea, monitor
bp = Blueprint("tesmlogs", __name__, url_prefix="/protokolle") bp = Blueprint("tesmlogs", __name__, url_prefix="/logs")
MAX_TAIL_LINES = 2000 MAX_TAIL_LINES = 2000
@bp.get("/kea") @bp.get("/dhcp")
@permission_required("logs_dhcp.view") @permission_required("logs_dhcp.read")
def dhcp(): def dhcp():
extension = core() extension = core()
if extension.license is not None and not extension.license.module_active("dhcp"): if extension.license is not None and not extension.license.module_active("dhcp"):
@@ -34,12 +34,12 @@ def dhcp():
else: else:
error = result.message error = result.message
else: else:
error = "Systemhelfer nicht verfuegbar." error = "Systemhelfer nicht verfügbar."
return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error) return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error)
@bp.get("/neustarts") @bp.get("/restarts")
@permission_required("devices.view") @permission_required("devices.read")
def restarts(): def restarts():
"""Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung. """Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung.
@@ -48,7 +48,7 @@ def restarts():
der Neustart-Historie vollstaendig -- obwohl genau dort jemand nachsieht, der Neustart-Historie vollstaendig -- obwohl genau dort jemand nachsieht,
der wissen will, warum ein Geraet weg war. der wissen will, warum ein Geraet weg war.
""" """
method = (request.args.get("methode") or "").strip() method = (request.args.get("method") or "").strip()
if method and method not in monitor.METHOD_LABELS: if method and method not in monitor.METHOD_LABELS:
method = "" method = ""
sql = ( sql = (
+35 -20
View File
@@ -16,15 +16,17 @@ geratene Kategorie schickt ein ``reboot`` an das falsche Geraet).
from __future__ import annotations from __future__ import annotations
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for 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 import audit
from tesm_core.auth.models import current_user, permission_required from tesm_core.auth.models import current_user, permission_required
from tesm_core.auth.service import client_ip from tesm_core.auth.service import client_ip
from tesm_core.db import Database from tesm_core.db import Database
from tesm_core.extension import core, db, transaction from tesm_core.extension import core, db, transaction
from ..services import inventory, maintenance as service, ssh, windowsops from ..permissions import SERVICING_PAGE
from ..services import inventory, maintenance as service, ssh, windowsops, monitor
bp = Blueprint("maintenance", __name__, url_prefix="/wartung") bp = Blueprint("maintenance", __name__, url_prefix="/maintenance")
@bp.before_request @bp.before_request
@@ -74,7 +76,7 @@ def _query_devices(conn) -> list[dict]: # type: ignore[no-untyped-def]
@bp.get("/") @bp.get("/")
@permission_required("maintenance.view", "maintenance.execute", mode="any") @permission_required(*SERVICING_PAGE, mode="any")
def overview(): def overview():
conn = db() conn = db()
return render_template( return render_template(
@@ -84,7 +86,7 @@ def overview():
category_labels=dict(inventory.CREDENTIAL_CATEGORIES), category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
category_effects=inventory.CATEGORY_EFFECTS, category_effects=inventory.CATEGORY_EFFECTS,
update_category=inventory.MAINTENANCE_CATEGORY, update_category=inventory.MAINTENANCE_CATEGORY,
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")), terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")),
windows_tool_missing=_windows_tool_missing(conn), windows_tool_missing=_windows_tool_missing(conn),
windows_package=windowsops.REQUIRED_PACKAGE, windows_package=windowsops.REQUIRED_PACKAGE,
) )
@@ -104,8 +106,8 @@ def _windows_tool_missing(conn) -> bool: # type: ignore[no-untyped-def]
) )
@bp.post("/paket-nachinstallieren") @bp.post("/install-package")
@permission_required("maintenance.execute") @permission_required("servicing_tool.execute")
def install_windows_tool(): def install_windows_tool():
"""Installiert das Paket, das der Fehlermeldung nach fehlt. """Installiert das Paket, das der Fehlermeldung nach fehlt.
@@ -115,7 +117,7 @@ def install_windows_tool():
""" """
sysops = core().sysops sysops = core().sysops
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
return redirect(url_for("maintenance.overview")) return redirect(url_for("maintenance.overview"))
result = sysops.apt_install(windowsops.REQUIRED_PACKAGE) result = sysops.apt_install(windowsops.REQUIRED_PACKAGE)
@@ -138,7 +140,7 @@ def install_windows_tool():
@bp.get("/status") @bp.get("/status")
@permission_required("maintenance.view", "maintenance.execute", mode="any") @permission_required(*SERVICING_PAGE, mode="any")
def status(): def status():
"""Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher.""" """Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher."""
conn = db() conn = db()
@@ -164,8 +166,8 @@ def status():
) )
@bp.get("/auftrag/<job_id>") @bp.get("/job/<job_id>")
@permission_required("maintenance.view", "maintenance.execute", mode="any") @permission_required(*SERVICING_PAGE, mode="any")
def job_detail(job_id: str): def job_detail(job_id: str):
conn = db() conn = db()
job = service.get_job(conn, job_id) job = service.get_job(conn, job_id)
@@ -174,8 +176,9 @@ def job_detail(job_id: str):
return render_template("tesm/maintenance_job.html", job=job) return render_template("tesm/maintenance_job.html", job=job)
@bp.get("/auftrag/<job_id>/ausgabe") @bp.get("/job/<job_id>/output")
@permission_required("maintenance.view", "maintenance.execute", mode="any") @permission_required(*SERVICING_PAGE, mode="any")
@fragment
def job_output(job_id: str): def job_output(job_id: str):
conn = db() conn = db()
job = service.get_job(conn, job_id) job = service.get_job(conn, job_id)
@@ -189,7 +192,7 @@ def _start(kind: str) -> None:
conn = db() conn = db()
device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()] device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()]
if not device_ids: if not device_ids:
flash("Es wurde kein Geraet ausgewaehlt.", "warning") flash("Es wurde kein Gerät ausgewählt.", "warning")
return return
eligible = {int(row["id"]): row for row in _eligible_devices(conn)} eligible = {int(row["id"]): row for row in _eligible_devices(conn)}
@@ -202,8 +205,8 @@ def _start(kind: str) -> None:
# zurueck, der nie gestartet wird und ewig auf "wartet" steht. # zurueck, der nie gestartet wird und ewig auf "wartet" steht.
if kind == service.JOB_KIND_UPDATE and str(device["category"]) != "linux": if kind == service.JOB_KIND_UPDATE and str(device["category"]) != "linux":
flash( flash(
f"{device['name']}: Paketaktualisierung gibt es nur fuer Linux-Clients. " f"{device['name']}: Paketaktualisierung gibt es nur für Linux-Clients. "
"Windows-Geraete lassen sich hier nur neu starten.", "Windows-Geräte lassen sich hier nur neu starten.",
"warning", "warning",
) )
continue continue
@@ -246,6 +249,9 @@ def _start(kind: str) -> None:
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900), timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
) )
else: 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.start_background(
service.run_reboot, service.run_reboot,
database=extension.database, database=extension.database,
@@ -255,22 +261,31 @@ def _start(kind: str) -> None:
password=password, password=password,
wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300), wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300),
actor=current_user.username, 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 started += 1
if started: if started:
flash(f"{started} Auftrag/Auftraege gestartet.", "success") flash(f"{started} Auftrag/Aufträge gestartet.", "success")
@bp.post("/aktualisieren") @bp.post("/update")
@permission_required("maintenance.execute") @permission_required("servicing_update.execute")
def start_update(): def start_update():
_start(service.JOB_KIND_UPDATE) _start(service.JOB_KIND_UPDATE)
return redirect(url_for("maintenance.overview")) return redirect(url_for("maintenance.overview"))
@bp.post("/neustarten") @bp.post("/restart")
@permission_required("maintenance.execute") @permission_required("servicing_reboot.execute")
def start_reboot(): def start_reboot():
_start(service.JOB_KIND_REBOOT) _start(service.JOB_KIND_REBOOT)
return redirect(url_for("maintenance.overview")) return redirect(url_for("maintenance.overview"))
+273
View File
@@ -0,0 +1,273 @@
"""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, 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, 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 misst vor dem Verbinden, **wie** der VNC-Server sich authentifizieren
will. Bietet er nur Verfahren an, die der Browser-Client nicht kennt, steht
das als Satz auf der Seite -- mit der Zeile, die das auf dem Geraet aendert.
Der Unterschied zur schwarzen Flaeche ist der ganze Punkt: wer nicht weiss,
woran es liegt, sucht den Fehler bei TESM.
"""
conn = db()
device = _geraet(conn, device_id)
if device is None:
abort(404)
port = _port(conn)
if not port:
abort(404)
zugang, frisch = vnc.zugang_gemerkt(
device, port=port, pruefen=str(device.get("state") or "") == "online"
)
if frisch:
from ..services import monitor
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
)
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,
)
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",
)
+109 -14
View File
@@ -18,7 +18,9 @@ from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction from tesm_core.extension import core, db, transaction
from tesm_core.licensing_client import LicenseError from tesm_core.licensing_client import LicenseError
bp = Blueprint("settings", __name__, url_prefix="/einstellungen") import tesm_licensing as lic
bp = Blueprint("settings", __name__, url_prefix="/administration")
def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None: def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
@@ -46,6 +48,28 @@ def _license_hint(feature: str) -> str:
return "" 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: def _current_timezone() -> str:
sysops = core().sysops sysops = core().sysops
if sysops is None: if sysops is None:
@@ -65,7 +89,7 @@ def _timezone_choices() -> list[str]:
@bp.get("/system") @bp.get("/system")
@permission_required("settings_system.view") @permission_required("settings_system.read")
def system(): def system():
extension = core() extension = core()
conn = db() conn = db()
@@ -79,9 +103,16 @@ def system():
"monitor_failures_before_restart", "monitor_failures_before_restart",
"monitor_auto_restart", "monitor_auto_restart",
"dashboard_public", "dashboard_public",
"terminal_enabled", "vnc_enabled",
"vnc_port",
"vnc_warn_unencrypted_off",
"terminal_switches_enabled",
"terminal_clients_enabled",
"terminal_idle_timeout", "terminal_idle_timeout",
"signature_witness_enabled",
"trash_retention_days", "trash_retention_days",
"session_idle_minutes",
"session_absolute_hours",
"log_rotate_interval", "log_rotate_interval",
"log_rotate_keep", "log_rotate_keep",
], ],
@@ -90,15 +121,21 @@ def system():
hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""), hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""),
timezone=_current_timezone(), timezone=_current_timezone(),
timezones=_timezone_choices(), timezones=_timezone_choices(),
nav_items=extension.nav.items, # 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_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"), license_hint=_license_hint("monitor_interval"),
sysops_available=bool(sysops and sysops.available), sysops_available=bool(sysops and sysops.available),
module_rows=_module_rows(conn),
) )
@bp.post("/system") @bp.post("/system")
@permission_required("settings_system.edit") @permission_required("settings_system.write")
def system_save(): def system_save():
extension = core() extension = core()
conn = db() conn = db()
@@ -109,9 +146,16 @@ def system_save():
"monitor_failures_before_restart", "monitor_failures_before_restart",
"monitor_auto_restart", "monitor_auto_restart",
"dashboard_public", "dashboard_public",
"terminal_enabled", "vnc_enabled",
"vnc_port",
"vnc_warn_unencrypted_off",
"terminal_switches_enabled",
"terminal_clients_enabled",
"terminal_idle_timeout", "terminal_idle_timeout",
"signature_witness_enabled",
"trash_retention_days", "trash_retention_days",
"session_idle_minutes",
"session_absolute_hours",
"log_rotate_interval", "log_rotate_interval",
"log_rotate_keep", "log_rotate_keep",
): ):
@@ -151,10 +195,60 @@ def system_save():
if changed: if changed:
message = "Einstellungen gespeichert." message = "Einstellungen gespeichert."
if interval_changed: if interval_changed:
message += " Das Pruefintervall greift beim naechsten Durchlauf." message += " Das Prüfintervall greift beim nächsten Durchlauf."
flash(message, "success") flash(message, "success")
else: else:
flash("Keine Aenderungen.", "info") 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")) return redirect(url_for("settings.system"))
@@ -165,10 +259,10 @@ def set_hostname():
sysops = extension.sysops sysops = extension.sysops
hostname = (request.form.get("hostname") or "").strip() hostname = (request.form.get("hostname") or "").strip()
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum(): if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
flash("Der Hostname ist ungueltig.", "danger") flash("Der Hostname ist ungültig.", "danger")
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
result = sysops.set_hostname(hostname) result = sysops.set_hostname(hostname)
if result.ok: if result.ok:
@@ -181,7 +275,7 @@ def set_hostname():
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
@bp.post("/system/zeitzone") @bp.post("/system/timezone")
@permission_required("settings_system.execute") @permission_required("settings_system.execute")
def set_timezone(): def set_timezone():
extension = core() extension = core()
@@ -191,7 +285,7 @@ def set_timezone():
flash("Unbekannte Zeitzone.", "danger") flash("Unbekannte Zeitzone.", "danger")
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
result = sysops.set_timezone(timezone) result = sysops.set_timezone(timezone)
if result.ok: if result.ok:
@@ -210,14 +304,14 @@ def set_timezone():
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
@bp.post("/system/pruefung-jetzt") @bp.post("/system/check-now")
@permission_required("settings_system.execute") @permission_required("settings_system.execute")
def run_check_now(): def run_check_now():
"""Startet einen Ueberwachungsdurchlauf sofort, im Hintergrund.""" """Startet einen Ueberwachungsdurchlauf sofort, im Hintergrund."""
extension = core() extension = core()
def worker() -> None: def worker() -> None:
from ..services import inventory, monitor from ..services import inventory, monitor, vnc
allowed = extension.license is None or extension.license.feature_allowed( allowed = extension.license is None or extension.license.feature_allowed(
"auto_poe_restart" "auto_poe_restart"
@@ -227,6 +321,7 @@ def run_check_now():
live=extension.registry["live_log"], live=extension.registry["live_log"],
keystore=extension.keystore, keystore=extension.keystore,
license_allows_auto_restart=allowed, license_allows_auto_restart=allowed,
vnc_port=vnc.port_aus_einstellungen(extension),
) )
threading.Thread(target=worker, name="manual-sweep", daemon=True).start() threading.Thread(target=worker, name="manual-sweep", daemon=True).start()
+22 -23
View File
@@ -17,7 +17,7 @@ from tesm_core.extension import core, db, transaction
from ..services import inventory, ssh, switchcli from ..services import inventory, ssh, switchcli
bp = Blueprint("switches", __name__, url_prefix="/switche") bp = Blueprint("switches", __name__, url_prefix="/monitoring/switches")
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None: def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
@@ -47,19 +47,18 @@ def _form_values() -> dict[str, Any]:
@bp.get("/") @bp.get("/")
@permission_required("switches.view") @permission_required("switches.read")
def overview(): def overview():
conn = db() conn = db()
return render_template( return render_template(
"tesm/switches.html", "tesm/switches.html",
switches=inventory.list_switches(conn), switches=inventory.list_switches(conn),
credentials=inventory.list_credentials(conn), credentials=inventory.list_credentials(conn),
host_keys=Database.all(conn, "SELECT * FROM switch_host_keys ORDER BY host"), terminal_enabled=bool(core().settings.get(conn, "terminal_switches_enabled")),
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")),
) )
@bp.post("/neu") @bp.post("/new")
@permission_required("switches.create") @permission_required("switches.create")
def create(): def create():
try: try:
@@ -72,7 +71,7 @@ def create():
f"IP {request.form.get('ip')}", f"IP {request.form.get('ip')}",
severity="notice", severity="notice",
) )
flash("Switch angelegt. Der Host-Schluessel muss vor dem ersten Zugriff freigegeben werden.", "success") 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)) return redirect(url_for("switches.detail", switch_id=switch_id))
except inventory.ValidationError as exc: except inventory.ValidationError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
@@ -80,7 +79,7 @@ def create():
@bp.post("/<int:switch_id>") @bp.post("/<int:switch_id>")
@permission_required("switches.edit") @permission_required("switches.write")
def update(switch_id: int): def update(switch_id: int):
try: try:
with transaction() as conn: with transaction() as conn:
@@ -93,13 +92,13 @@ def update(switch_id: int):
"; ".join(changes), "; ".join(changes),
severity="notice", severity="notice",
) )
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info") flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
except inventory.ValidationError as exc: except inventory.ValidationError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(request.referrer or url_for("switches.overview")) return redirect(request.referrer or url_for("switches.overview"))
@bp.post("/<int:switch_id>/loeschen") @bp.post("/<int:switch_id>/delete")
@permission_required("switches.delete") @permission_required("switches.delete")
def delete(switch_id: int): def delete(switch_id: int):
try: try:
@@ -116,7 +115,7 @@ def delete(switch_id: int):
@bp.get("/<int:switch_id>") @bp.get("/<int:switch_id>")
@permission_required("switches.view") @permission_required("switches.read")
def detail(switch_id: int): def detail(switch_id: int):
conn = db() conn = db()
switch = inventory.get_switch(conn, switch_id) switch = inventory.get_switch(conn, switch_id)
@@ -143,7 +142,7 @@ def detail(switch_id: int):
devices=devices, devices=devices,
credentials=inventory.list_credentials(conn), credentials=inventory.list_credentials(conn),
safe_commands=switchcli.SAFE_COMMANDS, safe_commands=switchcli.SAFE_COMMANDS,
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")), terminal_enabled=bool(core().settings.get(conn, "terminal_switches_enabled")),
) )
@@ -152,8 +151,8 @@ def detail(switch_id: int):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/<int:switch_id>/hostkey/pruefen") @bp.post("/<int:switch_id>/host-key/probe")
@permission_required("switches.edit") @permission_required("switches.write")
def probe_host_key(switch_id: int): def probe_host_key(switch_id: int):
conn = db() conn = db()
switch = inventory.get_switch(conn, switch_id) switch = inventory.get_switch(conn, switch_id)
@@ -177,8 +176,8 @@ def probe_host_key(switch_id: int):
) )
@bp.post("/<int:switch_id>/hostkey/freigeben") @bp.post("/<int:switch_id>/host-key/trust")
@permission_required("switches.edit") @permission_required("switches.write")
@reauth_required @reauth_required
def trust_host_key(switch_id: int): def trust_host_key(switch_id: int):
"""Gibt einen Host-Schluessel ausdruecklich frei. """Gibt einen Host-Schluessel ausdruecklich frei.
@@ -193,7 +192,7 @@ def trust_host_key(switch_id: int):
key_type = (request.form.get("key_type") or "").strip() key_type = (request.form.get("key_type") or "").strip()
fingerprint = (request.form.get("fingerprint") or "").strip() fingerprint = (request.form.get("fingerprint") or "").strip()
if not key_type or not ssh.valid_fingerprint(fingerprint): if not key_type or not ssh.valid_fingerprint(fingerprint):
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger") flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
return redirect(url_for("switches.detail", switch_id=switch_id)) return redirect(url_for("switches.detail", switch_id=switch_id))
with transaction(): with transaction():
@@ -212,12 +211,12 @@ def trust_host_key(switch_id: int):
f"{key_type} {fingerprint}", f"{key_type} {fingerprint}",
severity="notice", severity="notice",
) )
flash("Host-Schluessel freigegeben.", "success") flash("Host-Schlüssel freigegeben.", "success")
return redirect(url_for("switches.detail", switch_id=switch_id)) return redirect(url_for("switches.detail", switch_id=switch_id))
@bp.post("/<int:switch_id>/hostkey/verwerfen") @bp.post("/<int:switch_id>/host-key/forget")
@permission_required("switches.edit") @permission_required("switches.write")
def forget_host_key(switch_id: int): def forget_host_key(switch_id: int):
conn = db() conn = db()
switch = inventory.get_switch(conn, switch_id) switch = inventory.get_switch(conn, switch_id)
@@ -226,7 +225,7 @@ def forget_host_key(switch_id: int):
with transaction(): with transaction():
ssh.forget_key(conn, str(switch["ip"]), int(switch["ssh_port"])) ssh.forget_key(conn, str(switch["ip"]), int(switch["ssh_port"]))
_audit(conn, "switch.host_key_forgotten", str(switch["hostname"]), severity="warning") _audit(conn, "switch.host_key_forgotten", str(switch["hostname"]), severity="warning")
flash("Gespeicherte Host-Schluessel entfernt.", "info") flash("Gespeicherte Host-Schlüssel entfernt.", "info")
return redirect(url_for("switches.detail", switch_id=switch_id)) return redirect(url_for("switches.detail", switch_id=switch_id))
@@ -235,8 +234,8 @@ def forget_host_key(switch_id: int):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/<int:switch_id>/kommando") @bp.post("/<int:switch_id>/command")
@permission_required("switches.execute") @permission_required("switch_console.execute")
@reauth_required @reauth_required
def run_command(switch_id: int): def run_command(switch_id: int):
"""Fuehrt ein Kommando aus der Positivliste aus. """Fuehrt ein Kommando aus der Positivliste aus.
@@ -257,7 +256,7 @@ def run_command(switch_id: int):
flash("Dieses Kommando steht nicht auf der Positivliste.", "danger") flash("Dieses Kommando steht nicht auf der Positivliste.", "danger")
return redirect(url_for("switches.detail", switch_id=switch_id)) return redirect(url_for("switches.detail", switch_id=switch_id))
if not switch["credential_id"]: if not switch["credential_id"]:
flash("Fuer diesen Switch sind keine Zugangsdaten hinterlegt.", "danger") flash("Für diesen Switch sind keine Zugangsdaten hinterlegt.", "danger")
return redirect(url_for("switches.detail", switch_id=switch_id)) return redirect(url_for("switches.detail", switch_id=switch_id))
try: try:
+60 -20
View File
@@ -12,7 +12,7 @@ Zwei Ziele, ein Ablauf:
* **Switch** -- ``switches.execute``. Interaktive ArubaOS-Sitzung ueber * **Switch** -- ``switches.execute``. Interaktive ArubaOS-Sitzung ueber
:class:`~tesm.services.switchcli.SwitchCli`. :class:`~tesm.services.switchcli.SwitchCli`.
* **Client** -- ``maintenance.execute`` und das lizenzpflichtige Modul * **Client** -- ``device_console.execute`` und das lizenzpflichtige Modul
``maintenance``. Ein Kommando je ``exec``-Kanal ueber ``maintenance``. Ein Kommando je ``exec``-Kanal ueber
:class:`~tesm.services.shellcli.ShellSession`. :class:`~tesm.services.shellcli.ShellSession`.
@@ -24,7 +24,10 @@ Rechteausweitung, die dieses Projekt an anderer Stelle ausschliesst.
Gemeinsame Sicherheitsmerkmale: Gemeinsame Sicherheitsmerkmale:
* Frische Passwortbestaetigung, zusaetzlich zum Recht. * Frische Passwortbestaetigung, zusaetzlich zum Recht.
* Abschaltbar ueber die Einstellung ``terminal_enabled``. * 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 * Der Host-Schluessel muss vorher freigegeben sein -- die Konsole akzeptiert
keine unbekannten Schluessel (anders als der Vorgaenger, der sie hier keine unbekannten Schluessel (anders als der Vorgaenger, der sie hier
interaktiv uebernahm). Freigegeben wird auf der jeweiligen Detailseite. interaktiv uebernahm). Freigegeben wird auf der jeweiligen Detailseite.
@@ -71,7 +74,7 @@ CLIENT_COMMANDS: tuple[tuple[str, str], ...] = (
("ip -brief address", "Netzwerkadressen"), ("ip -brief address", "Netzwerkadressen"),
("systemctl --failed", "Fehlgeschlagene Dienste"), ("systemctl --failed", "Fehlgeschlagene Dienste"),
("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"), ("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"),
("apt list --upgradable 2>/dev/null", "Verfuegbare Aktualisierungen"), ("apt list --upgradable 2>/dev/null", "Verfügbare Aktualisierungen"),
("uname -a", "Kernel und Architektur"), ("uname -a", "Kernel und Architektur"),
) )
@@ -128,6 +131,18 @@ def _load_target(conn: Any, kind: str, target_id: int) -> Target | None:
) )
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: def _client_module_active() -> bool:
manager = core().license manager = core().license
return manager is None or manager.module_active("maintenance") return manager is None or manager.module_active("maintenance")
@@ -136,10 +151,10 @@ def _client_module_active() -> bool:
def _render_console(target: Target): # type: ignore[no-untyped-def] def _render_console(target: Target): # type: ignore[no-untyped-def]
extension = core() extension = core()
conn = db() conn = db()
if not extension.settings.get(conn, "terminal_enabled"): if not _konsole_erlaubt(conn, target.kind):
abort(404) abort(404)
if not target.credential_id: if not target.credential_id:
abort(409, description="Fuer dieses Ziel sind keine Zugangsdaten hinterlegt.") abort(409, description="Für dieses Ziel sind keine Zugangsdaten hinterlegt.")
return render_template( return render_template(
"tesm/console.html", "tesm/console.html",
@@ -151,8 +166,8 @@ def _render_console(target: Target): # type: ignore[no-untyped-def]
) )
@bp.get("/switche/<int:switch_id>/konsole") @bp.get("/monitoring/switches/<int:switch_id>/console")
@permission_required("switches.execute") @permission_required("switch_console.execute")
@reauth_required @reauth_required
def switch_console(switch_id: int): def switch_console(switch_id: int):
target = _load_target(db(), KIND_SWITCH, switch_id) target = _load_target(db(), KIND_SWITCH, switch_id)
@@ -161,8 +176,8 @@ def switch_console(switch_id: int):
return _render_console(target) return _render_console(target)
@bp.get("/clients/<int:device_id>/konsole") @bp.get("/monitoring/clients/<int:device_id>/console")
@permission_required("maintenance.execute") @permission_required("device_console.execute")
@reauth_required @reauth_required
def device_console(device_id: int): def device_console(device_id: int):
if not _client_module_active(): if not _client_module_active():
@@ -180,7 +195,7 @@ def init_app(app) -> None: # type: ignore[no-untyped-def]
try: try:
from flask_sock import Sock from flask_sock import Sock
except ImportError: # pragma: no cover - optionale Abhaengigkeit except ImportError: # pragma: no cover - optionale Abhaengigkeit
logger.info("flask-sock ist nicht installiert -- die Konsole bleibt deaktiviert.") logger.info("flask-sock ist nicht installiert – die Konsole bleibt deaktiviert.")
return return
sock = Sock(app) sock = Sock(app)
@@ -196,7 +211,17 @@ def init_app(app) -> None: # type: ignore[no-untyped-def]
def _send(ws, kind: str, text: str) -> None: # type: ignore[no-untyped-def] def _send(ws, kind: str, text: str) -> None: # type: ignore[no-untyped-def]
ws.send(json.dumps({"type": kind, "data": text})) """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: def _transcript_name(label: str) -> str:
@@ -205,23 +230,34 @@ def _transcript_name(label: str) -> str:
def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-untyped-def] 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() extension = core()
conn = db() conn = db()
_send(ws, "info", "Sitzung wird geöffnet …")
# -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein # -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein
# WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich. # WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich.
required = "switches.execute" if kind == KIND_SWITCH else "maintenance.execute" required = (
"switch_console.execute" if kind == KIND_SWITCH else "device_console.execute"
)
if not current_user.is_authenticated or not current_user.has(required): if not current_user.is_authenticated or not current_user.has(required):
_send(ws, "error", "Keine Berechtigung fuer diese Konsole.") _send(ws, "error", "Keine Berechtigung für diese Konsole.")
return return
if not reauth_is_fresh(): if not reauth_is_fresh():
_send(ws, "error", "Die Passwortbestaetigung ist abgelaufen. Bitte Seite neu laden.") _send(ws, "error", "Die Passwortbestätigung ist abgelaufen. Bitte Seite neu laden.")
return return
if not extension.settings.get(conn, "terminal_enabled"): if not _konsole_erlaubt(conn, kind):
_send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.") _send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.")
return return
if kind == KIND_DEVICE and not _client_module_active(): if kind == KIND_DEVICE and not _client_module_active():
_send(ws, "error", "Die Client-Konsole gehoert zum Modul Wartung -- es ist nicht aktiv.") _send(ws, "error", "Die Client-Konsole gehört zum Modul Wartung – es ist nicht aktiv.")
return return
target = _load_target(conn, kind, target_id) target = _load_target(conn, kind, target_id)
@@ -238,6 +274,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
/ f"{stamp}-{_transcript_name(current_user.username)}-{_transcript_name(target.label)}.log" / f"{stamp}-{_transcript_name(current_user.username)}-{_transcript_name(target.label)}.log"
) )
_send(ws, "info", f"Ziel: {target.label} ({target.host}:{target.port})")
try: try:
username, password = inventory.credential_secret( username, password = inventory.credential_secret(
conn, extension.keystore, target.credential_id conn, extension.keystore, target.credential_id
@@ -245,6 +282,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
except inventory.ValidationError as exc: except inventory.ValidationError as exc:
_send(ws, "error", str(exc)) _send(ws, "error", str(exc))
return return
_send(ws, "info", f"Anmeldung als {username} …")
with extension.database.transaction(conn): with extension.database.transaction(conn):
audit.log( audit.log(
@@ -278,8 +316,8 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
_send( _send(
ws, ws,
"error", "error",
f"{exc} Die Konsole akzeptiert keine unbekannten Schluessel -- bitte auf der " f"{exc} Die Konsole akzeptiert keine unbekannten Schlüssel – bitte auf der "
f"{page} ausdruecklich freigeben.", f"{page} ausdrücklich freigeben.",
) )
except ssh.SshError as exc: except ssh.SshError as exc:
_send(ws, "error", str(exc)) _send(ws, "error", str(exc))
@@ -304,7 +342,7 @@ def _read_command(ws, idle_timeout: int) -> str | None: # type: ignore[no-untyp
"""Naechstes Kommando, oder ``None`` wenn die Sitzung enden soll.""" """Naechstes Kommando, oder ``None`` wenn die Sitzung enden soll."""
raw = ws.receive(timeout=idle_timeout) raw = ws.receive(timeout=idle_timeout)
if raw is None: if raw is None:
_send(ws, "info", "Verbindung wegen Untaetigkeit getrennt.") _send(ws, "info", "Verbindung wegen Untätigkeit getrennt.")
return None return None
try: try:
message = json.loads(raw) message = json.loads(raw)
@@ -326,6 +364,7 @@ def _run_switch_session( # type: ignore[no-untyped-def]
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
) -> int: ) -> int:
commands = 0 commands = 0
_send(ws, "info", "Warte auf die Eingabeaufforderung des Switches …")
with switchcli.SwitchCli( with switchcli.SwitchCli(
conn, host=target.host, port=target.port, username=username, password=password conn, host=target.host, port=target.port, username=username, password=password
) as cli: ) as cli:
@@ -358,6 +397,7 @@ def _run_client_session( # type: ignore[no-untyped-def]
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
) -> int: ) -> int:
commands = 0 commands = 0
_send(ws, "info", "Verbindung wird aufgebaut …")
with shellcli.ShellSession( with shellcli.ShellSession(
conn, host=target.host, port=target.port, username=username, password=password conn, host=target.host, port=target.port, username=username, password=password
) as session: ) as session:
@@ -392,6 +432,6 @@ def _run_client_session( # type: ignore[no-untyped-def]
_send( _send(
ws, ws,
"error", "error",
f"Rueckgabewert {result['status']} nach {result['duration_ms']} ms.", f"Rückgabewert {result['status']} nach {result['duration_ms']} ms.",
) )
return commands return commands
+371 -16
View File
@@ -39,7 +39,7 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
if not password: if not password:
password = getpass.getpass("Passwort: ") password = getpass.getpass("Passwort: ")
if password != getpass.getpass("Passwort wiederholen: "): if password != getpass.getpass("Passwort wiederholen: "):
print("Die Passwoerter stimmen nicht ueberein.", file=sys.stderr) print("Die Passwörter stimmen nicht überein.", file=sys.stderr)
return 2 return 2
problems = extension.password_policy.check(password, username=username) problems = extension.password_policy.check(password, username=username)
@@ -69,13 +69,41 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
) )
user_id = int(cursor.lastrowid or 0) user_id = int(cursor.lastrowid or 0)
action = "angelegt" 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( audit.log(
conn, conn,
actor="cli", actor="cli",
actor_id=user_id, actor_id=user_id,
action="user.admin_created", action="user.admin_created",
target=username, target=username,
detail=f"ueber die Kommandozeile {action}", detail=(
f"über die Kommandozeile {action}, Gruppe "
f"{FUNKTIONEN_VOLLZUGRIFF.name} zugewiesen"
),
severity="warning", severity="warning",
) )
print(f"Administrator {username!r} {action}.") print(f"Administrator {username!r} {action}.")
@@ -133,7 +161,7 @@ def cmd_status(args: argparse.Namespace) -> int:
print( print(
"Audit-Kette : " "Audit-Kette : "
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei Eintrag {chain['broken_at']}") + ("unversehrt" if chain["ok"] else f"GEBROCHEN bei Eintrag {chain['broken_at']}")
+ f" ({chain['checked']} geprueft)" + f" ({chain['checked']} geprüft)"
) )
for key, value in counts.items(): for key, value in counts.items():
print(f"{key.capitalize():<15}: {value}") print(f"{key.capitalize():<15}: {value}")
@@ -168,9 +196,30 @@ def cmd_housekeeping(_args: argparse.Namespace) -> int:
return 0 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: def cmd_check_now(_args: argparse.Namespace) -> int:
app, extension = _app_and_core() app, extension = _app_and_core()
from .services import inventory, monitor from .services import inventory, monitor, vnc
with app.app_context(): with app.app_context():
allowed = extension.license is None or extension.license.feature_allowed( allowed = extension.license is None or extension.license.feature_allowed(
@@ -181,9 +230,10 @@ def cmd_check_now(_args: argparse.Namespace) -> int:
live=extension.registry["live_log"], live=extension.registry["live_log"],
keystore=extension.keystore, keystore=extension.keystore,
license_allows_auto_restart=allowed, license_allows_auto_restart=allowed,
vnc_port=vnc.port_aus_einstellungen(extension),
) )
print( print(
f"{report.checked} geprueft, {report.online} online, {report.offline} offline, " f"{report.checked} geprüft, {report.online} online, {report.offline} offline, "
f"{report.restarts_triggered} Neustarts ({report.restarts_ok} erfolgreich), " f"{report.restarts_triggered} Neustarts ({report.restarts_ok} erfolgreich), "
f"{report.duration_ms} ms" f"{report.duration_ms} ms"
) )
@@ -195,6 +245,123 @@ def cmd_web_setup(args: argparse.Namespace) -> int:
return run_web_setup(app, extension, args) 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: def cmd_convert_legacy(args: argparse.Namespace) -> int:
"""Uebersetzt einen Export des alten TESM in das neue Format. """Uebersetzt einen Export des alten TESM in das neue Format.
@@ -214,7 +381,7 @@ def cmd_convert_legacy(args: argparse.Namespace) -> int:
return 2 return 2
ziel = Path(args.ausgabe) if args.ausgabe else quelle.with_name(f"{quelle.stem}-neu.json") ziel = Path(args.ausgabe) if args.ausgabe else quelle.with_name(f"{quelle.stem}-neu.json")
if ziel.exists() and not args.force: if ziel.exists() and not args.force:
print(f"{ziel} existiert bereits. Mit --force ueberschreiben.") print(f"{ziel} existiert bereits. Mit --force überschreiben.")
return 2 return 2
alt_pass = args.passphrase or getpass("Passphrase der alten Datei: ") alt_pass = args.passphrase or getpass("Passphrase der alten Datei: ")
@@ -231,7 +398,7 @@ def cmd_convert_legacy(args: argparse.Namespace) -> int:
print("--dry-run: keine Datei geschrieben.") print("--dry-run: keine Datei geschrieben.")
return 0 return 0
neu_pass = args.neue_passphrase or getpass("Passphrase fuer die neue Datei: ") neu_pass = args.neue_passphrase or getpass("Passphrase für die neue Datei: ")
try: try:
ziel.write_text(transfer.seal(nutzlast, neu_pass), encoding="utf-8", newline="\n") 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 except Exception as exc: # noqa: BLE001 - Passphrase zu kurz, Pfad nicht schreibbar
@@ -243,15 +410,141 @@ def cmd_convert_legacy(args: argparse.Namespace) -> int:
return 0 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: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="tesm-admin", description="Verwaltungswerkzeuge fuer TESM" prog="tesm-admin", description="Verwaltungswerkzeuge für TESM"
) )
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zuruecksetzen") admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zurücksetzen")
admin.add_argument("--username") admin.add_argument("--username")
admin.add_argument("--password", help="Nur fuer automatisierte Installationen") admin.add_argument("--password", help="Nur für automatisierte Installationen")
admin.add_argument("--force", action="store_true", help="Passwortregeln ignorieren") admin.add_argument("--force", action="store_true", help="Passwortregeln ignorieren")
admin.set_defaults(func=cmd_create_admin) admin.set_defaults(func=cmd_create_admin)
@@ -259,18 +552,40 @@ def build_parser() -> argparse.ArgumentParser:
status.add_argument("--json", action="store_true") status.add_argument("--json", action="store_true")
status.set_defaults(func=cmd_status) status.set_defaults(func=cmd_status)
house = sub.add_parser("housekeeping", help="Sitzungen, Ratenereignisse, Protokolle aufraeumen") house = sub.add_parser("housekeeping", help="Sitzungen, Ratenereignisse, Protokolle aufräumen")
house.set_defaults(func=cmd_housekeeping) house.set_defaults(func=cmd_housekeeping)
check = sub.add_parser("check-now", help="Einen Ueberwachungsdurchlauf sofort ausfuehren") 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) 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( legacy = sub.add_parser(
"convert-legacy", "convert-legacy",
help="Export des alten TESM in das neue Format uebersetzen", help="Export des alten TESM in das neue Format übersetzen",
description=( description=(
"Liest eine Exportdatei des Vorgaengers und schreibt eine Datei im " "Liest eine Exportdatei des Vorgängers und schreibt eine Datei im "
"Format dieser Anwendung. Uebersetzt werden Clients, Switche, " "Format dieser Anwendung. Übersetzt werden Clients, Switche, "
"Zugangsdaten und die DHCP-Konfiguration." "Zugangsdaten und die DHCP-Konfiguration."
), ),
) )
@@ -285,9 +600,49 @@ def build_parser() -> argparse.ArgumentParser:
legacy.add_argument( legacy.add_argument(
"--dry-run", action="store_true", help="Nur den Bericht zeigen, nichts schreiben" "--dry-run", action="store_true", help="Nur den Bericht zeigen, nichts schreiben"
) )
legacy.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben") legacy.add_argument("--force", action="store_true", help="Zieldatei überschreiben")
legacy.set_defaults(func=cmd_convert_legacy) 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) add_web_setup(sub, cmd_web_setup)
return parser return parser
+38 -36
View File
@@ -13,6 +13,8 @@ from typing import Any
from tesm_core.extension import core from tesm_core.extension import core
from tesm_core.nav import NavItem, NavTree from tesm_core.nav import NavItem, NavTree
from .permissions import DHCP_PAGE, SERVICING_PAGE
def _module(name: str): def _module(name: str):
"""Bedingung: Vollmodul lizenziert?""" """Bedingung: Vollmodul lizenziert?"""
@@ -36,7 +38,7 @@ def _has_fileshare_mounts(user: Any) -> bool:
return False return False
if not _module("fileshare")(user): if not _module("fileshare")(user):
return False return False
if user.has("fileshare.edit"): if user.has("fileshare.write"):
return True return True
from .services import fileshare from .services import fileshare
@@ -45,10 +47,10 @@ def _has_fileshare_mounts(user: Any) -> bool:
NAVIGATION = NavTree( NAVIGATION = NavTree(
[ [
NavItem(key="index", label="Statusuebersicht", icon="grid", endpoint="dashboard.index"), NavItem(key="index", label="Statusübersicht", icon="grid", endpoint="dashboard.index"),
NavItem( NavItem(
key="monitoring", key="monitoring",
label="Ueberwachung", label="Überwachung",
icon="cpu", icon="cpu",
children=( children=(
NavItem( NavItem(
@@ -56,45 +58,45 @@ NAVIGATION = NavTree(
label="Clients", label="Clients",
icon="cpu", icon="cpu",
endpoint="devices.overview", endpoint="devices.overview",
permissions=("devices.view",), permissions=("devices.read",),
), ),
NavItem( NavItem(
key="switches", key="switches",
label="Switche", label="Switche",
icon="switch", icon="switch",
endpoint="switches.overview", endpoint="switches.overview",
permissions=("switches.view",), permissions=("switches.read",),
), ),
NavItem( NavItem(
key="credentials", key="credentials",
label="Zugangsdaten", label="Zugangsdaten",
icon="key", icon="key",
endpoint="credentials.overview", endpoint="credentials.overview",
permissions=("credentials.view",), permissions=("credentials.read",),
), ),
NavItem( NavItem(
key="hostkeys", key="hostkeys",
label="Host-Schluessel", label="Host-Schlüssel",
icon="shield", icon="shield",
endpoint="hostkeys.overview", endpoint="hostkeys.overview",
permissions=("devices.view", "switches.view"), permissions=("devices.read", "switches.read"),
),
NavItem(
key="maintenance",
label="Wartung",
icon="wrench",
endpoint="maintenance.overview",
permissions=("maintenance.view", "maintenance.execute"),
condition=_module("maintenance"),
), ),
), ),
), ),
NavItem(
key="maintenance",
label="Wartung",
icon="wrench",
endpoint="maintenance.overview",
permissions=SERVICING_PAGE,
condition=_module("maintenance"),
),
NavItem( NavItem(
key="dhcp", key="dhcp",
label="DHCP-Server", label="DHCP-Server",
icon="network", icon="network",
endpoint="dhcp.overview", endpoint="dhcp.overview",
permissions=("dhcp.view", "dhcp.edit", "dhcp.execute"), permissions=DHCP_PAGE,
condition=_module("dhcp"), condition=_module("dhcp"),
), ),
NavItem( NavItem(
@@ -108,14 +110,14 @@ NAVIGATION = NavTree(
label="Meine Freigaben", label="Meine Freigaben",
icon="folder", icon="folder",
endpoint="fileshare.browse", endpoint="fileshare.browse",
permissions=("fileshare.view",), permissions=("fileshare.read",),
), ),
NavItem( NavItem(
key="fileshare_mappings", key="fileshare_mappings",
label="Zuordnungen", label="Zuordnungen",
icon="link", icon="link",
endpoint="fileshare.mappings", endpoint="fileshare.mappings",
permissions=("fileshare.edit",), permissions=("fileshare.write",),
), ),
), ),
), ),
@@ -129,28 +131,28 @@ NAVIGATION = NavTree(
label="Laufend", label="Laufend",
icon="terminal", icon="terminal",
endpoint="logs.live", endpoint="logs.live",
permissions=("logs_live.view",), permissions=("logs_live.read",),
), ),
NavItem( NavItem(
key="logs_history", key="logs_history",
label="Verlauf", label="Verlauf",
icon="clock", icon="clock",
endpoint="logs.history", endpoint="logs.history",
permissions=("logs_history.view", "logs_history.export"), permissions=("logs_history.read", "logs_history.export"),
), ),
NavItem( NavItem(
key="logs_audit", key="logs_audit",
label="Aenderungen", label="Audit",
icon="history", icon="history",
endpoint="logs.audit_view", endpoint="logs.audit_view",
permissions=("logs_audit.view", "logs_audit.export"), permissions=("logs_audit.read", "logs_audit.export"),
), ),
NavItem( NavItem(
key="logs_dhcp", key="logs_dhcp",
label="Kea-DHCP", label="DHCP",
icon="network", icon="network",
endpoint="tesmlogs.dhcp", endpoint="tesmlogs.dhcp",
permissions=("logs_dhcp.view",), permissions=("logs_dhcp.read",),
condition=_module("dhcp"), condition=_module("dhcp"),
), ),
NavItem( NavItem(
@@ -158,7 +160,7 @@ NAVIGATION = NavTree(
label="Neustarts", label="Neustarts",
icon="power", icon="power",
endpoint="tesmlogs.restarts", endpoint="tesmlogs.restarts",
permissions=("devices.view",), permissions=("devices.read",),
), ),
), ),
), ),
@@ -172,42 +174,42 @@ NAVIGATION = NavTree(
label="Benutzer", label="Benutzer",
icon="users", icon="users",
endpoint="admin.users", endpoint="admin.users",
permissions=("users.view",), permissions=("users.read",),
), ),
NavItem( NavItem(
key="groups", key="groups",
label="Gruppen und Rechte", label="Gruppen und Rechte",
icon="shield-users", icon="shield-users",
endpoint="admin.groups", endpoint="admin.groups",
permissions=("groups.view",), permissions=("groups.read",),
), ),
NavItem( NavItem(
key="settings_system", key="settings_system",
label="System", label="System",
icon="sliders", icon="sliders",
endpoint="settings.system", endpoint="settings.system",
permissions=("settings_system.view",), permissions=("settings_system.read",),
), ),
NavItem( NavItem(
key="settings_network", key="settings_network",
label="Netzwerk", label="Netzwerk",
icon="network", icon="network",
endpoint="hostsettings.network", endpoint="hostsettings.network",
permissions=("settings_network.view",), permissions=("settings_network.read",),
), ),
NavItem( NavItem(
key="settings_web", key="settings_web",
label="Webserver und TLS", label="Webserver und TLS",
icon="server", icon="server",
endpoint="hostsettings.web", endpoint="hostsettings.web",
permissions=("settings_web.view",), permissions=("settings_web.read",),
), ),
NavItem( NavItem(
key="settings_directory", key="settings_directory",
label="Verzeichnisdienst", label="Verzeichnisdienst",
icon="users", icon="users",
endpoint="hostsettings.directory_settings", endpoint="hostsettings.directory_settings",
permissions=("settings_directory.view",), permissions=("settings_directory.read",),
), ),
NavItem( NavItem(
key="settings_license", key="settings_license",
@@ -216,28 +218,28 @@ NAVIGATION = NavTree(
endpoint="license.overview", endpoint="license.overview",
# Bewusst immer erreichbar, auch ohne gueltige Lizenz -- # Bewusst immer erreichbar, auch ohne gueltige Lizenz --
# sonst liesse sich eine neue Lizenz nie einspielen. # sonst liesse sich eine neue Lizenz nie einspielen.
permissions=("settings_license.view",), permissions=("settings_license.read",),
), ),
NavItem( NavItem(
key="backup", key="backup",
label="Sicherung", label="Sicherung",
icon="transfer", icon="transfer",
endpoint="backup.overview", endpoint="backup.overview",
permissions=("backup.view", "backup.export", "backup.execute"), permissions=("backup_export.read", "backup_import.read"),
), ),
NavItem( NavItem(
key="trash", key="trash",
label="Papierkorb", label="Papierkorb",
icon="trash", icon="trash",
endpoint="trash.overview", endpoint="trash.overview",
permissions=("trash.view",), permissions=("trash.read",),
), ),
NavItem( NavItem(
key="diagnostics", key="diagnostics",
label="Diagnose", label="Diagnose",
icon="info", icon="info",
endpoint="diagnostics.overview", endpoint="diagnostics.overview",
permissions=("diagnostics.view",), permissions=("diagnostics.read",),
), ),
), ),
), ),
+339 -99
View File
@@ -30,101 +30,351 @@ A = Action
DEVICES = Resource( DEVICES = Resource(
key="devices", key="devices",
label="Clients", label="Clients",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE), actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Ueberwachte PoE-Endgeraete", hint="Stammdaten überwachter PoE-Endgeräte: Name, MAC, IP, Switch und Port",
action_hints={ action_hints={
A.EXECUTE: "PoE-Neustart eines Geraets manuell ausloesen. Der automatische " A.DELETE: "Gerät in den Papierkorb legen.",
"Neustart bei Ausfall ist davon unabhaengig und laeuft immer.", },
A.DELETE: "Geraet 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( SWITCHES = Resource(
key="switches", key="switches",
label="Switche", label="Switche",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE), actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Aruba-Switche und ihre Erreichbarkeit", 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={ action_hints={
A.EXECUTE: "Browser-Terminal oeffnen und Kommandos auf dem Switch ausfuehren. " A.EXECUTE: "Sitzung öffnen. Verlangt eine frische Passwortbestätigung; "
"Verlangt zusaetzlich eine frische Passwortbestaetigung; jede Sitzung wird " "jede Sitzung wird vollständig mitgeschrieben.",
"vollstaendig protokolliert.", },
)
#: 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( CREDENTIALS = Resource(
key="credentials", key="credentials",
label="Zugangsdaten", label="Zugangsdaten",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.SECRETS), actions=(A.READ, A.CREATE, A.WRITE, A.DELETE, A.SECRETS),
hint="Wiederverwendbare SSH-Logins", hint="Wiederverwendbare SSH-Logins",
action_hints={ action_hints={
A.VIEW: "Namen und Benutzer sehen -- nicht das Passwort.", A.READ: "Namen und Benutzer sehen – nicht das Passwort.",
A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische " A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische "
"Passwortbestaetigung und wird protokolliert.", "Passwortbestätigung und wird protokolliert.",
}, },
) )
MAINTENANCE = Resource( #: Aus einer Zeile ("Aktualisierung und Neustart") wurden drei. Sie deckte
key="maintenance", #: vier Funktionen ab: Pakete aktualisieren, reihenweise neu starten, das
label="Wartung", #: Windows-Werkzeug installieren -- und die Client-Konsole, die jetzt bei den
actions=(A.VIEW, A.EXECUTE), #: Clients steht, wo sie hingehoert.
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)", SERVICING_UPDATE = Resource(
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewaehlten Geraeten starten."}, 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."},
) )
DHCP = Resource( SERVICING_REBOOT = Resource(
key="dhcp", key="servicing_reboot",
label="DHCP-Server", label="Neustart als Auftrag",
actions=(A.VIEW, A.EDIT, A.EXECUTE), actions=(A.READ, A.EXECUTE),
hint="Kea-DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)", 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={ action_hints={
A.EDIT: "Konfiguration aendern und schreiben.", A.EXECUTE: "Schreiben und neu laden. Bis dahin ändert sich am laufenden "
A.EXECUTE: "Dienst installieren, starten oder stoppen.", "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( FILESHARE = Resource(
key="fileshare", key="fileshare",
label="Dateifreigaben", label="Dateifreigaben",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), actions=(A.READ, A.CREATE, A.WRITE, A.DELETE, A.EXPORT),
hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)", hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
action_hints={ action_hints={
A.CREATE: "Ordner anlegen und Dateien hochladen.", A.CREATE: "Ordner anlegen und Dateien hochladen.",
A.EDIT: "Umbenennen und verschieben.", 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( LOGS_DHCP = Resource(
key="logs_dhcp", key="logs_dhcp",
label="Kea-DHCP-Protokoll", label="DHCP-Protokoll",
actions=(A.VIEW,), actions=(A.READ,),
) )
MONITORING_AREA = Area( MONITORING_AREA = Area(
key="monitoring", key="monitoring",
label="Ueberwachung", label="Überwachung",
icon="cpu", icon="cpu",
hint="Geraete, Switche, Zugangsdaten und Wartung. Ohne diesen Zugang bleibt der " hint="Geräte, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der "
"gesamte Bereich verborgen.", "gesamte Bereich verborgen.",
resources=(DEVICES, SWITCHES, CREDENTIALS, MAINTENANCE), 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( NETWORK_AREA = Area(
key="network", key="network",
label="Netzwerkdienste", label="Netzwerkdienste",
icon="network", icon="network",
resources=(DHCP,), resources=(DHCP_SUBNETS, DHCP_OPTIONS, DHCP_RESERVATIONS, DHCP_CONFIG, DHCP_SERVICE),
) )
FILES_AREA = Area( FILES_AREA = Area(
key="files", key="files",
label="Dateifreigaben", label="Dateifreigaben",
icon="folder", icon="folder",
resources=(FILESHARE,), resources=(FILESHARE, FILESHARE_PDF, FILESHARE_SIGNATURE, FILESHARE_MAPPINGS),
) )
PERMISSIONS = PermissionTree( PERMISSIONS = PermissionTree(
[ [
MONITORING_AREA, MONITORING_AREA,
MAINTENANCE_AREA,
NETWORK_AREA, NETWORK_AREA,
FILES_AREA, FILES_AREA,
observability_area(LOGS_DHCP), observability_area(LOGS_DHCP),
@@ -133,90 +383,80 @@ PERMISSIONS = PermissionTree(
) )
#: 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, ...] = ( ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset( RolePreset(
key="viewer", key="viewer",
name="Benutzer", name="Benutzer",
description="Standardgruppe: Geraete und Switche ansehen, laufendes Protokoll lesen.", description="Standardgruppe: Geräte und Switche ansehen, laufendes Protokoll lesen.",
grants=( grants=(
("devices", (A.VIEW,)), ("devices", (A.READ,)),
("switches", (A.VIEW,)), ("switches", (A.READ,)),
("logs_live", (A.VIEW,)), ("logs_live", (A.READ,)),
), ),
is_default=True, is_default=True,
), ),
RolePreset(
key="operations",
name="Betrieb",
description="Geraete und Switche pflegen, Neustarts ausloesen, Wartung fahren.",
grants=(
("devices", "*"),
("switches", (A.VIEW, A.CREATE, A.EDIT, A.EXECUTE)),
("credentials", (A.VIEW,)),
("maintenance", "*"),
("logs_live", (A.VIEW,)),
("logs_history", (A.VIEW,)),
("trash", (A.VIEW, A.EDIT)),
),
),
RolePreset(
key="support",
name="Support",
description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart ausloesen.",
grants=(
("devices", (A.VIEW, A.EXECUTE)),
("switches", (A.VIEW,)),
("logs_live", (A.VIEW,)),
),
),
RolePreset(
key="network",
name="Netzwerk",
description="DHCP und Host-Netzwerkeinstellungen.",
grants=(
("dhcp", "*"),
("settings_network", "*"),
("logs_dhcp", (A.VIEW,)),
("devices", (A.VIEW,)),
("switches", (A.VIEW,)),
),
),
RolePreset(
key="auditor",
name="Revision",
description="Nur lesen -- inklusive Aenderungsprotokoll und Diagnose.",
grants=(
("devices", (A.VIEW,)),
("switches", (A.VIEW,)),
("credentials", (A.VIEW,)),
("dhcp", (A.VIEW,)),
("users", (A.VIEW,)),
("groups", (A.VIEW,)),
("logs_live", (A.VIEW,)),
("logs_history", (A.VIEW, A.EXPORT)),
("logs_audit", (A.VIEW, A.EXPORT)),
("diagnostics", (A.VIEW,)),
("trash", (A.VIEW,)),
),
),
RolePreset( RolePreset(
key="administration", key="administration",
name="Administration", name="Administration",
description="Benutzer, Gruppen und Einstellungen -- ohne Admin-Kennzeichen.", description="Benutzer, Gruppen und Einstellungen – ohne Admin-Kennzeichen.",
grants=( grants=(
("users", "*"), ("users", "*"),
("groups", "*"), ("groups", "*"),
("settings_system", "*"), ("settings_system", "*"),
("settings_nav", "*"),
("settings_license", "*"), ("settings_license", "*"),
("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)), ("settings_directory", (A.READ, A.WRITE, A.EXECUTE)),
("settings_web", "*"), ("settings_web", "*"),
("settings_network", "*"), ("settings_network", "*"),
("backup", (A.VIEW, A.EXECUTE, A.EXPORT)), ("backup_export", (A.READ, A.EXPORT)),
("backup_import", "*"),
("trash", "*"), ("trash", "*"),
("diagnostics", (A.VIEW,)), ("diagnostics", (A.READ,)),
("logs_audit", (A.VIEW,)), ("logs_audit", (A.READ,)),
), ),
), ),
FUNKTIONEN_VOLLZUGRIFF,
) )
__all__ = ["PERMISSIONS", "ROLE_PRESETS", "LOGS_DHCP"] __all__ = [
"PERMISSIONS",
"ROLE_PRESETS",
"LOGS_DHCP",
"SERVICING_PAGE",
"DHCP_PAGE",
]
+331
View File
@@ -23,6 +23,8 @@ Frische Zeitlinie ab Version 1 -- kein Nachbau der 35 gewachsenen
from __future__ import annotations from __future__ import annotations
from typing import Any
from tesm_core.migrations import Migration from tesm_core.migrations import Migration
V1 = """ V1 = """
@@ -250,8 +252,337 @@ CREATE INDEX IF NOT EXISTS idx_restart_events_method ON restart_events(method, t
""" """
# 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, ...] = ( TESM_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1), Migration(version=1, name="baseline", sql=V1),
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options), Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
Migration(version=3, name="restart-events", sql=V3), 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),
) )
+687 -23
View File
@@ -21,17 +21,21 @@ Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger:
from __future__ import annotations from __future__ import annotations
import hashlib
import zlib
import json
import logging import logging
import mimetypes import mimetypes
import os import os
import re import re
import secrets import secrets
import shutil import shutil
import socket
import sqlite3 import sqlite3
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import Any, Iterable from typing import Any, Iterable, Mapping
from tesm_core.db import Database from tesm_core.db import Database
from tesm_core.extension import core from tesm_core.extension import core
@@ -39,7 +43,7 @@ from tesm_core.extension import core
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_UNC_HINT = ( _UNC_HINT = (
"Der Freigabepfad muss die Form \\\\server\\freigabe haben. Schraegstriche " "Der Freigabepfad muss die Form \\\\server\\freigabe haben. Schrägstriche "
"gehen auch: //server/freigabe." "gehen auch: //server/freigabe."
) )
@@ -56,6 +60,10 @@ PREVIEW_MIME: dict[str, str] = {
".gif": "image/gif", ".gif": "image/gif",
".webp": "image/webp", ".webp": "image/webp",
".bmp": "image/bmp", ".bmp": "image/bmp",
# Das Format, in dem ein iPad fotografiert. Safari zeigt es an, andere
# Browser nicht -- die Vorschau sagt es dann und bietet den Download an.
".heic": "image/heic",
".heif": "image/heif",
".txt": "text/plain; charset=utf-8", ".txt": "text/plain; charset=utf-8",
".csv": "text/plain; charset=utf-8", ".csv": "text/plain; charset=utf-8",
".log": "text/plain; charset=utf-8", ".log": "text/plain; charset=utf-8",
@@ -67,6 +75,21 @@ PREVIEW_MIME: dict[str, str] = {
#: im Ursprung der Anwendung ausgefuehrt. Download bleibt moeglich. #: im Ursprung der Anwendung ausgefuehrt. Download bleibt moeglich.
def preview_kind(name: str) -> str:
"""Welche Anzeige passt: ``bild``, ``pdf`` oder ``text``?
Die Unterscheidung gehoert hierher und nicht in die Vorlage: die
Zuordnung Endung -> MIME-Typ steht schon hier, und eine zweite Liste in
Jinja liefe frueher oder spaeter auseinander.
"""
mime = PREVIEW_MIME.get(Path(name).suffix.lower(), "")
if mime.startswith("image/"):
return "bild"
if mime == "application/pdf":
return "pdf"
return "text"
class FileshareError(Exception): class FileshareError(Exception):
"""Fehler mit Klartextmeldung fuer die Oberflaeche.""" """Fehler mit Klartextmeldung fuer die Oberflaeche."""
@@ -114,12 +137,123 @@ def validate_unc(value: str) -> str:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _resolvable(host: str) -> bool:
"""Loest dieser Name auf diesem Server auf?"""
try:
socket.getaddrinfo(host, None)
except (socket.gaierror, UnicodeError, OSError):
return False
return True
def search_suffixes(conn: sqlite3.Connection) -> list[str]:
"""Domaenen, mit denen ein kurzer Hostname vervollstaendigt werden kann.
Ein Windows-Client loest "S2025" ueber seine Suchdomaene auf. Dieser Server
hat oft keine (``search .`` in resolv.conf), und dann scheitert die
Einbindung mit "could not resolve address for S2025" -- obwohl der Name im
Netz voellig richtig ist. Die AD-Domaene ist aber bekannt, also wird sie
hier zum Vervollstaendigen benutzt.
"""
kandidaten: list[str] = []
einstellungen = core().settings
basis = str(einstellungen.get(conn, "ldap_base_dn") or "").strip()
if basis:
# DC=ad,DC=eertmoed,DC=net -> ad.eertmoed.net
teile = [
stueck.split("=", 1)[1]
for stueck in basis.split(",")
if stueck.strip().lower().startswith("dc=")
]
if teile:
kandidaten.append(".".join(teile))
rechner = str(einstellungen.get(conn, "ldap_host") or "").strip()
if rechner and "." in rechner:
kandidaten.append(rechner)
# Auch die Elterndomaene: ist ldap_host ein DC-Name, liegt die Domaene
# eine Ebene darueber.
kandidaten.append(rechner.split(".", 1)[1])
# Reihenfolge erhalten, Doppelte entfernen.
gesehen: set[str] = set()
return [k for k in kandidaten if k and not (k in gesehen or gesehen.add(k))]
def resolve_unc_host(unc: str, suffixes: Iterable[str]) -> tuple[str, str]:
"""Ersetzt den Hostnamen einer UNC durch einen aufloesbaren.
Rueckgabe: ``(unc, hinweis)``. Der Hinweis ist leer, wenn nichts geaendert
wurde; laesst sich der Name gar nicht aufloesen, bleibt die UNC unveraendert
und der Hinweis nennt das Problem.
"""
teile = unc.lstrip(chr(92)).split(chr(92), 1)
if not teile or not teile[0]:
return unc, ""
host = teile[0]
rest = teile[1] if len(teile) > 1 else ""
if _resolvable(host):
return unc, ""
for suffix in suffixes:
kandidat = f"{host}.{suffix.strip('.')}"
if _resolvable(kandidat):
neu = chr(92) * 2 + kandidat + (chr(92) + rest if rest else "")
return neu, f"Name {host} über {kandidat} aufgelöst"
return unc, (
f"Der Name {host} ist auf diesem Server nicht auflösbar. Bitte den "
"vollqualifizierten Namen oder die IP-Adresse eintragen – oder unter "
"Netzwerk eine Suchdomäne setzen."
)
def list_mappings(conn: sqlite3.Connection) -> list[dict[str, Any]]: def list_mappings(conn: sqlite3.Connection) -> list[dict[str, Any]]:
return Database.all( return Database.all(
conn, "SELECT * FROM fileshare_mappings ORDER BY label COLLATE NOCASE" conn, "SELECT * FROM fileshare_mappings ORDER BY label COLLATE NOCASE"
) )
#: Felder einer Zuordnung mit der Beschriftung, unter der sie in der
#: Oberflaeche stehen. Das Protokoll soll dieselben Woerter benutzen wie das
#: Formular -- sonst muss man beim Lesen uebersetzen.
MAPPING_FELDER: tuple[tuple[str, str], ...] = (
("label", "Anzeigename"),
("unc", "Pfad"),
("ad_group_name", "Gruppe"),
("ad_group_dn", "Gruppen-DN"),
)
def get_mapping(conn: sqlite3.Connection, mapping_id: int) -> dict[str, Any] | None:
return Database.one(conn, "SELECT * FROM fileshare_mappings WHERE id=?", (mapping_id,))
def describe_mapping(row: Mapping[str, Any] | None) -> str:
"""Eine Zuordnung in einer Zeile -- fuer das Protokoll.
Ohne sie stand beim Loeschen nur die Nummer der Zuordnung im Protokoll.
Die ist nach dem Loeschen nirgends mehr nachschlagbar; damit war der
Eintrag da, aber wertlos.
"""
if row is None:
return ""
teile = [str(row["unc"] or "")]
gruppe = str(row["ad_group_name"] or row["ad_group_dn"] or "")
if gruppe:
teile.append(f"Gruppe: {gruppe}")
return " – ".join(teil for teil in teile if teil)
def mapping_changes(vorher: Mapping[str, Any] | None, nachher: Mapping[str, Any] | None) -> str:
"""Was sich geaendert hat, Feld fuer Feld: ``Anzeigename: alt -> neu``."""
if vorher is None or nachher is None:
return ""
geaendert = []
for feld, beschriftung in MAPPING_FELDER:
alt = str(vorher[feld] or "")
neu = str(nachher[feld] or "")
if alt != neu:
geaendert.append(f"{beschriftung}: {alt or '(leer)'} -> {neu or '(leer)'}")
return "; ".join(geaendert)
def add_mapping( def add_mapping(
conn: sqlite3.Connection, *, ad_group_dn: str, ad_group_name: str, label: str, unc: str conn: sqlite3.Connection, *, ad_group_dn: str, ad_group_name: str, label: str, unc: str
) -> int: ) -> int:
@@ -128,7 +262,7 @@ def add_mapping(
if not clean_label: if not clean_label:
raise FileshareError("Der Anzeigename darf nicht leer sein.") raise FileshareError("Der Anzeigename darf nicht leer sein.")
if not (ad_group_dn or "").strip(): if not (ad_group_dn or "").strip():
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.") raise FileshareError("Es ist keine Verzeichnisgruppe ausgewählt.")
cursor = conn.execute( cursor = conn.execute(
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) " "INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO NOTHING", "VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO NOTHING",
@@ -139,6 +273,52 @@ def add_mapping(
return int(cursor.lastrowid or 0) return int(cursor.lastrowid or 0)
def update_mapping(
conn: sqlite3.Connection,
mapping_id: int,
*,
ad_group_dn: str,
ad_group_name: str,
label: str,
unc: str,
) -> None:
"""Aendert eine bestehende Zuordnung.
Dieselben Pruefungen wie beim Anlegen -- eine Zuordnung, die man nur neu
anlegen statt korrigieren kann, verleitet dazu, den Tippfehler stehen zu
lassen und daneben einen zweiten Eintrag zu machen.
"""
clean_unc = validate_unc(unc)
clean_label = (label or "").strip()
clean_dn = (ad_group_dn or "").strip()
if not clean_label:
raise FileshareError("Der Anzeigename darf nicht leer sein.")
if not clean_dn:
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewählt.")
vorhanden = Database.one(
conn, "SELECT id FROM fileshare_mappings WHERE id=?", (mapping_id,)
)
if vorhanden is None:
raise FileshareError("Diese Zuordnung existiert nicht mehr.")
# Die Eindeutigkeit (ad_group_dn, unc) gilt weiterhin -- nur nicht gegen
# sich selbst.
kollision = Database.one(
conn,
"SELECT id FROM fileshare_mappings WHERE ad_group_dn=? AND unc=? AND id<>?",
(clean_dn, clean_unc, mapping_id),
)
if kollision is not None:
raise FileshareError("Diese Zuordnung existiert bereits.")
conn.execute(
"UPDATE fileshare_mappings SET ad_group_dn=?, ad_group_name=?, label=?, unc=? "
"WHERE id=?",
(clean_dn, (ad_group_name or "").strip(), clean_label, clean_unc, mapping_id),
)
def delete_mapping(conn: sqlite3.Connection, mapping_id: int) -> None: def delete_mapping(conn: sqlite3.Connection, mapping_id: int) -> None:
conn.execute("DELETE FROM fileshare_mappings WHERE id=?", (mapping_id,)) conn.execute("DELETE FROM fileshare_mappings WHERE id=?", (mapping_id,))
@@ -185,12 +365,32 @@ def mount_root() -> Path:
return Path(str(core().settings.get(db(), "fileshare_mount_root") or "/mnt/tesm-shares")) return Path(str(core().settings.get(db(), "fileshare_mount_root") or "/mnt/tesm-shares"))
def active_mounts(conn: sqlite3.Connection, user_id: int) -> list[Mount]: def aktuelle_sitzung() -> str:
"""Die ID der laufenden Sitzung -- oder leer ausserhalb einer Anfrage."""
from flask import session
return str(getattr(session, "sid", "") or "")
def active_mounts(
conn: sqlite3.Connection, user_id: int, session_id: str | None = None
) -> list[Mount]:
"""Die offenen Einbindungen dieses Kontos.
Mit ``session_id`` nur die dieser Sitzung. Das braucht das Abmelden: wer
an zwei Geraeten angemeldet ist, hat je Geraet eine eigene Einbindung mit
eigenem Einhaengepunkt, und das Abmelden am einen darf das andere nicht
aushaengen.
"""
bedingung = "AND session_id = ? " if session_id else ""
werte: list[Any] = [user_id, _iso(_now())]
if session_id:
werte.insert(1, session_id)
rows = Database.all( rows = Database.all(
conn, conn,
"SELECT * FROM fileshare_mounts WHERE user_id=? AND released_at IS NULL " "SELECT * FROM fileshare_mounts WHERE user_id=? " + bedingung
"AND expires_at > ? ORDER BY label COLLATE NOCASE", + "AND released_at IS NULL AND expires_at > ? ORDER BY label COLLATE NOCASE, id DESC",
(user_id, _iso(_now())), tuple(werte),
) )
return [ return [
Mount( Mount(
@@ -211,6 +411,38 @@ def has_mounts(user_id: int) -> bool:
return bool(active_mounts(db(), user_id)) return bool(active_mounts(db(), user_id))
def doppelte_zusammenfassen(
conn: sqlite3.Connection, mounts: list[Mount], session_id: str = ""
) -> list[Mount]:
"""Je Freigabe eine Karteikarte -- vorzugsweise die dieser Sitzung.
Wer an zwei Geraeten angemeldet ist, hat zwei Einbindungen derselben
Freigabe: eine je Sitzung, jede mit eigenem Einhaengepunkt. In der Leiste
stand sie deshalb doppelt, einmal "Share" und einmal wieder "Share" -- ohne
jeden Unterschied fuer den Betrachter.
Genommen wird die Einbindung der eigenen Sitzung. Nur wenn es die nicht
gibt (alte Zeile, Hintergrundprozess), bleibt eine fremde stehen.
"""
eigene: set[int] = set()
if session_id:
eigene = {
int(zeile["id"])
for zeile in Database.all(
conn,
"SELECT id FROM fileshare_mounts WHERE session_id=? AND released_at IS NULL",
(session_id,),
)
}
gewaehlt: dict[str, Mount] = {}
for mount in mounts:
schluessel = mount.unc.lower()
vorhanden = gewaehlt.get(schluessel)
if vorhanden is None or (mount.id in eigene and vorhanden.id not in eigene):
gewaehlt[schluessel] = mount
return sorted(gewaehlt.values(), key=lambda m: m.label.lower())
def mount_for_login( def mount_for_login(
conn: sqlite3.Connection, conn: sqlite3.Connection,
*, *,
@@ -230,7 +462,7 @@ def mount_for_login(
extension = core() extension = core()
sysops = extension.sysops sysops = extension.sysops
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
return ["Systemhelfer nicht verfuegbar -- keine Einbindungen."] return ["Systemhelfer nicht verfügbar – keine Einbindungen."]
dns = [dn for dn in group_dns if dn and dn.strip()] dns = [dn for dn in group_dns if dn and dn.strip()]
shares = shares_for_groups(conn, dns) shares = shares_for_groups(conn, dns)
@@ -253,18 +485,56 @@ def mount_for_login(
# eingebunden, aber leer. Das betrifft die Anmeldung mit zweitem Faktor, bei # eingebunden, aber leer. Das betrifft die Anmeldung mit zweitem Faktor, bei
# der das Passwort im zweiten Schritt nicht mehr vorliegt. # der das Passwort im zweiten Schritt nicht mehr vorliegt.
if not password: if not password:
return ["Kein Passwort fuer den Fileserver verfuegbar -- keine Einbindungen."] return ["Kein Passwort für den Fileserver verfügbar – keine Einbindungen."]
# Was zuerst kommt: das Ende der Sitzung oder die eigene Obergrenze. Die
# Sitzung ist die eigentliche Klammer -- mit ihr endet die Einbindung; die
# Obergrenze sorgt dafuer, dass eine sehr lange Sitzung nicht beliebig
# lange auf fremden Zugangsdaten sitzt.
hours = int(extension.settings.get(conn, "fileshare_max_age_hours") or 12) hours = int(extension.settings.get(conn, "fileshare_max_age_hours") or 12)
frist = _now() + timedelta(hours=hours)
# Die Sitzungszeile gibt es hier noch nicht: eingebunden wird waehrend der
# Anmeldung, geschrieben wird sie erst beim Speichern der Antwort. Die
# Dauer steht aber in derselben Einstellung, aus der die Sitzung sie
# gleich bekommt -- und falls die Zeile doch schon steht (erneutes
# Einbinden), gilt deren Ende.
zeile = (
Database.one(
conn, "SELECT absolute_expires_at FROM user_sessions WHERE id=?", (session_id,)
)
if session_id
else None
)
if zeile is not None and zeile["absolute_expires_at"]:
try:
sitzungsende = datetime.fromisoformat(str(zeile["absolute_expires_at"]))
if sitzungsende.tzinfo is None:
sitzungsende = sitzungsende.replace(tzinfo=timezone.utc)
frist = min(frist, sitzungsende)
except ValueError:
pass
else:
try:
dauer = int(extension.settings.get(conn, "session_absolute_hours") or 0)
except (TypeError, ValueError):
dauer = 0
if dauer > 0:
frist = min(frist, _now() + timedelta(hours=dauer))
root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares")) root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares"))
messages: list[str] = [] messages: list[str] = []
suffixes = search_suffixes(conn)
for share in shares: for share in shares:
token = secrets.token_urlsafe(16) token = secrets.token_urlsafe(16)
target = root / token target = root / token
result = sysops.mount_cifs( # Kurze Hostnamen vervollstaendigen, bevor mount daran scheitert.
str(share["unc"]), str(target), username, password, domain=domain unc, hinweis = resolve_unc_host(str(share["unc"]), suffixes)
) if unc == str(share["unc"]) and hinweis:
messages.append(f"{share['label']}: {hinweis}")
continue
if hinweis:
logger.info("%s: %s", share["label"], hinweis)
result = sysops.mount_cifs(unc, str(target), username, password, domain=domain)
if not result.ok: if not result.ok:
messages.append(f"{share['label']}: Einbindung fehlgeschlagen ({result.message})") messages.append(f"{share['label']}: Einbindung fehlgeschlagen ({result.message})")
continue continue
@@ -279,7 +549,7 @@ def mount_for_login(
str(share["unc"]), str(share["unc"]),
str(target), str(target),
_iso(_now()), _iso(_now()),
_iso(_now() + timedelta(hours=hours)), _iso(frist),
), ),
) )
messages.append(f"{share['label']}: eingebunden") messages.append(f"{share['label']}: eingebunden")
@@ -298,14 +568,48 @@ def release_mount(conn: sqlite3.Connection, mount_id: int) -> None:
) )
def release_all_for_user(conn: sqlite3.Connection, user_id: int) -> int: def forget_all_mounts(conn: sqlite3.Connection) -> int:
"""Vergisst alle Einbindungen -- beim Start des Webprozesses.
Eingebunden wird im Mount-Namensraum dieses Dienstes. Mit dem Dienst
verschwindet der Namensraum und mit ihm jede Einbindung; die Zeilen in der
Datenbank ueberleben aber. Ohne dieses Aufraeumen zeigte die Oberflaeche
nach jedem Neustart Freigaben an, die es nicht mehr gibt -- und ein Klick
darauf fuehrte in ein leeres Verzeichnis.
Bewusst **kein** ``umount``: es gibt nichts zu loesen. Der Aufruf ginge in
einen Namensraum, der nicht mehr existiert.
"""
cursor = conn.execute(
"UPDATE fileshare_mounts SET released_at=? WHERE released_at IS NULL", (_iso(_now()),)
)
return int(cursor.rowcount or 0)
def release_all_for_user(conn: sqlite3.Connection, user_id: int, session_id: str = "") -> int:
"""Loest die Einbindungen -- ohne Sitzung die des ganzen Kontos.
Beim Abmelden wird die Sitzung uebergeben: das andere Geraet desselben
Benutzers arbeitet weiter, seine Einbindung bleibt. Ohne Sitzung (etwa
beim Loeschen eines Kontos) faellt alles.
"""
count = 0 count = 0
for mount in active_mounts(conn, user_id): for mount in active_mounts(conn, user_id, session_id):
if session_id and not _gehoert_zur_sitzung(conn, mount.id, session_id):
continue
release_mount(conn, mount.id) release_mount(conn, mount.id)
count += 1 count += 1
return count return count
def _gehoert_zur_sitzung(conn: sqlite3.Connection, mount_id: int, session_id: str) -> bool:
"""Zeilen ohne Sitzung werden beim Abmelden nicht mitgerissen."""
zeile = Database.one(
conn, "SELECT session_id FROM fileshare_mounts WHERE id=?", (mount_id,)
)
return zeile is not None and str(zeile["session_id"] or "") == session_id
def sweep_expired(database: Any) -> int: def sweep_expired(database: Any) -> int:
"""Loest abgelaufene Einbindungen -- laeuft im Hintergrundprozess. """Loest abgelaufene Einbindungen -- laeuft im Hintergrundprozess.
@@ -315,10 +619,30 @@ def sweep_expired(database: Any) -> int:
""" """
released = 0 released = 0
with database.session() as conn: with database.session() as conn:
# Eine Einbindung lebt genau so lange wie die Sitzung, aus der sie
# entstanden ist. Ohne Sitzungsangabe gilt allein die eigene Frist:
# "keine Sitzung eingetragen" ist etwas anderes als "Sitzung
# abgelaufen". Diese Verwechslung hat jede frische Einbindung binnen
# Minuten wieder geloest, weil die ID beim Anmelden noch nicht stand. Vorher hing sie an einer eigenen Frist von zwoelf
# Stunden: nach dem Abmelden -- oder nach der automatischen Abmeldung
# bei Leerlauf -- blieb die Freigabe mit den Zugangsdaten des Benutzers
# weiter eingebunden. Die eigene Frist bleibt als Obergrenze bestehen;
# eine Sitzung kann durch Aktivitaet beliebig lange leben, eine
# Einbindung soll trotzdem irgendwann neu entstehen muessen.
rows = Database.all( rows = Database.all(
conn, conn,
"SELECT id FROM fileshare_mounts WHERE released_at IS NULL AND expires_at <= ?", """
(_iso(_now()),), SELECT m.id FROM fileshare_mounts m
LEFT JOIN user_sessions s ON s.id = m.session_id
WHERE m.released_at IS NULL
AND (m.expires_at <= ?
OR (COALESCE(m.session_id, '') <> ''
AND (s.id IS NULL
OR s.revoked_at IS NOT NULL
OR s.idle_expires_at <= ?
OR s.absolute_expires_at <= ?)))
""",
(_iso(_now()), _iso(_now()), _iso(_now())),
) )
if not rows: if not rows:
return 0 return 0
@@ -342,14 +666,260 @@ def resolve_path(mount: Mount, relative: str) -> Path:
""" """
candidate = PurePosixPath((relative or "").strip().lstrip("/")) candidate = PurePosixPath((relative or "").strip().lstrip("/"))
if any(part == ".." for part in candidate.parts): if any(part == ".." for part in candidate.parts):
raise FileshareError("Ungueltiger Pfad.") raise FileshareError("Ungültiger Pfad.")
root = mount.path.resolve() root = mount.path.resolve()
target = (root / str(candidate)).resolve() target = (root / str(candidate)).resolve()
if target != root and root not in target.parents: if target != root and root not in target.parents:
raise FileshareError("Der Pfad liegt ausserhalb der Freigabe.") raise FileshareError("Der Pfad liegt außerhalb der Freigabe.")
return target return target
#: Wie eine unterschriebene Fassung im Aenderungsprotokoll steht. Die
#: Pruefsumme ist der Anker -- sie steht im selben Eintrag wie der Name, den
#: der Unterschreibende angegeben hat.
SIGNATUR_AKTION = "fileshare.form_signed"
def pruefsumme(pfad: Path) -> str:
"""SHA-256 der Datei, blockweise gelesen."""
kessel = hashlib.sha256()
with pfad.open("rb") as strom:
for block in iter(lambda: strom.read(1024 * 1024), b""):
kessel.update(block)
return kessel.hexdigest()
def signaturdetail_lesen(detail: str) -> dict[str, Any]:
"""Zerlegt die Protokollzeile einer Unterschrift.
Aufbau: ``<Freigabe>:<Pfad> sha256=<Summe> -- angegeben unterschrieben
von: <Namen>``. Gelesen wird sie an zwei Stellen -- beim Uebernehmen
alter Eintraege und wenn das Verzeichnis nichts weiss.
"""
# Der Trenner ist heute ein Zeilenumbruch, frueher ein Leerzeichen -- die
# Zerlegung muss beides vertragen, sonst sind alte Eintraege stumm.
ort, _, rest = detail.partition("sha256=")
ort = ort.strip()
label, _, pfad = ort.partition(":")
summe = rest.split(" ", 1)[0].strip() if rest else ""
return {
"sha256": summe,
"label": label.strip(),
"pfad": pfad.strip(),
"namen": _namen_aus_detail(detail),
}
def signatur_merken(
conn: sqlite3.Connection,
*,
sha256: str,
label: str,
pfad: str,
actor: str,
namen: Iterable[str],
audit_id: int | None = None,
) -> None:
"""Haelt eine Unterschrift im Verzeichnis fest.
Das Aenderungsprotokoll bleibt der Beleg -- mit Hash-Kette und, nach der
Archivierung, in einer Datei. Dieses Verzeichnis ist nur der schnelle Weg
dorthin und wird nie ausgelagert; eine Zeile misst ein paar hundert Byte.
"""
conn.execute(
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, actor, "
"names, audit_id) VALUES (?,?,?,?,?,?,?)",
(
sha256,
label,
pfad,
_iso(_now()),
actor,
", ".join(namen),
audit_id,
),
)
def signaturjournal() -> Path:
"""Wo das zweite Exemplar liegt: neben dem Archiv des Protokolls."""
return core().audit_archive_dir / "signaturen.jsonl"
def signatur_journalisieren(
*, sha256: str, label: str, pfad: str, actor: str, namen: Iterable[str]
) -> None:
"""Schreibt die Unterschrift zusaetzlich in eine Datei -- nur angehaengt.
Zwei Exemplare an zwei Orten: die Tabelle beantwortet die Frage schnell,
diese Datei ueberlebt auch eine zurueckgespielte aeltere Datenbank. Genau
das ist der Fall, den eine Sicherung erzeugt: die Datei auf der Freigabe
ist von gestern, die Datenbank von vorgestern -- ohne das Journal waere
die Unterschrift dazwischen verloren.
Ein Fehlschlag hier darf das Speichern nicht kippen: die Datei liegt
bereits auf der Freigabe, und Tabelle wie Aenderungsprotokoll haben den
Eintrag. Er wird protokolliert, nicht geworfen.
"""
eintrag = {
"ts": _iso(_now()),
"sha256": sha256,
"share": label,
"path": pfad,
"actor": actor,
"names": list(namen),
}
try:
ziel = signaturjournal()
ziel.parent.mkdir(parents=True, exist_ok=True)
neu = not ziel.exists()
with ziel.open("a", encoding="utf-8", newline=chr(10)) as strom:
strom.write(json.dumps(eintrag, ensure_ascii=False, sort_keys=True) + chr(10))
strom.flush()
# Ohne fsync steht die Zeile im Puffer des Kernels -- genau der
# Zustand, gegen den dieses Journal geschrieben wurde.
os.fsync(strom.fileno())
if neu:
try:
os.chmod(ziel, 0o640)
except OSError: # pragma: no cover - andere Dateisysteme
pass
except OSError as fehler: # pragma: no cover - Plattenfehler
logger.warning("Signaturjournal nicht geschrieben: %s", fehler)
def signaturen_aus_journal(conn: sqlite3.Connection) -> int:
"""Traegt fehlende Unterschriften aus dem Journal in die Tabelle ein.
Der Weg zurueck nach einer Wiederherstellung. Vorhandene bleiben, wie sie
sind -- verglichen wird ueber Pruefsumme und Ablageort.
"""
quelle = signaturjournal()
if not quelle.is_file():
return 0
uebernommen = 0
with quelle.open("r", encoding="utf-8") as strom:
for zeile in strom:
zeile = zeile.strip()
if not zeile:
continue
try:
eintrag = json.loads(zeile)
except ValueError:
continue
vorhanden = Database.value(
conn,
"SELECT 1 FROM document_signatures WHERE sha256=? AND path=? LIMIT 1",
(str(eintrag.get("sha256", "")), str(eintrag.get("path", ""))),
)
if vorhanden:
continue
conn.execute(
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, "
"actor, names, audit_id) VALUES (?,?,?,?,?,?,NULL)",
(
str(eintrag.get("sha256", "")),
str(eintrag.get("share", "")),
str(eintrag.get("path", "")),
str(eintrag.get("ts", "")),
str(eintrag.get("actor", "")),
", ".join(eintrag.get("names", []) or []),
),
)
uebernommen += 1
return uebernommen
def signaturstand(conn: sqlite3.Connection, mount: "Mount", relative: str) -> dict[str, Any]:
"""Ist diese Datei die, die unterschrieben wurde?
Drei moegliche Antworten, und die dritte ist die wichtigste:
* ``unveraendert`` -- es gibt einen Protokolleintrag mit genau dieser
Pruefsumme. Die Datei ist Byte fuer Byte die unterschriebene.
* ``veraendert`` -- es gibt einen Eintrag zu genau diesem Ablageort, aber
mit anderer Pruefsumme. Jemand hat nach der Unterschrift etwas an der
Datei geaendert. Das ist der Fall, wegen dem es diese Pruefung gibt.
* ``unbekannt`` -- kein Eintrag. Kein Verdacht: die meisten Dateien in
einer Freigabe sind nie durch TESM gegangen.
Nachgeschlagen wird im Signaturverzeichnis (``document_signatures``) und
nicht im Aenderungsprotokoll: das wird ab 20.000 Zeilen tageweise in
Dateien ausgelagert, und mit dem ausgelagerten Tag waere die Pruefsumme aus
der Datenbank verschwunden. Eine aeltere unterschriebene Fassung waere
still zu "unbekannt" geworden -- und eine **veraenderte** ebenso, weil ohne
Vergleichswert niemand widerspricht. Das Verzeichnis wird nie archiviert.
"""
ziel = resolve_path(mount, relative)
if not ziel.is_file():
return {"status": "unbekannt"}
hash_ = pruefsumme(ziel)
zeile = Database.one(
conn,
"SELECT signed_at, actor, names FROM document_signatures WHERE sha256=? "
"ORDER BY id DESC LIMIT 1",
(hash_,),
)
if zeile is not None:
return _stand("unveraendert", hash_, zeile)
# Nichts mit dieser Pruefsumme -- aber vielleicht etwas zu diesem Ort.
zeile = Database.one(
conn,
"SELECT signed_at, actor, names FROM document_signatures WHERE share_label=? "
"AND path=? ORDER BY id DESC LIMIT 1",
(mount.label, relative),
)
if zeile is not None:
return _stand("veraendert", hash_, zeile)
return {"status": "unbekannt", "sha256": hash_}
def _stand(status: str, hash_: str, zeile: Any) -> dict[str, Any]:
namen = [teil.strip() for teil in str(zeile["names"] or "").split(",") if teil.strip()]
return {
"status": status,
"sha256": hash_,
"zeit": str(zeile["signed_at"]),
"konto": str(zeile["actor"] or ""),
"namen": namen,
}
def _namen_aus_detail(detail: str) -> list[str]:
"""Die angegebenen Namen aus dem Protokolltext -- ohne sie zu deuten."""
marke = "angegeben unterschrieben von: "
if marke not in detail:
return []
return [teil.strip() for teil in detail.split(marke, 1)[1].split(",") if teil.strip()]
#: Mehr als das zaehlt niemand mehr mit dem Auge -- und auf einer
#: Netzwerkfreigabe kostet jeder weitere Eintrag Zeit.
ZAEHLGRENZE = 500
def _inhalt_zaehlen(verzeichnis: Path) -> int:
"""Wie viele Eintraege liegen im Ordner? Begrenzt gezaehlt.
Gezeigt wird die Zahl an der Stelle, an der bei einer Datei die Groesse
steht -- dort stand bisher ein Gedankenstrich. Bei mehr als
``ZAEHLGRENZE`` bricht das Zaehlen ab: die Oberflaeche schreibt dann
"500+", und niemand wartet auf eine Freigabe mit 40.000 Dateien.
"""
anzahl = 0
try:
with os.scandir(verzeichnis) as eintraege:
for _ in eintraege:
anzahl += 1
if anzahl >= ZAEHLGRENZE:
break
except OSError:
return -1
return anzahl
def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]: def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
path = resolve_path(mount, relative) path = resolve_path(mount, relative)
if not path.is_dir(): if not path.is_dir():
@@ -371,9 +941,15 @@ def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
), ),
} }
if entry.is_dir(): if entry.is_dir():
record["entries"] = _inhalt_zaehlen(entry)
directories.append(record) directories.append(record)
else: else:
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
record["preview_kind"] = preview_kind(entry.name)
# Getrennt, damit die Oberflaeche nur den Namen zum Bearbeiten
# gibt und die Endung danebenstellt.
record["stem"] = entry.stem
record["suffix"] = entry.suffix
files.append(record) files.append(record)
except OSError as exc: except OSError as exc:
raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc
@@ -396,7 +972,7 @@ def preview_mime(name: str) -> str:
suffix = Path(name).suffix.lower() suffix = Path(name).suffix.lower()
if suffix in PREVIEW_MIME: if suffix in PREVIEW_MIME:
return PREVIEW_MIME[suffix] return PREVIEW_MIME[suffix]
raise FileshareError("Fuer diesen Dateityp gibt es keine Vorschau -- bitte herunterladen.") raise FileshareError("Für diesen Dateityp gibt es keine Vorschau – bitte herunterladen.")
def download_mime(name: str) -> str: def download_mime(name: str) -> str:
@@ -408,16 +984,86 @@ def safe_filename(name: str) -> str:
"""Verhindert Pfadanteile und versteckte Dateien in Uploads/Neuanlagen.""" """Verhindert Pfadanteile und versteckte Dateien in Uploads/Neuanlagen."""
cleaned = os.path.basename((name or "").strip().replace("\\", "/")) cleaned = os.path.basename((name or "").strip().replace("\\", "/"))
if not cleaned or cleaned in {".", ".."} or cleaned.startswith("."): if not cleaned or cleaned in {".", ".."} or cleaned.startswith("."):
raise FileshareError("Ungueltiger Dateiname.") raise FileshareError("Ungültiger Dateiname.")
if len(cleaned) > 200: if len(cleaned) > 200:
raise FileshareError("Der Dateiname ist zu lang.") raise FileshareError("Der Dateiname ist zu lang.")
return cleaned return cleaned
def all_directories(mount: Mount, *, tiefe: int = 4, grenze: int = 400) -> list[str]:
"""Die Ordner der Freigabe, relativ und alphabetisch -- fuer eine Auswahlliste.
Begrenzt in Tiefe und Anzahl: eine Netzwerkfreigabe kann zehntausende
Verzeichnisse haben, und eine Liste, die den Browser lahmlegt, hilft
niemandem. Wer tiefer speichern will, tippt den Pfad -- das Feld nimmt
beides.
"""
wurzel = mount.path.resolve()
gefunden: list[str] = []
def hinein(verzeichnis: Path, ebene: int) -> None:
if ebene > tiefe or len(gefunden) >= grenze:
return
try:
eintraege = sorted(verzeichnis.iterdir(), key=lambda e: e.name.lower())
except OSError:
return
for eintrag in eintraege:
if len(gefunden) >= grenze:
return
if not eintrag.is_dir() or eintrag.name.startswith("."):
continue
gefunden.append(eintrag.relative_to(wurzel).as_posix())
hinein(eintrag, ebene + 1)
hinein(wurzel, 1)
return gefunden
def hat_formularfelder(daten: bytes) -> bool:
"""Steckt in diesen PDF-Bytes noch ein ausfuellbares Formular?
Gesucht wird nach Formularfeldern (``/Subtype /Widget``) -- auch in
gepackten Objektstroemen, denn moderne Schreiber legen fast alles dort ab.
Ein flach gerechnetes Formular hat keine mehr.
Bewusst ohne Zusatzpaket: die Frage ist einfach genug, und eine
PDF-Bibliothek auf jedem Zielsystem waere ein hoher Preis dafuer.
"""
text = bytearray(daten)
for treffer in re.finditer(rb"stream\r?\n", daten):
start = treffer.end()
ende = daten.find(b"endstream", start)
if ende == -1:
continue
try:
text += b"\n" + zlib.decompress(daten[start:ende])
except zlib.error:
continue # kein zlib-Strom -- Bilder, Schriften, alles moegliche
return re.search(rb"/Subtype\s*/Widget", bytes(text)) is not None
def free_name(parent: Path, stem: str, zusatz: str, suffix: str) -> Path:
"""Ein freier Name neben dem Original: ``vorlage-ausgefuellt.pdf``.
Nichts ueberschreiben: auf einer Freigabe liegt meist die leere Vorlage,
und zwei Leute fuellen sie nacheinander aus. Ab dem zweiten Mal wird
durchnummeriert.
"""
kandidat = parent / f"{stem}{zusatz}{suffix}"
zaehler = 2
while kandidat.exists():
kandidat = parent / f"{stem}{zusatz}-{zaehler}{suffix}"
zaehler += 1
if zaehler > 200:
raise FileshareError("Zu viele Dateien dieses Namens – bitte aufräumen.")
return kandidat
def delete_entry(mount: Mount, relative: str) -> str: def delete_entry(mount: Mount, relative: str) -> str:
path = resolve_path(mount, relative) path = resolve_path(mount, relative)
if path == mount.path.resolve(): if path == mount.path.resolve():
raise FileshareError("Das Wurzelverzeichnis der Freigabe kann nicht geloescht werden.") raise FileshareError("Das Wurzelverzeichnis der Freigabe kann nicht gelöscht werden.")
if path.is_dir(): if path.is_dir():
shutil.rmtree(path) shutil.rmtree(path)
return "Verzeichnis" return "Verzeichnis"
@@ -435,8 +1081,26 @@ def make_directory(mount: Mount, relative: str, name: str) -> Path:
def rename_entry(mount: Mount, relative: str, new_name: str) -> Path: def rename_entry(mount: Mount, relative: str, new_name: str) -> Path:
"""Benennt um und behaelt die Endung der Datei.
Eingegeben wird nur der Name ohne Endung. Eine Datei, die danach auf
``.pdf.txt`` oder auf gar nichts endet, waere auf einer Windows-Freigabe
praktisch kaputt -- sie oeffnet sich nicht mehr mit dem richtigen
Programm. Verzeichnisse haben keine Endung; dort bleibt der Name, wie er
eingegeben wurde.
Wer die Endung trotzdem mittippt, bekommt sie nicht doppelt.
"""
path = resolve_path(mount, relative) path = resolve_path(mount, relative)
target = path.parent / safe_filename(new_name) endung = path.suffix if path.is_file() else ""
gewuenscht = (new_name or "").strip()
if endung and gewuenscht.lower().endswith(endung.lower()):
gewuenscht = gewuenscht[: -len(endung)]
if not gewuenscht:
raise FileshareError("Der Name darf nicht leer sein.")
target = path.parent / safe_filename(gewuenscht + endung)
if target == path:
return path
if target.exists(): if target.exists():
raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.") raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
path.rename(target) path.rename(target)
+33 -24
View File
@@ -54,13 +54,13 @@ def is_ssh_managed(category: str | None) -> bool:
#: war "Windows-Client" vorher -- waehlbar, aber ohne jede Wirkung. #: war "Windows-Client" vorher -- waehlbar, aber ohne jede Wirkung.
CATEGORY_EFFECTS: dict[str, str] = { CATEGORY_EFFECTS: dict[str, str] = {
"switch": "Anmeldung an Switchen: PoE schalten, Konsole, Statusabfragen.", "switch": "Anmeldung an Switchen: PoE schalten, Konsole, Statusabfragen.",
"linux": "Wartung ueber SSH: Paketaktualisierung (apt) und Neustart. Konsole.", "linux": "Wartung über SSH: Paketaktualisierung (apt) und Neustart. Konsole.",
"windows": ( "windows": (
"Neustart ueber RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schluessel. Das " "Neustart über RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schlüssel. Das "
"Geraet braucht offene Datei- und Druckerfreigabe (Port 445); das Konto braucht " "Gerät braucht offene Datei- und Druckerfreigabe (Port 445); das Konto braucht "
"das Recht, aus der Ferne herunterzufahren. Paketaktualisierung gibt es hier nicht." "das Recht, aus der Ferne herunterzufahren. Paketaktualisierung gibt es hier nicht."
), ),
"router": "Nur Dokumentation -- diese Kategorie loest keine Aktion aus.", "router": "Nur Dokumentation – diese Kategorie löst keine Aktion aus.",
} }
DEVICE_CATEGORIES: tuple[tuple[str, str], ...] = ( DEVICE_CATEGORIES: tuple[tuple[str, str], ...] = (
@@ -95,7 +95,7 @@ def normalize_mac(value: str) -> str:
cleaned = re.sub(r"[^0-9a-fA-F]", "", value or "") cleaned = re.sub(r"[^0-9a-fA-F]", "", value or "")
if len(cleaned) != 12: if len(cleaned) != 12:
raise ValidationError( raise ValidationError(
"Die MAC-Adresse muss zwoelf hexadezimale Zeichen enthalten " "Die MAC-Adresse muss zwölf hexadezimale Zeichen enthalten "
"(z. B. aa:bb:cc:dd:ee:ff)." "(z. B. aa:bb:cc:dd:ee:ff)."
) )
return ":".join(cleaned[index : index + 2] for index in range(0, 12, 2)).lower() return ":".join(cleaned[index : index + 2] for index in range(0, 12, 2)).lower()
@@ -106,7 +106,7 @@ def validate_ip(value: str, *, field: str = "IP-Adresse") -> str:
try: try:
ipaddress.ip_address(text) ipaddress.ip_address(text)
except ValueError as exc: except ValueError as exc:
raise ValidationError(f"{field}: {text!r} ist keine gueltige IP-Adresse.") from exc raise ValidationError(f"{field}: {text!r} ist keine gültige IP-Adresse.") from exc
return text return text
@@ -116,7 +116,7 @@ def validate_port_name(value: str) -> str:
return "" return ""
if not PORT_RE.match(text): if not PORT_RE.match(text):
raise ValidationError( raise ValidationError(
"Die Portbezeichnung enthaelt unerlaubte Zeichen (erlaubt: Buchstaben, " "Die Portbezeichnung enthält unerlaubte Zeichen (erlaubt: Buchstaben, "
"Ziffern, / . : -)." "Ziffern, / . : -)."
) )
return text return text
@@ -125,7 +125,7 @@ def validate_port_name(value: str) -> str:
def validate_hostname(value: str) -> str: def validate_hostname(value: str) -> str:
text = (value or "").strip() text = (value or "").strip()
if not HOSTNAME_RE.match(text): if not HOSTNAME_RE.match(text):
raise ValidationError("Der Hostname enthaelt unerlaubte Zeichen oder ist leer.") raise ValidationError("Der Hostname enthält unerlaubte Zeichen oder ist leer.")
return text return text
@@ -208,7 +208,7 @@ def create_credential(
if existing["deleted_at"]: if existing["deleted_at"]:
raise ValidationError( raise ValidationError(
f"Zugangsdaten namens {clean_name!r} liegen im Papierkorb. Stellen Sie sie " f"Zugangsdaten namens {clean_name!r} liegen im Papierkorb. Stellen Sie sie "
"wieder her oder loeschen Sie sie endgueltig." "wieder her oder löschen Sie sie endgültig."
) )
raise ValidationError(f"Zugangsdaten namens {clean_name!r} existieren bereits.") raise ValidationError(f"Zugangsdaten namens {clean_name!r} existieren bereits.")
@@ -258,11 +258,11 @@ def update_credential(
if clean_name != str(row["name"]): if clean_name != str(row["name"]):
changes.append(f"Name: {row['name']} -> {clean_name}") changes.append(f"Name: {row['name']} -> {clean_name}")
if (username or "").strip() != str(row["username"] or ""): if (username or "").strip() != str(row["username"] or ""):
changes.append("Benutzer geaendert") changes.append("Benutzer geändert")
if category != str(row["category"]): if category != str(row["category"]):
changes.append(f"Kategorie: {row['category']} -> {category}") changes.append(f"Kategorie: {row['category']} -> {category}")
if (description or "").strip() != str(row["description"] or ""): if (description or "").strip() != str(row["description"] or ""):
changes.append("Beschreibung geaendert") changes.append("Beschreibung geändert")
conn.execute( conn.execute(
"UPDATE credentials SET name=?, username=?, category=?, description=?, updated_at=?, " "UPDATE credentials SET name=?, username=?, category=?, description=?, updated_at=?, "
@@ -274,7 +274,7 @@ def update_credential(
"UPDATE credentials SET secret_encrypted=? WHERE id=?", "UPDATE credentials SET secret_encrypted=? WHERE id=?",
(keystore.encrypt(secret, aad=aad_for_credential(credential_id)), credential_id), (keystore.encrypt(secret, aad=aad_for_credential(credential_id)), credential_id),
) )
changes.append("Passwort geaendert") changes.append("Passwort geändert")
return changes return changes
@@ -296,8 +296,8 @@ def credential_secret(
secret = keystore.decrypt(encrypted, aad=aad_for_credential(int(credential_id))) secret = keystore.decrypt(encrypted, aad=aad_for_credential(int(credential_id)))
except DecryptionError as exc: except DecryptionError as exc:
raise ValidationError( raise ValidationError(
"Das gespeicherte Passwort konnte nicht entschluesselt werden. Wurde der " "Das gespeicherte Passwort konnte nicht entschlüsselt werden. Wurde der "
"Datenschluessel ausgetauscht? Bitte die Zugangsdaten neu eingeben." "Datenschlüssel ausgetauscht? Bitte die Zugangsdaten neu eingeben."
) from exc ) from exc
return str(row["username"] or ""), secret return str(row["username"] or ""), secret
@@ -315,7 +315,7 @@ def delete_credential(conn: sqlite3.Connection, credential_id: int) -> None:
if int(row.get("switches") or 0) or int(row.get("devices") or 0): if int(row.get("switches") or 0) or int(row.get("devices") or 0):
raise ValidationError( raise ValidationError(
f"Diese Zugangsdaten werden noch von {row.get('switches')} Switch(en) und " f"Diese Zugangsdaten werden noch von {row.get('switches')} Switch(en) und "
f"{row.get('devices')} Geraet(en) verwendet." f"{row.get('devices')} Gerät(en) verwendet."
) )
conn.execute("UPDATE credentials SET deleted_at=? WHERE id=?", (_now(), credential_id)) conn.execute("UPDATE credentials SET deleted_at=? WHERE id=?", (_now(), credential_id))
@@ -509,7 +509,7 @@ def delete_switch(conn: sqlite3.Connection, switch_id: int) -> None:
) )
if int(count or 0): if int(count or 0):
raise ValidationError( raise ValidationError(
f"Diesem Switch sind noch {count} Geraet(e) zugeordnet. Bitte zuerst umhaengen." 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)) conn.execute("UPDATE switches SET deleted_at=? WHERE id=?", (_now(), switch_id))
@@ -542,7 +542,10 @@ def list_devices(
WHERE k.host = d.ip AND k.port = d.ssh_port) AS host_key_known, 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, 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.last_ok_at, st.last_change_at, st.last_restart_at, st.restart_count,
st.fail_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 FROM devices d
LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL 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 credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL
@@ -590,13 +593,13 @@ def create_device(
) )
if existing is not None: if existing is not None:
if existing["deleted_at"]: if existing["deleted_at"]:
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} liegt im Papierkorb.") raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} liegt im Papierkorb.")
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} existiert bereits.") raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} existiert bereits.")
if switch_id and not clean_port: if switch_id and not clean_port:
raise ValidationError( raise ValidationError(
"Zu einem zugeordneten Switch gehoert eine Portbezeichnung -- sonst ist kein " "Zu einem zugeordneten Switch gehört eine Portbezeichnung – sonst ist kein "
"PoE-Neustart moeglich." "PoE-Neustart möglich."
) )
cursor = conn.execute( cursor = conn.execute(
@@ -651,7 +654,7 @@ def update_device(
conn, "SELECT * FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,) conn, "SELECT * FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,)
) )
if row is None: if row is None:
raise ValidationError("Dieses Geraet existiert nicht (mehr).") raise ValidationError("Dieses Gerät existiert nicht (mehr).")
clean_name = (name or "").strip() clean_name = (name or "").strip()
if not clean_name: if not clean_name:
raise ValidationError("Der Name darf nicht leer sein.") raise ValidationError("Der Name darf nicht leer sein.")
@@ -664,9 +667,9 @@ def update_device(
(clean_mac, device_id), (clean_mac, device_id),
) )
if clash is not None: if clash is not None:
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} existiert bereits.") raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} existiert bereits.")
if switch_id and not clean_port: if switch_id and not clean_port:
raise ValidationError("Zu einem zugeordneten Switch gehoert eine Portbezeichnung.") raise ValidationError("Zu einem zugeordneten Switch gehört eine Portbezeichnung.")
changes: list[str] = [] changes: list[str] = []
for label, before, after in ( for label, before, after in (
@@ -677,6 +680,12 @@ def update_device(
("Port", str(row["port"] or ""), clean_port), ("Port", str(row["port"] or ""), clean_port),
("Aktiv", str(bool(row["is_active"])), str(bool(is_active))), ("Aktiv", str(bool(row["is_active"])), str(bool(is_active))),
("Automatischer Neustart", str(bool(row["auto_restart"])), str(bool(auto_restart))), ("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: if before != after:
changes.append(f"{label}: {before or '-'} -> {after or '-'}") changes.append(f"{label}: {before or '-'} -> {after or '-'}")
+12 -12
View File
@@ -46,7 +46,7 @@ STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
(9, "lpr-servers", "ipv4-address", "LPR-Druckserver"), (9, "lpr-servers", "ipv4-address", "LPR-Druckserver"),
(10, "impress-servers", "ipv4-address", "Impress-Server"), (10, "impress-servers", "ipv4-address", "Impress-Server"),
(11, "resource-location-servers", "ipv4-address", "Resource Location Server"), (11, "resource-location-servers", "ipv4-address", "Resource Location Server"),
(13, "boot-size", "uint16", "Boot-Image-Groesse (Bloecke)"), (13, "boot-size", "uint16", "Boot-Image-Größe (Blöcke)"),
(14, "merit-dump", "string", "Crash-Dump-Datei"), (14, "merit-dump", "string", "Crash-Dump-Datei"),
(16, "swap-server", "ipv4-address", "Swap-Server"), (16, "swap-server", "ipv4-address", "Swap-Server"),
(17, "root-path", "string", "Root-Pfad (NFS)"), (17, "root-path", "string", "Root-Pfad (NFS)"),
@@ -54,8 +54,8 @@ STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
(19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"), (19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"),
(20, "non-local-source-routing", "boolean", "Non-Local Source Routing"), (20, "non-local-source-routing", "boolean", "Non-Local Source Routing"),
(21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"), (21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"),
(22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgroesse"), (22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgröße"),
(23, "default-ip-ttl", "uint8", "Standard-TTL fuer IP"), (23, "default-ip-ttl", "uint8", "Standard-TTL für IP"),
(24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"), (24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"),
(25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"), (25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"),
(26, "interface-mtu", "uint16", "MTU des Interfaces"), (26, "interface-mtu", "uint16", "MTU des Interfaces"),
@@ -69,7 +69,7 @@ STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
(34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"), (34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"),
(35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"), (35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"),
(36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"), (36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"),
(37, "default-tcp-ttl", "uint8", "Standard-TTL fuer TCP"), (37, "default-tcp-ttl", "uint8", "Standard-TTL für TCP"),
(38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"), (38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"),
(40, "nis-domain", "string", "NIS-Domain"), (40, "nis-domain", "string", "NIS-Domain"),
(41, "nis-servers", "ipv4-address", "NIS-Server"), (41, "nis-servers", "ipv4-address", "NIS-Server"),
@@ -190,12 +190,12 @@ def validate_subnet(
) -> str: ) -> str:
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung.""" """Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung."""
if not interface: if not interface:
return "Es ist kein Interface ausgewaehlt." return "Es ist kein Interface ausgewählt."
try: try:
start = ipaddress.ip_address(range_start) start = ipaddress.ip_address(range_start)
end = ipaddress.ip_address(range_end) end = ipaddress.ip_address(range_end)
except ValueError: except ValueError:
return "Start- und Endadresse muessen gueltige IPv4-Adressen sein." return "Start- und Endadresse müssen gültige IPv4-Adressen sein."
if int(end) < int(start): if int(end) < int(start):
return "Die Endadresse liegt vor der Startadresse." return "Die Endadresse liegt vor der Startadresse."
if matching_network(networks, interface, range_start, range_end) is None: if matching_network(networks, interface, range_start, range_end) is None:
@@ -405,7 +405,7 @@ def render_config(
# fuehren, dass gar keine Konfiguration mehr geschrieben wird. # fuehren, dass gar keine Konfiguration mehr geschrieben wird.
warnings.append( warnings.append(
f"Subnetz {subnet['range_start']}-{subnet['range_end']} auf {interface} " f"Subnetz {subnet['range_start']}-{subnet['range_end']} auf {interface} "
"passt in kein aktuelles Netz und wurde uebersprungen." "passt in kein aktuelles Netz und wurde übersprungen."
) )
continue continue
interfaces.add(interface) interfaces.add(interface)
@@ -444,7 +444,7 @@ def render_config(
) )
if not subnet_entries: if not subnet_entries:
warnings.append("Es ist kein gueltiges Subnetz konfiguriert -- Kea wuerde nicht starten.") warnings.append("Es ist kein gültiges Subnetz konfiguriert | Kea würde nicht starten.")
global_option_data = option_data(global_values) global_option_data = option_data(global_values)
domain = str(settings.get("dhcp_domain") or "").strip() domain = str(settings.get("dhcp_domain") or "").strip()
@@ -560,14 +560,14 @@ class Lease:
#: benutzte, und ein Geraet, das noch auf einer alten Adresse haengt, faellt #: benutzte, und ein Geraet, das noch auf einer alten Adresse haengt, faellt
#: ueberhaupt nicht auf. #: ueberhaupt nicht auf.
RESERVATION_STATES: dict[str, tuple[str, str]] = { RESERVATION_STATES: dict[str, tuple[str, str]] = {
"aktiv": ("online", "Das Geraet benutzt diese Adresse gerade."), "aktiv": ("online", "Das Gerät benutzt diese Adresse gerade."),
"abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen -- Geraet derzeit nicht da."), "abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen | Gerät derzeit nicht da."),
"andere_adresse": ( "andere_adresse": (
"warning", "warning",
"Das Geraet haelt noch eine Lease auf einer anderen Adresse. Es uebernimmt die " "Das Gerät hält noch eine Lease auf einer anderen Adresse. Es übernimmt die "
"Reservierung erst nach Ablauf oder Neuanforderung.", "Reservierung erst nach Ablauf oder Neuanforderung.",
), ),
"unbenutzt": ("", "Fuer dieses Geraet liegt keine Lease vor."), "unbenutzt": ("", "Für dieses Gerät liegt keine Lease vor."),
} }
+566 -36
View File
@@ -16,10 +16,20 @@ eingelesen. Ein eigener Schreibpfad fuer Altdaten waere eine zweite Stelle, an
der Fremdeingaben in die Datenbank gelangen -- und die zweite Stelle ist die, der Fremdeingaben in die Datenbank gelangen -- und die zweite Stelle ist die,
die niemand pflegt. die niemand pflegt.
Uebersetzt werden Clients, Switche, Zugangsdaten und die DHCP-Konfiguration. Uebersetzt werden Clients, Switche, Zugangsdaten, die DHCP-Konfiguration, die
Was in der Datei sonst noch steht, wird nicht stillschweigend verworfen, Freigabe-Zuordnungen und die Anbindung an das Verzeichnis (Server, Transport,
sondern im Bericht benannt -- damit sichtbar bleibt, was von Hand nachzuziehen Basis-DN, Anmeldeattribut, Bind-Konto samt Passwort, und die Zuordnung
ist. "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 from __future__ import annotations
@@ -27,6 +37,7 @@ from __future__ import annotations
import base64 import base64
import json import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import Any from typing import Any
#: Runden der alten Schluesselableitung. Steht so im Vorgaenger und darf sich #: Runden der alten Schluesselableitung. Steht so im Vorgaenger und darf sich
@@ -35,25 +46,24 @@ LEGACY_KDF_ITERATIONS = 390000
LEGACY_APP_TAG = "tesm" LEGACY_APP_TAG = "tesm"
#: Diese Abschnitte werden uebersetzt. #: Diese Abschnitte werden uebersetzt.
SUPPORTED = ("credentials", "switches", "devices", "dhcp") SUPPORTED = ("credentials", "switches", "devices", "dhcp", "ldap")
#: Was der alte Export sonst enthaelt. Wird gemeldet, nicht uebersetzt. #: Was der alte Export sonst enthaelt. Wird gemeldet, nicht uebersetzt.
KNOWN_UNSUPPORTED: dict[str, str] = { KNOWN_UNSUPPORTED: dict[str, str] = {
"users": ( "users": (
"Lokale Konten: der alte Passwort-Hash ist bcrypt, diese Anwendung " "Lokale Konten: der alte Passwort-Hash ist bcrypt, diese Anwendung "
"verwendet Argon2id. Ein Hash laesst sich nicht umrechnen -- das ist sein " "verwendet Argon2id. Ein Hash lässt sich nicht umrechnen – das ist sein "
"Zweck. Konten neu anlegen." "Zweck. Konten neu anlegen."
), ),
"groups": "Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.", "groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.",
"ldap": ( # Der Abschnitt "ldap" wird uebersetzt (siehe _verzeichnis) -- was dabei
"Verzeichnisdienst: Einstellungen, Gruppen- und Freigabe-Zuordnungen von " # nicht mitkommt, benennt die Uebersetzung einzeln und genauer, als eine
"Hand uebertragen." # pauschale Zeile hier es koennte.
), "nginx": "Webserver: Domain, Ports und Zertifikat von Hand übertragen.",
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand uebertragen.",
"logs": "Auditlog: bleibt bewusst beim alten System.", "logs": "Auditlog: bleibt bewusst beim alten System.",
"known_hosts": ( "known_hosts": (
"SSH-Host-Schluessel: werden hier je Geraet mit Fingerprint gefuehrt und " "SSH-Host-Schlüssel: werden hier je Gerät mit Fingerprint geführt und "
"ueber die Seite 'Host-Schluessel' freigegeben." "über die Seite 'Host-Schlüssel' freigegeben."
), ),
} }
@@ -84,8 +94,13 @@ class Report:
if text not in self.notes: if text not in self.notes:
self.notes.append(text) self.notes.append(text)
def as_text(self) -> str: def as_text(self, titel: str = "Uebersetzt") -> str:
zeilen = ["Uebersetzt:"] """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 [ zeilen += [f" {key}: {value}" for key, value in sorted(self.counts.items())] or [
" nichts" " nichts"
] ]
@@ -113,10 +128,10 @@ def open_legacy(raw: bytes | str, passphrase: str) -> dict[str, Any]:
except ValueError as exc: except ValueError as exc:
raise LegacyTransferError("Die Datei ist kein JSON.") from exc raise LegacyTransferError("Die Datei ist kein JSON.") from exc
if not isinstance(envelope, dict): if not isinstance(envelope, dict):
raise LegacyTransferError("Die Datei enthaelt kein Objekt.") raise LegacyTransferError("Die Datei enthält kein Objekt.")
if str(envelope.get("app") or "") != LEGACY_APP_TAG: if str(envelope.get("app") or "") != LEGACY_APP_TAG:
raise LegacyTransferError( raise LegacyTransferError(
f"Diese Datei gehoert zu {envelope.get('app') or 'einer unbekannten Anwendung'!r}, " f"Diese Datei gehört zu {envelope.get('app') or 'einer unbekannten Anwendung'!r}, "
"nicht zu TESM." "nicht zu TESM."
) )
for schluessel in ("salt", "payload"): for schluessel in ("salt", "payload"):
@@ -136,15 +151,15 @@ def open_legacy(raw: bytes | str, passphrase: str) -> dict[str, Any]:
klartext = Fernet(key).decrypt(str(envelope["payload"]).encode("utf-8")) klartext = Fernet(key).decrypt(str(envelope["payload"]).encode("utf-8"))
except (InvalidToken, ValueError) as exc: except (InvalidToken, ValueError) as exc:
raise LegacyTransferError( raise LegacyTransferError(
"Entschluesselung fehlgeschlagen -- falsche Passphrase oder beschaedigte Datei." "Entschlüsselung fehlgeschlagen – falsche Passphrase oder beschädigte Datei."
) from exc ) from exc
try: try:
payload = json.loads(klartext.decode("utf-8")) payload = json.loads(klartext.decode("utf-8"))
except ValueError as exc: except ValueError as exc:
raise LegacyTransferError("Die entschluesselte Nutzlast ist kein JSON.") from exc raise LegacyTransferError("Die entschlüsselte Nutzlast ist kein JSON.") from exc
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise LegacyTransferError("Die Nutzlast enthaelt kein Objekt.") raise LegacyTransferError("Die Nutzlast enthält kein Objekt.")
return payload return payload
@@ -174,14 +189,14 @@ def _credentials(items: Any, report: Report) -> list[dict[str, Any]]:
for entry in _liste(items): for entry in _liste(items):
name = str(entry.get("name") or "").strip() name = str(entry.get("name") or "").strip()
if not name: if not name:
report.note("Zugangsdaten ohne Namen uebersprungen.") report.note("Zugangsdaten ohne Namen übersprungen.")
continue continue
umgesetzt: dict[str, Any] = { umgesetzt: dict[str, Any] = {
"name": name, "name": name,
"username": str(entry.get("username") or ""), "username": str(entry.get("username") or ""),
"category": _category(entry.get("category"), report, f"Zugangsdaten {name!r}"), "category": _category(entry.get("category"), report, f"Zugangsdaten {name!r}"),
# Der Vorgaenger kennt kein Beschreibungsfeld. # Der Vorgaenger kennt kein Beschreibungsfeld.
"description": "aus altem TESM uebernommen", "description": "aus altem TESM übernommen",
} }
# Der alte Export entschluesselt das Passwort und legt es im Klartext in # Der alte Export entschluesselt das Passwort und legt es im Klartext in
# die -- als Ganzes passphrasenverschluesselte -- Datei. Beim Import # die -- als Ganzes passphrasenverschluesselte -- Datei. Beim Import
@@ -193,8 +208,8 @@ def _credentials(items: Any, report: Report) -> list[dict[str, Any]]:
ergebnis.append(umgesetzt) ergebnis.append(umgesetzt)
if ohne_passwort: if ohne_passwort:
report.note( report.note(
f"{ohne_passwort} Zugangsdaten ohne Passwort in der Datei -- " f"{ohne_passwort} Zugangsdaten ohne Passwort in der Datei – "
"sie muessen nachgetragen werden." "sie müssen nachgetragen werden."
) )
report.took("credentials", len(ergebnis)) report.took("credentials", len(ergebnis))
return ergebnis return ergebnis
@@ -205,7 +220,7 @@ def _switches(items: Any, report: Report) -> list[dict[str, Any]]:
for entry in _liste(items): for entry in _liste(items):
hostname = str(entry.get("hostname") or "").strip() hostname = str(entry.get("hostname") or "").strip()
if not hostname: if not hostname:
report.note("Switch ohne Hostnamen uebersprungen.") report.note("Switch ohne Hostnamen übersprungen.")
continue continue
ergebnis.append( ergebnis.append(
{ {
@@ -234,7 +249,7 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l
for entry in _liste(items): for entry in _liste(items):
mac = str(entry.get("mac") or "").strip() mac = str(entry.get("mac") or "").strip()
if not mac: if not mac:
report.note("Client ohne MAC-Adresse uebersprungen.") report.note("Client ohne MAC-Adresse übersprungen.")
continue continue
zugang = entry.get("credential_name") zugang = entry.get("credential_name")
kategorie = kategorie_je_zugang.get(str(zugang or ""), "") kategorie = kategorie_je_zugang.get(str(zugang or ""), "")
@@ -260,12 +275,174 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l
if abgeleitet: if abgeleitet:
report.note( report.note(
f"Kategorie von {abgeleitet} Client(s) aus den zugeordneten Zugangsdaten " f"Kategorie von {abgeleitet} Client(s) aus den zugeordneten Zugangsdaten "
"abgeleitet -- der Vorgaenger kennt kein solches Feld. Bitte pruefen." "abgeleitet – der Vorgänger kennt kein solches Feld. Bitte prüfen."
) )
report.took("devices", len(ergebnis)) report.took("devices", len(ergebnis))
return 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]]: def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
"""DHCP-Teil uebersetzen. Rueckgabe: ``(dhcp, settings)``.""" """DHCP-Teil uebersetzen. Rueckgabe: ``(dhcp, settings)``."""
if not isinstance(data, dict): if not isinstance(data, dict):
@@ -292,14 +469,14 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
for entry in _liste(data.get("reservations")): for entry in _liste(data.get("reservations")):
mac = str(entry.get("mac") or "").strip() mac = str(entry.get("mac") or "").strip()
if not mac: if not mac:
report.note("Manuelle Reservierung ohne MAC-Adresse uebersprungen.") report.note("Manuelle Reservierung ohne MAC-Adresse übersprungen.")
continue continue
reservations.append( reservations.append(
{ {
"mac": mac, "mac": mac,
"ip": str(entry.get("ip") or ""), "ip": str(entry.get("ip") or ""),
"hostname": str(entry.get("name") or ""), "hostname": str(entry.get("name") or ""),
"comment": "aus altem TESM uebernommen", "comment": "aus altem TESM übernommen",
} }
) )
@@ -316,7 +493,7 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
"is_standard": int(bool(definition.get("is_standard"))), "is_standard": int(bool(definition.get("is_standard"))),
} }
if not gemeinsam["code"] or not gemeinsam["name"]: if not gemeinsam["code"] or not gemeinsam["name"]:
report.note("DHCP-Option ohne Code oder Namen uebersprungen.") report.note("DHCP-Option ohne Code oder Namen übersprungen.")
continue continue
werte = _liste(definition.get("values")) werte = _liste(definition.get("values"))
if not werte: if not werte:
@@ -333,7 +510,7 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
) )
if ohne_wert: if ohne_wert:
report.note( report.note(
f"{ohne_wert} eigene DHCP-Option(en) ohne Wert uebersprungen -- ohne Wert " f"{ohne_wert} eigene DHCP-Option(en) ohne Wert übersprungen – ohne Wert "
"gibt es nichts zu senden. Bei Bedarf von Hand anlegen." "gibt es nichts zu senden. Bei Bedarf von Hand anlegen."
) )
@@ -376,6 +553,14 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
if devices: if devices:
payload["devices"] = 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) dhcp, settings = _dhcp(legacy.get("dhcp"), report)
if dhcp: if dhcp:
payload["dhcp"] = dhcp payload["dhcp"] = dhcp
@@ -399,7 +584,7 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
report.note( report.note(
"Diese Zugangsdaten sind zugeordnet, fehlen aber in der Datei und bleiben " "Diese Zugangsdaten sind zugeordnet, fehlen aber in der Datei und bleiben "
f"deshalb leer: {', '.join(fehlend)}. Beim Export im alten TESM die " f"deshalb leer: {', '.join(fehlend)}. Beim Export im alten TESM die "
"Kategorie 'Zugangsdaten' mitwaehlen." "Kategorie 'Zugangsdaten' mitwählen."
) )
for schluessel, grund in KNOWN_UNSUPPORTED.items(): for schluessel, grund in KNOWN_UNSUPPORTED.items():
@@ -407,11 +592,356 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
report.note(grund) report.note(grund)
unbekannt = sorted(set(legacy) - set(SUPPORTED) - set(KNOWN_UNSUPPORTED)) unbekannt = sorted(set(legacy) - set(SUPPORTED) - set(KNOWN_UNSUPPORTED))
if unbekannt: if unbekannt:
report.note(f"Unbekannte Abschnitte uebersprungen: {', '.join(unbekannt)}.") report.note(f"Unbekannte Abschnitte übersprungen: {', '.join(unbekannt)}.")
if not payload: if not payload:
raise LegacyTransferError( raise LegacyTransferError(
"Die Datei enthaelt keine Clients, Switche, Zugangsdaten oder DHCP-Daten. " "Die Datei enthält keine Clients, Switche, Zugangsdaten, DHCP-Daten oder "
"Beim Export im alten TESM diese Kategorien mitwaehlen." "Freigaben. Beim Export im alten TESM diese Kategorien mitwählen."
) )
return payload, report 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"
+47 -34
View File
@@ -110,47 +110,47 @@ def is_noise(line: str) -> bool:
HINTS: tuple[tuple[str, str], ...] = ( HINTS: tuple[tuple[str, str], ...] = (
( (
"incorrect password attempt", "incorrect password attempt",
"Das Passwort aus den Zugangsdaten wurde von 'sudo' abgewiesen. Fuer die " "Das Passwort aus den Zugangsdaten wurde von 'sudo' abgewiesen. Für die "
"SSH-Anmeldung hat es gereicht -- 'sudo' auf diesem Geraet verlangt also ein " "SSH-Anmeldung hat es gereicht – 'sudo' auf diesem Gerät verlangt also ein "
"anderes Passwort, oder das Konto steht nicht in der sudoers-Datei.", "anderes Passwort, oder das Konto steht nicht in der sudoers-Datei.",
), ),
( (
"is not in the sudoers file", "is not in the sudoers file",
"Das Wartungskonto darf auf diesem Geraet kein 'sudo' ausfuehren. Auf dem " "Das Wartungskonto darf auf diesem Gerät kein 'sudo' ausführen. Auf dem "
"Zielgeraet eintragen, z. B. in /etc/sudoers.d/tesm-wartung: " "Zielgerät eintragen, z. B. in /etc/sudoers.d/tesm-wartung: "
"'<benutzer> ALL=(root) /usr/bin/apt-get, /sbin/reboot'. Mit vorangestelltem " "'<benutzer> ALL=(root) /usr/bin/apt-get, /sbin/reboot'. Mit vorangestelltem "
"NOPASSWD: entfaellt die Passwortabfrage ganz.", "NOPASSWD: entfällt die Passwortabfrage ganz.",
), ),
( (
"a password is required", "a password is required",
"'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geraets ist keines " "'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geräts ist keines "
"hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein -- TESM reicht " "hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein | TESM reicht "
"es ueber die bereits verschluesselte SSH-Verbindung an 'sudo' weiter.", "es über die bereits verschlüsselte SSH-Verbindung an 'sudo' weiter.",
), ),
( (
"no tty present", "no tty present",
"Das Wartungskonto verlangt fuer 'sudo' ein Terminal. In der sudoers-Datei des " "Das Wartungskonto verlangt für 'sudo' ein Terminal. In der sudoers-Datei des "
"Zielgeraets 'Defaults requiretty' entfernen oder fuer dieses Konto abschalten.", "Zielgeräts 'Defaults requiretty' entfernen oder für dieses Konto abschalten.",
), ),
( (
"command not found", "command not found",
"Das Kommando gibt es auf dem Zielgeraet nicht. Die Wartung erwartet ein " "Das Kommando gibt es auf dem Zielgerät nicht. Die Wartung erwartet ein "
"Debian- oder Ubuntu-System mit apt-get.", "Debian- oder Ubuntu-System mit apt-get.",
), ),
( (
"Access is denied", "Access is denied",
"Das Konto darf auf diesem Windows-Geraet keinen Neustart ausloesen. Es muss " "Das Konto darf auf diesem Windows-Gerät keinen Neustart auslösen. Es muss "
"Mitglied der lokalen Administratoren sein.", "Mitglied der lokalen Administratoren sein.",
), ),
( (
"is not recognized as an internal or external command", "is not recognized as an internal or external command",
"Das Kommando gibt es auf diesem Geraet nicht. Ist bei den Zugangsdaten die " "Das Kommando gibt es auf diesem Gerät nicht. Ist bei den Zugangsdaten die "
"richtige Kategorie gewaehlt (Linux-Client bzw. Windows-Client)?", "richtige Kategorie gewählt (Linux-Client bzw. Windows-Client)?",
), ),
( (
"Could not get lock", "Could not get lock",
"Ein anderer Paketvorgang laeuft bereits auf dem Zielgeraet (etwa " "Ein anderer Paketvorgang läuft bereits auf dem Zielgerät (etwa "
"unattended-upgrades). Spaeter erneut versuchen.", "unattended-upgrades). Später erneut versuchen.",
), ),
) )
@@ -256,8 +256,8 @@ def mark_orphaned_jobs(conn: sqlite3.Connection) -> int:
"UPDATE jobs SET state='unknown', message=?, finished_at=?, updated_at=? " "UPDATE jobs SET state='unknown', message=?, finished_at=?, updated_at=? "
"WHERE state IN ('pending','running')", "WHERE state IN ('pending','running')",
( (
"Der Dienst wurde waehrend des Laufs neu gestartet. Was auf dem Zielgeraet " "Der Dienst wurde während des Laufs neu gestartet. Was auf dem Zielgerät "
"passiert ist, laesst sich von hier nicht mehr feststellen.", "passiert ist, lässt sich von hier nicht mehr feststellen.",
_now(), _now(),
_now(), _now(),
), ),
@@ -293,7 +293,7 @@ def _run_remote_command(
channel.sendall(stdin_text.encode("utf-8")) channel.sendall(stdin_text.encode("utf-8"))
channel.shutdown_write() channel.shutdown_write()
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
on_line(f"[Eingabe konnte nicht uebergeben werden: {exc}]") on_line(f"[Eingabe konnte nicht übergeben werden: {exc}]")
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
pending = b"" pending = b""
while True: while True:
@@ -308,7 +308,7 @@ def _run_remote_command(
elif channel.exit_status_ready(): elif channel.exit_status_ready():
break break
elif time.monotonic() > deadline: elif time.monotonic() > deadline:
on_line("[Zeitlimit ueberschritten -- Verbindung wird getrennt]") on_line("[Zeitlimit überschritten | Verbindung wird getrennt]")
channel.close() channel.close()
return 124 return 124
else: else:
@@ -341,14 +341,14 @@ def _sudo_mode(
except Exception: # noqa: BLE001 - dann eben der Weg mit Passwort except Exception: # noqa: BLE001 - dann eben der Weg mit Passwort
status = 1 status = 1
if status == 0: if status == 0:
on_line("sudo ist ohne Passwort moeglich.") on_line("sudo ist ohne Passwort möglich.")
return SUDO_PASSWORDLESS, "" return SUDO_PASSWORDLESS, ""
if not password: if not password:
on_line( on_line(
"sudo verlangt ein Passwort, in den Zugangsdaten ist keines hinterlegt." "sudo verlangt ein Passwort, in den Zugangsdaten ist keines hinterlegt."
) )
return SUDO_PASSWORDLESS, "" return SUDO_PASSWORDLESS, ""
on_line("sudo verlangt ein Passwort -- es wird aus den Zugangsdaten uebergeben.") on_line("sudo verlangt ein Passwort – es wird aus den Zugangsdaten übergeben.")
return SUDO_WITH_PASSWORD, password + "\n" return SUDO_WITH_PASSWORD, password + "\n"
@@ -411,7 +411,7 @@ def run_update(
) )
if status != 0: if status != 0:
failed = True failed = True
emit(f"Schritt {label!r} endete mit Rueckgabewert {status}.") emit(f"Schritt {label!r} endete mit Rückgabewert {status}.")
problem = hint_for("\n".join(collected)) problem = hint_for("\n".join(collected))
if problem: if problem:
emit("") emit("")
@@ -465,7 +465,7 @@ def _reboot_windows(
Rueckkehr danach ist dasselbe -- und das steht beim Aufrufer. Rueckkehr danach ist dasselbe -- und das steht beim Aufrufer.
""" """
host = str(device["ip"]) host = str(device["ip"])
emit(f"Windows-Geraet -- Neustart ueber RPC an {host} (Port {WINDOWS_PROBE_PORT}).") emit(f"Windows-Gerät | Neustart über RPC an {host} (Port {WINDOWS_PROBE_PORT}).")
result = windowsops.reboot(host, username, password) result = windowsops.reboot(host, username, password)
for line in result.output.splitlines(): for line in result.output.splitlines():
@@ -500,6 +500,7 @@ def run_reboot(
password: str, password: str,
wait_seconds: int, wait_seconds: int,
actor: str = "system", actor: str = "system",
method: str = METHOD_SSH,
) -> None: ) -> None:
"""Loest einen Neustart aus und wartet auf die Rueckkehr. """Loest einen Neustart aus und wartet auf die Rueckkehr.
@@ -518,7 +519,15 @@ def run_reboot(
return int((time.monotonic() - started) * 1000) return int((time.monotonic() - started) * 1000)
host = str(device["ip"]) host = str(device["ip"])
is_windows = str(device.get("category") or "") == WINDOWS_CATEGORY # 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 # Woran erkennt man, dass das Geraet zurueck ist? Am selben Dienst, ueber
# den der Neustart lief: SSH bei Linux, SMB/RPC bei Windows. # 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) probe_port = WINDOWS_PROBE_PORT if is_windows else int(device.get("ssh_port") or 22)
@@ -566,19 +575,20 @@ def run_reboot(
actor=actor, actor=actor,
duration_ms=elapsed(), duration_ms=elapsed(),
detail=str(exc), detail=str(exc),
method=method,
) )
return return
sudo, sudo_stdin = _sudo_mode(transport, password, emit) sudo, sudo_stdin = _sudo_mode(transport, password, emit)
command = REBOOT_COMMAND.format(sudo=sudo) command = REBOOT_COMMAND.format(sudo=sudo)
try: try:
emit(f"Neustart wird ausgeloest ({command})") emit(f"Neustart wird ausgelöst ({command})")
try: try:
_run_remote_command( _run_remote_command(
transport, command, timeout=20, on_line=capture, stdin_text=sudo_stdin transport, command, timeout=20, on_line=capture, stdin_text=sudo_stdin
) )
except Exception as exc: # noqa: BLE001 - Abbruch ist hier normal except Exception as exc: # noqa: BLE001 - Abbruch ist hier normal
emit(f"Verbindung getrennt ({exc}) -- das ist beim Neustart zu erwarten.") emit(f"Verbindung getrennt ({exc}) – das ist beim Neustart zu erwarten.")
finally: finally:
try: try:
transport.close() transport.close()
@@ -607,10 +617,11 @@ def run_reboot(
actor=actor, actor=actor,
duration_ms=elapsed(), duration_ms=elapsed(),
detail=problem, detail=problem,
method=method,
) )
return return
update_job(database, job_id, progress=40, message="Warte auf Rueckkehr des Geraets") update_job(database, job_id, progress=40, message="Warte auf Rückkehr des Geräts")
time.sleep(REBOOT_GRACE_SECONDS) time.sleep(REBOOT_GRACE_SECONDS)
deadline = time.monotonic() + wait_seconds deadline = time.monotonic() + wait_seconds
ssh_port = probe_port ssh_port = probe_port
@@ -621,7 +632,7 @@ def run_reboot(
# Zeitueberschreitung, obwohl das Geraet zurueck war. Ausserdem ist # Zeitueberschreitung, obwohl das Geraet zurueck war. Ausserdem ist
# "SSH antwortet wieder" die Aussage, auf die es hier ankommt. # "SSH antwortet wieder" die Aussage, auf die es hier ankommt.
if tcp_reachable(host, ssh_port): if tcp_reachable(host, ssh_port):
emit(f"Geraet ist wieder erreichbar (Port {ssh_port} antwortet).") emit(f"Gerät ist wieder erreichbar (Port {ssh_port} antwortet).")
update_job( update_job(
database, database,
job_id, job_id,
@@ -645,7 +656,8 @@ def run_reboot(
result="ok", result="ok",
actor=actor, actor=actor,
duration_ms=elapsed(), duration_ms=elapsed(),
detail="Neustart ueber SSH, Geraet ist zurueckgekehrt.", detail="Neustart über SSH, Gerät ist zurückgekehrt.",
method=method,
) )
return return
time.sleep(REBOOT_POLL_SECONDS) time.sleep(REBOOT_POLL_SECONDS)
@@ -656,7 +668,7 @@ def run_reboot(
job_id, job_id,
state="failed", state="failed",
progress=100, progress=100,
message=f"Geraet nicht zurueckgekehrt ({host}:{ssh_port} antwortet nicht).", message=f"Gerät nicht zurückgekehrt ({host}:{ssh_port} antwortet nicht).",
finished=True, finished=True,
) )
_log_restart( _log_restart(
@@ -666,10 +678,11 @@ def run_reboot(
actor=actor, actor=actor,
duration_ms=elapsed(), duration_ms=elapsed(),
detail=( detail=(
f"Neustart ausgeloest, aber {host}:{ssh_port} hat innerhalb von " f"Neustart ausgelöst, aber {host}:{ssh_port} hat innerhalb von "
f"{wait_seconds}s nicht wieder geantwortet." f"{wait_seconds}s nicht wieder geantwortet."
), ),
) method=method,
)
def _log_restart( def _log_restart(
@@ -705,7 +718,7 @@ def _log_restart(
method=method, method=method,
) )
except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen
logger.exception("Neustart konnte nicht protokolliert werden (Geraet %s)", device_id) logger.exception("Neustart konnte nicht protokolliert werden (Gerät %s)", device_id)
def start_background(target: Callable[..., None], **kwargs: Any) -> None: def start_background(target: Callable[..., None], **kwargs: Any) -> None:
+146 -20
View File
@@ -33,7 +33,7 @@ import threading
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
@@ -106,10 +106,16 @@ def ping(host: str, *, timeout: int = PING_TIMEOUT_SECONDS) -> PingResult:
command = [binary, "-c", "1", "-W", str(timeout), host] command = [binary, "-c", "1", "-W", str(timeout), host]
try: try:
completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell
command, capture_output=True, text=True, timeout=timeout + 3, check=False command,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout + 3,
check=False,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return PingResult(False, detail="Zeitlimit ueberschritten") return PingResult(False, detail="Zeitlimit überschritten")
except OSError as exc: except OSError as exc:
return PingResult(False, detail=str(exc)) return PingResult(False, detail=str(exc))
@@ -268,8 +274,8 @@ METHOD_SSH = "ssh"
METHOD_RPC = "rpc" METHOD_RPC = "rpc"
METHOD_LABELS = { METHOD_LABELS = {
METHOD_POE: "PoE-Port", METHOD_POE: "PoE-Port",
METHOD_SSH: "Neustart ueber SSH", METHOD_SSH: "Neustart über SSH",
METHOD_RPC: "Neustart ueber RPC", METHOD_RPC: "Neustart über RPC",
} }
#: Zustand zwischen ausgeloestem Neustart und Rueckkehr. #: Zustand zwischen ausgeloestem Neustart und Rueckkehr.
@@ -313,9 +319,10 @@ def record_restart(
(device_id, _now(), trigger, actor, action, result, duration_ms, detail[:8000], method), (device_id, _now(), trigger, actor, action, result, duration_ms, detail[:8000], method),
) )
if result == "ok": if result == "ok":
# Der Zustand wird hier gesetzt, nicht in den einzelnen Neustartwegen: # Hier faellt nur noch der Zaehler und der Zeitstempel an. Den Zustand
# PoE, SSH und RPC laufen alle durch diese Funktion. Eine zweite Stelle # setzt ``mark_restarting`` schon beim Ausloesen -- dieses Ergebnis
# waere eine, die beim naechsten Verfahren vergessen wird. # kommt bei SSH und RPC erst, wenn das Geraet zurueck ist, und bis
# dahin stand die Kachel weiter auf "online".
conn.execute( conn.execute(
"UPDATE device_status SET last_restart_at=?, restart_count=restart_count+1, " "UPDATE device_status SET last_restart_at=?, restart_count=restart_count+1, "
"state=?, last_change_at=?, fail_count=0 WHERE device_id=?", "state=?, last_change_at=?, fail_count=0 WHERE device_id=?",
@@ -323,6 +330,33 @@ def record_restart(
) )
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 # PoE-Neustart
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -363,9 +397,16 @@ def restart_device_poe(
switch_ip = str(device.get("switch_ip") or "") switch_ip = str(device.get("switch_ip") or "")
credential_id = device.get("switch_credential_id") 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: if not port or not switch_ip:
message = ( message = (
"Kein Switch oder kein Port zugeordnet -- ein PoE-Neustart ist nicht moeglich." "Kein Switch oder kein Port zugeordnet – ein PoE-Neustart ist nicht möglich."
) )
with write(): with write():
record_restart( record_restart(
@@ -381,7 +422,7 @@ def restart_device_poe(
return {"ok": False, "message": message, "transcript": ""} return {"ok": False, "message": message, "transcript": ""}
if not credential_id: if not credential_id:
message = f"Fuer Switch {device.get('switch_hostname') or switch_ip} sind keine Zugangsdaten hinterlegt." message = f"Für Switch {device.get('switch_hostname') or switch_ip} sind keine Zugangsdaten hinterlegt."
with write(): with write():
record_restart( record_restart(
conn, conn,
@@ -445,7 +486,7 @@ def restart_device_poe(
live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}") live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}")
return { return {
"ok": outcome.ok, "ok": outcome.ok,
"message": outcome.error or "PoE-Neustart durchgefuehrt.", "message": outcome.error or "PoE-Neustart durchgeführt.",
"transcript": outcome.transcript, "transcript": outcome.transcript,
} }
@@ -466,6 +507,39 @@ class SweepReport:
duration_ms: 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( def run_sweep(
*, *,
database: Any, database: Any,
@@ -473,13 +547,25 @@ def run_sweep(
keystore: Any, keystore: Any,
license_allows_auto_restart: bool, license_allows_auto_restart: bool,
failures_before_restart: int = DEFAULT_FAILURES_BEFORE_RESTART, failures_before_restart: int = DEFAULT_FAILURES_BEFORE_RESTART,
vnc_port: int = 0,
) -> SweepReport: ) -> SweepReport:
"""Ein vollstaendiger Ueberwachungsdurchlauf. """Ein vollstaendiger Ueberwachungsdurchlauf.
Die Pruefungen laufen parallel (I/O-gebunden), die Datenbankschreibvorgaenge Die Pruefungen laufen parallel (I/O-gebunden), die Datenbankschreibvorgaenge
danach in **einer** Transaktion -- so gibt es keinen Schreib-Wettlauf und danach in **einer** Transaktion -- so gibt es keinen Schreib-Wettlauf und
keine halb geschriebenen Durchlaeufe. 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() report = SweepReport()
started = time.monotonic() started = time.monotonic()
@@ -490,15 +576,28 @@ def run_sweep(
report.duration_ms = int((time.monotonic() - started) * 1000) report.duration_ms = int((time.monotonic() - started) * 1000)
return report 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: with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
results = list(pool.map(lambda device: (device, ping(str(device["ip"]))), devices)) results = list(pool.map(pruefen, devices))
restart_candidates: list[dict[str, Any]] = [] restart_candidates: list[dict[str, Any]] = []
with database.session() as conn: with database.session() as conn:
with database.transaction(conn): with database.transaction(conn):
for device, result in results: for device, result, vnc_offen in results:
report.checked += 1 report.checked += 1
previous, current = record_status(conn, int(device["id"]), result) 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: if result.ok:
report.online += 1 report.online += 1
else: else:
@@ -526,7 +625,7 @@ def run_sweep(
report.skipped_unlicensed += 1 report.skipped_unlicensed += 1
live.warning( live.warning(
"poe", "poe",
f"{name}: automatischer PoE-Neustart uebersprungen -- keine gueltige Lizenz.", f"{name}: automatischer PoE-Neustart übersprungen – keine gültige Lizenz.",
) )
continue continue
report.restarts_triggered += 1 report.restarts_triggered += 1
@@ -573,10 +672,34 @@ def _in_cooldown(last_restart_at: Any) -> bool:
return (datetime.now(timezone.utc) - moment).total_seconds() < RESTART_COOLDOWN_SECONDS return (datetime.now(timezone.utc) - moment).total_seconds() < RESTART_COOLDOWN_SECONDS
def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]: def next_check_at(conn: sqlite3.Connection, interval_seconds: int) -> datetime | None:
row = Database.one( """Wann der naechste Ueberwachungsdurchlauf faellig ist.
conn,
""" 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 SELECT
COUNT(*) AS total, COUNT(*) AS total,
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled, SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
@@ -586,8 +709,11 @@ def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
SUM(CASE WHEN d.is_active=1 AND st.state='restarting' THEN 1 ELSE 0 END) AS restarting 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 FROM devices d LEFT JOIN device_status st ON st.device_id = d.id
WHERE d.deleted_at IS NULL 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 {} row = row or {}
return { return {
key: int(row.get(key) or 0) key: int(row.get(key) or 0)
+70 -12
View File
@@ -22,7 +22,7 @@ etwa bei einem Geraet, das schreibend auf eine Platte zugreift.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any, Callable
METHOD_POE = "poe" METHOD_POE = "poe"
METHOD_SSH = "ssh" METHOD_SSH = "ssh"
@@ -40,39 +40,70 @@ class Method:
#: Braucht dieser Weg das lizenzpflichtige Modul "Wartung"? #: Braucht dieser Weg das lizenzpflichtige Modul "Wartung"?
licensed: bool = False 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 @property
def is_poe(self) -> bool: def is_poe(self) -> bool:
return self.key == METHOD_POE 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] = { 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( METHOD_POE: Method(
key=METHOD_POE, key=METHOD_POE,
label="PoE-Port", label="PoE-Port",
hint="Schaltet den Switch-Port kurz ab. Wirkt auch, wenn das Geraet haengt.", hint="Schaltet den Switch-Port kurz ab. Wirkt auch, wenn das Gerät hängt.",
), ),
METHOD_SSH: Method( METHOD_SSH: Method(
key=METHOD_SSH, key=METHOD_SSH,
label="Neustart ueber SSH", label="Neustart über SSH",
hint="Faehrt das Betriebssystem sauber herunter. Setzt ein antwortendes Geraet " hint="Fährt das Betriebssystem sauber herunter. Setzt ein antwortendes Gerät "
"und einen freigegebenen Host-Schluessel voraus.", "und einen freigegebenen Host-Schlüssel voraus.",
licensed=True, licensed=True,
), ),
METHOD_RPC: Method( METHOD_RPC: Method(
key=METHOD_RPC, key=METHOD_RPC,
label="Neustart ueber RPC", label="Neustart über RPC",
hint="Windows: wie 'shutdown /m'. Setzt offene Datei- und Druckerfreigabe voraus.", hint="Windows: wie 'shutdown /m'. Setzt offene Datei- und Druckerfreigabe voraus.",
licensed=True, licensed=True,
), ),
} }
def available(device: Any, *, maintenance_active: bool = True) -> list[Method]: 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. """Alle Wege, die bei diesem Geraet tatsaechlich zu etwas fuehren.
Ein Weg, der nur *fast* geht -- Switch zugeordnet, aber kein Port --, gilt Ein Weg, der nur *fast* geht -- Switch zugeordnet, aber kein Port --, gilt
als nicht verfuegbar. Ein Knopf, der verlaesslich nichts bewirkt, ist als nicht verfuegbar. Ein Knopf, der verlaesslich nichts bewirkt, ist
schlimmer als kein Knopf. 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: def field(name: str) -> Any:
@@ -82,9 +113,20 @@ def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
return None return None
methods: list[Method] = [] methods: list[Method] = []
if field("switch_id") and str(field("port") or "").strip(): hat_poe = bool(field("switch_id")) and bool(str(field("port") or "").strip())
if hat_poe:
methods.append(DEFINITIONS[METHOD_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 "") category = str(field("credential_category") or "")
has_login = bool(field("credential_id")) has_login = bool(field("credential_id"))
if has_login and maintenance_active: if has_login and maintenance_active:
@@ -93,23 +135,39 @@ def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
elif category == "linux": elif category == "linux":
methods.append(DEFINITIONS[METHOD_SSH]) 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)) return sorted(methods, key=lambda entry: PREFERENCE.index(entry.key))
def preferred(device: Any, *, maintenance_active: bool = True) -> Method | None: 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.""" """Der Weg, der vorausgewaehlt wird -- oder ``None``, wenn es keinen gibt."""
options = available(device, maintenance_active=maintenance_active) options = available(device, maintenance_active=maintenance_active, darf=darf)
return options[0] if options else None return options[0] if options else None
def resolve(device: Any, requested: str, *, maintenance_active: bool = True) -> Method | 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. """Prueft eine Anforderung gegen das, was tatsaechlich moeglich ist.
Die Oberflaeche darf vorschlagen; entscheiden tut der Server. Ein Weg, der Die Oberflaeche darf vorschlagen; entscheiden tut der Server. Ein Weg, der
fuer dieses Geraet nicht offensteht, wird nicht "irgendwie" ersetzt -- fuer dieses Geraet nicht offensteht, wird nicht "irgendwie" ersetzt --
sonst startet jemand ein Geraet anders neu, als er dachte. sonst startet jemand ein Geraet anders neu, als er dachte.
""" """
options = {entry.key: entry for entry in available(device, maintenance_active=maintenance_active)} options = {
entry.key: entry
for entry in available(device, maintenance_active=maintenance_active, darf=darf)
}
if not options: if not options:
return None return None
if not requested: if not requested:
+1 -1
View File
@@ -128,7 +128,7 @@ class ShellSession:
elif time.monotonic() > deadline: elif time.monotonic() > deadline:
channel.close() channel.close()
raise SshError( raise SshError(
f"Das Kommando hat das Zeitlimit von {timeout}s ueberschritten." f"Das Kommando hat das Zeitlimit von {timeout}s überschritten."
) )
else: else:
time.sleep(0.05) time.sleep(0.05)
+11 -11
View File
@@ -48,8 +48,8 @@ class UnknownHostKey(SshError):
def __init__(self, host: str, port: int, key_type: str, fingerprint: str) -> None: def __init__(self, host: str, port: int, key_type: str, fingerprint: str) -> None:
super().__init__( super().__init__(
f"Der SSH-Host-Schluessel von {host}:{port} ist unbekannt " f"Der SSH-Host-Schlüssel von {host}:{port} ist unbekannt "
f"({key_type}, {fingerprint}). Bitte pruefen und ausdruecklich freigeben." f"({key_type}, {fingerprint}). Bitte prüfen und ausdrücklich freigeben."
) )
self.host = host self.host = host
self.port = port self.port = port
@@ -62,9 +62,9 @@ class HostKeyChanged(SshError):
def __init__(self, host: str, port: int, expected: str, received: str) -> None: def __init__(self, host: str, port: int, expected: str, received: str) -> None:
super().__init__( super().__init__(
f"Der SSH-Host-Schluessel von {host}:{port} hat sich geaendert " f"Der SSH-Host-Schlüssel von {host}:{port} hat sich geändert "
f"(erwartet {expected}, erhalten {received}). Der Aufbau wurde abgebrochen. " f"(erwartet {expected}, erhalten {received}). Der Aufbau wurde abgebrochen. "
"Das kann ein Geraetetausch sein -- oder ein Angriff." "Das kann ein Gerätetausch sein – oder ein Angriff."
) )
self.host = host self.host = host
self.port = port self.port = port
@@ -187,17 +187,17 @@ def describe_probe(
if known is None: if known is None:
return ( return (
"info", "info",
f"Host-Schluessel gelesen: {record.key_type} {record.fingerprint}. " f"Host-Schlüssel gelesen: {record.key_type} {record.fingerprint}. "
"Bitte gegen eine unabhaengige Quelle pruefen und dann freigeben.", "Bitte gegen eine unabhängige Quelle prüfen und dann freigeben.",
) )
if known.fingerprint != record.fingerprint: if known.fingerprint != record.fingerprint:
return ( return (
"danger", "danger",
f"Achtung: der Schluessel weicht vom gespeicherten ab. Gespeichert " f"Achtung: der Schlüssel weicht vom gespeicherten ab. Gespeichert "
f"{known.fingerprint}, gelesen {record.fingerprint}. Das kann ein " f"{known.fingerprint}, gelesen {record.fingerprint}. Das kann ein "
"Geraetetausch sein -- oder ein Angriff.", "Gerätetausch sein – oder ein Angriff.",
) )
return ("success", "Der Host-Schluessel stimmt mit dem gespeicherten ueberein.") 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: def probe_host_key(host: str, port: int = 22, *, timeout: int = CONNECT_TIMEOUT) -> HostKeyRecord:
@@ -281,8 +281,8 @@ def open_transport(
raise SshError( raise SshError(
f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}). " f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}). "
f"Freigegeben sind nur {', '.join(sorted(set(trusted_types)))} -- " f"Freigegeben sind nur {', '.join(sorted(set(trusted_types)))} -- "
"bietet das Geraet inzwischen einen anderen Schluesseltyp an, muss der " "bietet das Gerät inzwischen einen anderen Schlüsseltyp an, muss der "
"Schluessel neu ausgelesen und freigegeben werden." "Schlüssel neu ausgelesen und freigegeben werden."
) from exc ) from exc
raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc
+127 -13
View File
@@ -30,14 +30,37 @@ from .ssh import SshError, open_transport
logger = logging.getLogger(__name__) 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 #: Erkennt Operator- (``>``) und privilegierten Modus (``#``) sowie den
#: Konfigurationsmodus (``switch(config)#``). #: Konfigurationsmodus (``switch(config)#``).
PROMPT_RE = re.compile(rb"[\r\n][^\r\n]{0,80}?[>#]\s?$") #:
#: 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) PAGER_RE = re.compile(rb"(--\s?MORE\s?--|Press any key to continue)", re.IGNORECASE)
ERROR_RE = re.compile( ERROR_RE = re.compile(
r"(invalid input|unknown command|incomplete|ambiguous|not supported|% *error)", re.IGNORECASE 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 READ_CHUNK = 65536
DEFAULT_COMMAND_TIMEOUT = 20 DEFAULT_COMMAND_TIMEOUT = 20
POE_SETTLE_SECONDS = 2 POE_SETTLE_SECONDS = 2
@@ -104,7 +127,42 @@ class SwitchCli:
self.close() self.close()
def close(self) -> None: def close(self) -> None:
for closer in (self._channel, self._transport): """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: try:
if closer is not None: if closer is not None:
closer.close() closer.close()
@@ -116,28 +174,62 @@ class SwitchCli:
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _read_until_prompt(self, *, timeout: int, initial: bool = False) -> str: 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 assert self._channel is not None
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
collected = b"" # 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: while time.monotonic() < deadline:
if self._channel.recv_ready(): if self._channel.recv_ready():
chunk = self._channel.recv(READ_CHUNK) chunk = self._channel.recv(READ_CHUNK)
if not chunk: if not chunk:
break break
collected += chunk sichtbar = ANSI_RE.sub(b"", sichtbar + chunk)
if PAGER_RE.search(collected[-200:]): if PAGER_RE.search(sichtbar[-300:]):
self._channel.send(" ") self._channel.send(" ")
collected = PAGER_RE.sub(b"", collected) sichtbar = PAGER_RE.sub(b"", sichtbar)
continue continue
if PROMPT_RE.search(collected[-200:]): if PROMPT_RE.search(sichtbar[-300:]):
prompt_gesehen = True
break break
# Der Login-Banner mancher Geraete verlangt eine Bestaetigung. # Der Login-Banner mancher Geraete verlangt eine Bestaetigung.
if initial and collected.rstrip().endswith(b"continue"): if initial and sichtbar.rstrip().endswith(b"continue"):
self._channel.send("\r\n") self._channel.send("\r\n")
else: else:
time.sleep(0.05) time.sleep(0.05)
text = collected.decode("utf-8", errors="replace") text = sichtbar.decode("utf-8", errors="replace")
self.transcript.append(text) 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 return text
def send( def send(
@@ -149,7 +241,7 @@ class SwitchCli:
``tolerate_error`` (z. B. ``no page``, das nicht jede Firmware kennt). ``tolerate_error`` (z. B. ``no page``, das nicht jede Firmware kennt).
""" """
if self._channel is None: if self._channel is None:
raise SshError("Die Sitzung ist nicht geoeffnet.") raise SshError("Die Sitzung ist nicht geöffnet.")
self.transcript.append(f"\n$ {command}\n") self.transcript.append(f"\n$ {command}\n")
self._channel.send(command + "\r\n") self._channel.send(command + "\r\n")
output = self._read_until_prompt(timeout=timeout) output = self._read_until_prompt(timeout=timeout)
@@ -207,11 +299,22 @@ class SwitchCli:
off_state = self.poe_state(port) off_state = self.poe_state(port)
if off_state == "enabled": if off_state == "enabled":
result.error = ( result.error = (
"Der Switch meldet den Port nach dem Abschalten weiter als aktiv -- " "Der Switch meldet den Port nach dem Abschalten weiter als aktiv – "
"der Neustart hat nicht stattgefunden." "der Neustart hat nicht stattgefunden."
) )
result.transcript = self.full_transcript result.transcript = self.full_transcript
return result 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}") result.add(f"Zustand im ausgeschalteten Zustand: {off_state}")
self.set_poe(port, enabled=True) self.set_poe(port, enabled=True)
@@ -224,6 +327,17 @@ class SwitchCli:
result.transcript = self.full_transcript result.transcript = self.full_transcript
return result 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 result.ok = True
except SshError as exc: except SshError as exc:
result.error = str(exc) result.error = str(exc)
@@ -245,11 +359,11 @@ class SwitchCli:
#: Kommandos, die die Oberflaeche ohne freies Eingabefeld anbietet. #: Kommandos, die die Oberflaeche ohne freies Eingabefeld anbietet.
SAFE_COMMANDS: tuple[tuple[str, str], ...] = ( SAFE_COMMANDS: tuple[tuple[str, str], ...] = (
("show system information", "Systeminformationen"), ("show system information", "Systeminformationen"),
("show power-over-ethernet brief", "PoE-Uebersicht aller Ports"), ("show power-over-ethernet brief", "PoE-Übersicht aller Ports"),
("show interfaces brief", "Portstatus"), ("show interfaces brief", "Portstatus"),
("show vlans", "VLAN-Uebersicht"), ("show vlans", "VLAN-Uebersicht"),
("show mac-address", "MAC-Adresstabelle"), ("show mac-address", "MAC-Adresstabelle"),
("show lldp info remote-device", "Nachbargeraete (LLDP)"), ("show lldp info remote-device", "Nachbargeräte (LLDP)"),
("show version", "Firmware-Version"), ("show version", "Firmware-Version"),
("show logging", "Switch-Protokoll"), ("show logging", "Switch-Protokoll"),
) )
+208 -8
View File
@@ -14,6 +14,7 @@ from typing import Any, Callable, Iterable
from tesm_core.db import Database from tesm_core.db import Database
from tesm_core.keystore import Keystore from tesm_core.keystore import Keystore
from tesm_core.rbac.legacy_permissions import umgeschrieben
from tesm_core.transfer import ( from tesm_core.transfer import (
MIN_PASSPHRASE_LENGTH, MIN_PASSPHRASE_LENGTH,
PREVIEW_TTL_MINUTES, PREVIEW_TTL_MINUTES,
@@ -50,20 +51,35 @@ def unseal(raw: bytes | str, passphrase: str) -> tuple[dict[str, Any], dict[str,
CATEGORIES: tuple[Category, ...] = ( CATEGORIES: tuple[Category, ...] = (
Category("devices", "Clients", "Geraetestammdaten inklusive Switch- und Portzuordnung"), Category("devices", "Clients", "Gerätestammdaten inklusive Switch- und Portzuordnung"),
Category("switches", "Switche", "Switch-Inventar"), Category("switches", "Switche", "Switch-Inventar"),
Category( Category(
"credentials", "credentials",
"Zugangsdaten", "Zugangsdaten",
"SSH-Logins. Die Passwoerter liegen im Export im Klartext -- geschuetzt " "SSH-Logins. Die Passwörter liegen im Export im Klartext – geschützt "
"ausschliesslich durch die Passphrase.", "ausschließlich durch die Passphrase.",
sensitive=True, sensitive=True,
), ),
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"), Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
Category("fileshare", "Freigabe-Zuordnungen", "Verzeichnisgruppe zu UNC-Pfad"), 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("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True), Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", 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} CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES}
@@ -123,7 +139,7 @@ def build_export(
str(row["secret_encrypted"]), aad=aad_for_credential(int(row["id"])) str(row["secret_encrypted"]), aad=aad_for_credential(int(row["id"]))
) )
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
entry["secret_error"] = "nicht entschluesselbar" entry["secret_error"] = "nicht entschlüsselbar"
entries.append(entry) entries.append(entry)
payload["credentials"] = entries payload["credentials"] = entries
if "dhcp" in wanted: if "dhcp" in wanted:
@@ -178,6 +194,49 @@ def build_export(
} }
for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL") 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: if "settings" in wanted:
payload["settings"] = { payload["settings"] = {
str(row["key"]): str(row["value"]) str(row["key"]): str(row["value"])
@@ -186,6 +245,16 @@ def build_export(
# Sicherung -- sonst wuerde ein Restore eine fremde Aktivierung erben. # Sicherung -- sonst wuerde ein Restore eine fremde Aktivierung erben.
if not str(row["key"]).startswith("license_") 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 return payload
@@ -237,6 +306,9 @@ def apply_import(
""" """
from .inventory import ValidationError, aad_for_credential, normalize_mac 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: def mac_or_none(value: Any) -> str | None:
"""MAC normalisieren, ohne den ganzen Import zu sprengen. """MAC normalisieren, ohne den ganzen Import zu sprengen.
@@ -513,19 +585,33 @@ def apply_import(
if "fileshare" in wanted: if "fileshare" in wanted:
for entry in payload.get("fileshare", []): 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( conn.execute(
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) " "INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO UPDATE SET " "VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO UPDATE SET "
"label=excluded.label, ad_group_name=excluded.ad_group_name", "label=excluded.label, ad_group_name=excluded.ad_group_name",
( (
str(entry.get("ad_group_dn") or ""), gruppe,
str(entry.get("ad_group_name") or ""), str(entry.get("ad_group_name") or ""),
str(entry.get("label") or ""), str(entry.get("label") or ""),
str(entry.get("unc") or ""), pfad,
stamp, stamp,
), ),
) )
bump("fileshare", "updated") bump("fileshare", "updated" if vorhanden else "created")
if "groups" in wanted: if "groups" in wanted:
for entry in payload.get("groups", []): for entry in payload.get("groups", []):
@@ -557,9 +643,21 @@ def apply_import(
group_id = int(existing["id"]) group_id = int(existing["id"])
bump("groups", "updated") bump("groups", "updated")
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) 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( conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)", "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(group_id, str(permission)) for permission in entry.get("permissions", [])], [(group_id, permission) for permission in neue],
) )
if "users" in wanted: if "users" in wanted:
@@ -625,6 +723,95 @@ def apply_import(
(user_id, int(group_id)), (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: if "settings" in wanted:
for key, value in (payload.get("settings") or {}).items(): for key, value in (payload.get("settings") or {}).items():
if str(key).startswith("license_"): if str(key).startswith("license_"):
@@ -634,4 +821,17 @@ def apply_import(
set_setting(str(key), value) set_setting(str(key), value)
bump("settings", "updated") 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 return report
+432
View File
@@ -0,0 +1,432 @@
"""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,
)
#: So lange gilt eine Messung der Anmeldeverfahren als frisch.
#:
#: Sie kostet eine angefangene Protokollverhandlung, und ein RealVNC Server
#: zaehlt abgebrochene Versuche. Wer eine Seite dreimal neu laedt, soll dem
#: Geraet nicht dreimal als Fehlversuch erscheinen.
MESSUNG_GILT = 300.0
def zugang_gemerkt(
device: Mapping[str, Any], *, port: int = STANDARD_PORT, pruefen: bool = True
) -> tuple[Zugang, bool]:
"""Der Zugang -- aus der letzten Messung, wenn die frisch genug ist.
Gibt ``(Zugang, frisch_gemessen)`` zurueck; das zweite sagt dem Aufrufer,
ob er das Ergebnis festhalten muss.
"""
host = ziel(device)
if not pruefen:
return Zugang(ziel=host, port=port, erreichbar=False), False
gemessen_am = str(device.get("vnc_types_at") or "")
if gemessen_am and _juenger_als(gemessen_am, MESSUNG_GILT):
gespeichert = str(device.get("vnc_types") or "")
verfahren = tuple(
int(teil) for teil in gespeichert.split(",") if teil.strip().isdigit()
)
return (
Zugang(
ziel=host,
port=port,
erreichbar=bool(device.get("vnc_open")),
verfahren=verfahren,
absage=str(device.get("vnc_reason") or ""),
),
False,
)
return zugang(device, port=port), True
def _juenger_als(zeitpunkt: str, sekunden: float) -> bool:
"""Ist dieser ISO-Zeitstempel juenger als die Frist?"""
from datetime import datetime, timezone
try:
moment = datetime.fromisoformat(zeitpunkt)
except ValueError:
return False
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - moment).total_seconds() < sekunden
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)
+158 -47
View File
@@ -51,27 +51,46 @@ def available() -> bool:
HINTS: tuple[tuple[str, str], ...] = ( HINTS: tuple[tuple[str, str], ...] = (
( (
"NT_STATUS_LOGON_FAILURE", "NT_STATUS_LOGON_FAILURE",
"Benutzername oder Passwort wurden vom Windows-Geraet abgewiesen. Bei einem " "Benutzername oder Passwort wurden vom Windows-Gerät abgewiesen. Bei einem "
"Domaenenkonto die Form DOMAENE\\\\benutzer verwenden.", "Domänenkonto die Form DOMAENE\\benutzer verwenden – mit *einem* "
"Backslash.",
), ),
( (
"NT_STATUS_ACCESS_DENIED", "NT_STATUS_ACCESS_DENIED",
"Das Konto darf auf diesem Geraet keinen Neustart ausloesen. Es braucht das Recht " "Das Konto darf auf diesem Gerät keinen Neustart auslösen. Es braucht das Recht "
"'Herunterfahren von einem Remotesystem aus' -- Mitglied der lokalen " "'Herunterfahren von einem Remotesystem aus' | Mitglied der lokalen "
"Administratoren genuegt dafuer in der Regel.", "Administratoren genügt dafür in der Regel.",
), ),
( (
"NT_STATUS_CONNECTION_REFUSED", "NT_STATUS_CONNECTION_REFUSED",
"Das Geraet nimmt keine RPC-Verbindung an. Die Datei- und Druckerfreigabe muss " "Das Gerät nimmt keine RPC-Verbindung an. Die Datei- und Druckerfreigabe muss "
"aktiv und Port 445 in der Windows-Firewall freigegeben sein.", "aktiv und Port 445 in der Windows-Firewall freigegeben sein.",
), ),
( (
"NT_STATUS_IO_TIMEOUT", "NT_STATUS_IO_TIMEOUT",
"Keine Antwort auf Port 445. Ist das Geraet erreichbar und die Firewall offen?", "Keine Antwort auf Port 445. Ist das Gerät erreichbar und die Firewall offen?",
), ),
( (
"NT_STATUS_HOST_UNREACHABLE", "NT_STATUS_HOST_UNREACHABLE",
"Das Geraet ist nicht erreichbar.", "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.",
), ),
) )
@@ -83,32 +102,54 @@ def hint_for(output: str) -> str:
return "" return ""
def reboot( def split_account(username: str, domain: str = "") -> tuple[str, str]:
host: 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, username: str,
password: str, password: str,
*, domain: str,
domain: str = "", timeout: int,
comment: str = "Neustart durch TESM-Wartung", ) -> RemoteResult | tuple[int, str]:
delay: int = 5, """Ruft ``net`` mit einer 0600-Anmeldedatei auf und raeumt sie sicher weg.
timeout: int = DEFAULT_TIMEOUT,
) -> RemoteResult: Gemeinsam fuer Neustart und Pruefung -- die Datei traegt ein
"""Loest einen Neustart auf einem Windows-Geraet aus.""" 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") binary = shutil.which("net")
if binary is None: if binary is None:
return RemoteResult( return RemoteResult(
False, False,
"", "",
f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' laesst sich " f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' lässt sich "
"ein Windows-Geraet von hier nicht neu starten.", "ein Windows-Gerät von hier nicht ansprechen.",
) )
if not username: if not username:
return RemoteResult(False, "", "Fuer das Geraet sind keine Zugangsdaten hinterlegt.") return RemoteResult(False, "", "Es sind keine Zugangsdaten hinterlegt.")
# Ein Benutzer der Form DOMAENE\benutzer bringt seine Domaene selbst mit.
if "\\" in username and not domain:
domain, username = username.split("\\", 1)
username, domain = split_account(username, domain)
handle, path = tempfile.mkstemp(prefix="tesm-winauth-") handle, path = tempfile.mkstemp(prefix="tesm-winauth-")
try: try:
# mkstemp legt die Datei auf POSIX bereits mit 0600 an. Hier trotzdem # mkstemp legt die Datei auf POSIX bereits mit 0600 an. Hier trotzdem
@@ -123,34 +164,22 @@ def reboot(
if domain: if domain:
stream.write(f"domain={domain}\n") stream.write(f"domain={domain}\n")
command = [ command = [binary, *args, "--authentication-file", path]
binary,
"rpc",
"shutdown",
"-r", # neu starten statt herunterfahren
"-f", # laufende Anwendungen nicht blockieren lassen
"-t",
str(max(0, int(delay))),
"-C",
comment,
"-I",
host,
"--authentication-file",
path,
]
try: try:
completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell
command, capture_output=True, text=True, timeout=timeout, check=False command,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
check=False,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return RemoteResult(False, "", f"Zeitlimit von {timeout}s ueberschritten.") return RemoteResult(False, "", f"Zeitlimit von {timeout}s überschritten.")
except OSError as exc: except OSError as exc:
return RemoteResult(False, "", str(exc)) return RemoteResult(False, "", str(exc))
return completed.returncode, (completed.stdout + completed.stderr).strip()
output = (completed.stdout + completed.stderr).strip()
if completed.returncode == 0:
return RemoteResult(True, output or "Neustart ausgeloest.")
return RemoteResult(False, output, hint_for(output))
finally: finally:
# Ueberschreiben, dann loeschen -- die Datei enthaelt ein Klartextpasswort. # Ueberschreiben, dann loeschen -- die Datei enthaelt ein Klartextpasswort.
try: try:
@@ -161,8 +190,90 @@ def reboot(
stream.flush() stream.flush()
os.fsync(stream.fileno()) os.fsync(stream.fileno())
except OSError: # pragma: no cover - defensiv except OSError: # pragma: no cover - defensiv
logger.warning("Anmeldedatei konnte nicht ueberschrieben werden: %s", path) logger.warning("Anmeldedatei konnte nicht überschrieben werden: %s", path)
try: try:
os.unlink(path) os.unlink(path)
except OSError: # pragma: no cover - defensiv except OSError: # pragma: no cover - defensiv
pass 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))
+69 -23
View File
@@ -12,8 +12,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
"monitor_interval_seconds", "monitor_interval_seconds",
300, 300,
"int", "int",
"Pruefintervall (Sekunden)", "Prüfintervall (Sekunden)",
hint="Abstand zwischen zwei Erreichbarkeitspruefungen. Lizenzpflichtig.", hint="Abstand zwischen zwei Erreichbarkeitsprüfungen. Lizenzpflichtig.",
section="monitoring", section="monitoring",
minimum=30, minimum=30,
maximum=86400, maximum=86400,
@@ -23,8 +23,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
2, 2,
"int", "int",
"Fehlversuche bis zum automatischen PoE-Neustart", "Fehlversuche bis zum automatischen PoE-Neustart",
hint="Erst nach so vielen aufeinanderfolgenden Fehlschlaegen wird geschaltet. " hint="Erst nach so vielen aufeinanderfolgenden Fehlschlägen wird geschaltet. "
"Der Vorgaenger schaltete sofort beim ersten Ausfall.", "Der Vorgänger schaltete sofort beim ersten Ausfall.",
section="monitoring", section="monitoring",
minimum=1, minimum=1,
maximum=10, maximum=10,
@@ -40,11 +40,42 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
"dashboard_public", "dashboard_public",
True, True,
"bool", "bool",
"Statusuebersicht ohne Anmeldung erreichbar", "Statusübersicht ohne Anmeldung erreichbar",
hint="Zeigt ausschliesslich Name und Erreichbarkeit aktiver Geraete -- keine " hint="Zeigt ausschließlich Name und Erreichbarkeit aktiver Geräte – keine "
"IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.", "IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.",
section="monitoring", 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 --------------------------------------------------------------- # # -- DHCP --------------------------------------------------------------- #
SettingSpec("dhcp_domain", "local", "str", "Domainname", section="dhcp"), SettingSpec("dhcp_domain", "local", "str", "Domainname", section="dhcp"),
SettingSpec( SettingSpec(
@@ -74,20 +105,11 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
"fileshare_max_age_hours", "fileshare_max_age_hours",
12, 12,
"int", "int",
"Einbindungen automatisch loesen nach (Stunden)", "Einbindungen automatisch lösen nach (Stunden)",
section="fileshare", section="fileshare",
minimum=1, minimum=1,
maximum=168, maximum=168,
), ),
SettingSpec(
"fileshare_upload_max_mb",
512,
"int",
"Maximale Dateigroesse beim Hochladen (MB)",
section="fileshare",
minimum=1,
maximum=8192,
),
# -- Wartung ------------------------------------------------------------ # # -- Wartung ------------------------------------------------------------ #
SettingSpec( SettingSpec(
"maintenance_command_timeout", "maintenance_command_timeout",
@@ -102,26 +124,50 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
"maintenance_reboot_wait", "maintenance_reboot_wait",
300, 300,
"int", "int",
"Wartezeit auf die Rueckkehr nach einem Neustart (Sekunden)", "Wartezeit auf die Rückkehr nach einem Neustart (Sekunden)",
section="maintenance", section="maintenance",
minimum=30, minimum=30,
maximum=1800, maximum=1800,
), ),
# -- Terminal ----------------------------------------------------------- # # -- Unterschriften ------------------------------------------------------ #
SettingSpec( SettingSpec(
"terminal_enabled", "signature_witness_enabled",
True, True,
"bool", "bool",
"Browser-Terminal fuer Switche aktiv", "Unterschriften mit dem ablegenden Konto bezeugen",
hint="Auch wenn aktiv: erfordert das Recht 'Ausfuehren' auf Switche, eine frische " hint="Eine Zeile unter der **ersten** Unterschrift nennt das Konto, das das "
"Passwortbestaetigung und wird vollstaendig protokolliert.", "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", section="monitoring",
), ),
SettingSpec( SettingSpec(
"terminal_idle_timeout", "terminal_idle_timeout",
300, 300,
"int", "int",
"Terminal bei Untaetigkeit trennen nach (Sekunden)", "Terminal bei Untätigkeit trennen nach (Sekunden)",
section="monitoring", section="monitoring",
minimum=60, minimum=60,
maximum=3600, maximum=3600,
@@ -1,8 +1,8 @@
{# Teilbereich der Statusuebersicht -- wird auch einzeln per fetch geladen. #} {# Teilbereich der Statusübersicht -- wird auch einzeln per fetch geladen. #}
{% from "tesm_core/macros.html" import pill, empty_state %} {% from "tesm_core/macros.html" import pill, empty_state %}
{# Beide Bloecke in einen Stapel: sonst klebt die erste Gruppe an den {# Beide Blöcke in einen Stapel: sonst klebt die erste Gruppe an den
Filterkacheln, waehrend die Gruppen untereinander Abstand haben. #} Filterkacheln, während die Gruppen untereinander Abstand haben. #}
<div class="stack"> <div class="stack">
<div class="grid grid--4" data-behavior="tile-filter" data-filter-scope="tile-sections"> <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"> <button type="button" class="stat" data-filter-value="" aria-pressed="false">
@@ -25,7 +25,7 @@
</button> </button>
{% else %} {% else %}
<div class="stat stat--warning"> <div class="stat stat--warning">
<span class="stat__label">Noch nicht geprueft</span> <span class="stat__label">Noch nicht geprüft</span>
<span class="stat__value">{{ counts.unknown }}</span> <span class="stat__value">{{ counts.unknown }}</span>
</div> </div>
{% endif %} {% endif %}
@@ -49,9 +49,9 @@
<div class="grid grid--tiles"> <div class="grid grid--tiles">
{% for tile in section.tiles %} {% for tile in section.tiles %}
{# Kopf zuerst Kennzeichen, dann Name: das Auge findet die Kachel {# Kopf zuerst Kennzeichen, dann Name: das Auge findet die Kachel
ueber Farbe und Symbol, den genauen Namen liest es danach. über Farbe und Symbol, den genauen Namen liest es danach.
Die IP steht gross darunter statt in der Merkmalliste -- sie ist Die IP steht gross darunter statt in der Merkmalliste -- sie ist
die Angabe, nach der man auf dieser Seite tatsaechlich sucht. #} die Angabe, nach der man auf dieser Seite tatsächlich sucht. #}
<article class="tile tile--{{ tile.state }}" data-filter-item <article class="tile tile--{{ tile.state }}" data-filter-item
data-filter-text="{{ tile.name }} {{ tile.get('ip','') }} {{ tile.get('switch','') }} {{ tile.location }}"> data-filter-text="{{ tile.name }} {{ tile.get('ip','') }} {{ tile.get('switch','') }} {{ tile.location }}">
<div class="tile__head"> <div class="tile__head">
@@ -73,7 +73,7 @@
<dt>Switch</dt><dd class="truncate">{{ tile.switch }} / {{ tile.port }}</dd> <dt>Switch</dt><dd class="truncate">{{ tile.switch }} / {{ tile.port }}</dd>
{% endif %} {% endif %}
{% if tile.latency_ms %} {% if tile.latency_ms %}
<dt>Antwortzeit</dt><dd>{{ '%.1f' | format(tile.latency_ms) }} ms</dd> <dt>Antwortzeit</dt><dd>{{ tile.latency_ms | millis }}</dd>
{% endif %} {% endif %}
{% if tile.last_change_at %} {% if tile.last_change_at %}
<dt>Seit</dt> <dt>Seit</dt>
@@ -84,22 +84,7 @@
<a class="btn btn--sm btn--ghost" href="{{ url_for('devices.detail', device_id=tile.id) }}"> <a class="btn btn--sm btn--ghost" href="{{ url_for('devices.detail', device_id=tile.id) }}">
Details Details
</a> </a>
{% if current_user.has('devices.execute') and tile.restart_methods %} {% if current_user.has('devices.write') and tile.state == 'disabled' %}
{% set method = tile.restart_methods[0] %}
<form method="post" data-no-guard
action="{{ url_for('devices.restart_device', device_id=tile.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="method" value="{{ method.key }}">
<button class="btn btn--sm" type="submit" title="{{ method.hint }}"
data-busy-label="Startet ..."
data-confirm="{{ tile.name }} ueber {{ method.label }} neu starten? {{ method.hint }}"
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
data-confirm-style="primary">
{{ icon('power', size=14) }} {{ method.label }}
</button>
</form>
{% endif %}
{% if current_user.has('devices.edit') and tile.state == 'disabled' %}
<form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}" <form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}"
data-no-guard> data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -108,6 +93,40 @@
</form> </form>
{% endif %} {% endif %}
</div> </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 %} {% endif %}
</article> </article>
{% endfor %} {% endfor %}
@@ -118,8 +137,8 @@
{% endfor %} {% endfor %}
{% if not sections | map(attribute='tiles') | select | list %} {% if not sections | map(attribute='tiles') | select | list %}
{{ empty_state('Keine Geraete erfasst', {{ empty_state('Keine Geräte erfasst',
'Legen Sie das erste Geraet unter Ueberwachung an.', 'cpu') }} 'Legen Sie das erste Gerät unter Überwachung an.', 'cpu') }}
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -1 +1 @@
<pre class="log-view">{{ job.output or 'Noch keine Ausgabe.' }}</pre> <pre class="log-view" data-tail>{{ job.output or 'Noch keine Ausgabe.' }}</pre>
+25 -25
View File
@@ -1,14 +1,14 @@
{% extends "tesm_core/base.html" %} {% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, checkbox, alert, pill %} {% from "tesm_core/macros.html" import field, checkbox, alert, pill %}
{% block page_title %}Sicherung und Uebertragung{% endblock %} {% block page_title %}Sicherung und Übertragung{% endblock %}
{% block page_sub %}Verschluesselter Export &middot; zweistufiger Import{% endblock %} {% block page_sub %}Verschlüsselter Export &middot; zweistufiger Import{% endblock %}
{% block content %} {% block content %}
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %} {% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschluesselt (AES-256-GCM). Ohne {{ 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 Hintertuer. Bewahren Sie diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertür. Bewahren Sie
sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }} sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
<div class="grid grid--split"> <div class="grid grid--split">
@@ -16,7 +16,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('download', size=17) }} Sicherung erstellen</div> <div class="card__title">{{ icon('download', size=17) }} Sicherung erstellen</div>
<div class="card__sub">Auswahl je Kategorie &middot; verlangt eine frische Passwortbestaetigung</div> <div class="card__sub">Auswahl je Kategorie &middot; verlangt eine frische Passwortbestätigung</div>
</div> </div>
</div> </div>
<form method="post" action="{{ url_for('backup.export') }}" data-no-guard> <form method="post" action="{{ url_for('backup.export') }}" data-no-guard>
@@ -37,7 +37,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
{% if category.admin_only %}{{ pill('nur Admin', 'accent') }}{% endif %} {% if category.admin_only %}{{ pill('nur Admin', 'accent') }}{% endif %}
<div class="fs-xs faint">{{ category.hint }}</div> <div class="fs-xs faint">{{ category.hint }}</div>
{% if not allowed %} {% if not allowed %}
<div class="fs-xs danger-text">Fuer diese Kategorie fehlt Ihnen das Recht.</div> <div class="fs-xs danger-text">Für diese Kategorie fehlt Ihnen das Recht.</div>
{% endif %} {% endif %}
</span> </span>
</label> </label>
@@ -46,9 +46,9 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
</fieldset> </fieldset>
{% if can_secrets %} {% if can_secrets %}
{{ checkbox('include_secrets', 'Passwoerter der Zugangsdaten mit exportieren', {{ checkbox('include_secrets', 'Passwörter der Zugangsdaten mit exportieren',
hint_text='Sie liegen dann im Klartext innerhalb der verschluesselten hint_text='Sie liegen dann im Klartext innerhalb der verschlüsselten
Datei. Ohne dieses Kennzeichen enthaelt die Sicherung nur Namen und Datei. Ohne dieses Kennzeichen enthält die Sicherung nur Namen und
Benutzer.') }} Benutzer.') }}
{% endif %} {% endif %}
@@ -73,9 +73,9 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
<section class="card card--accent"> <section class="card card--accent">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Geoeffnete Sicherung</div> <div class="card__title">{{ icon('upload', size=17) }} Geöffnete Sicherung</div>
<div class="card__sub"> <div class="card__sub">
Waehlen Sie, was uebernommen werden soll. Gueltig fuer {{ preview_ttl }} Minuten. Wählen Sie, was übernommen werden soll. Gültig für {{ preview_ttl }} Minuten.
</div> </div>
</div> </div>
</div> </div>
@@ -84,7 +84,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
<div class="card__body stack"> <div class="card__body stack">
<div class="table-wrap"> <div class="table-wrap">
<table class="table table--compact"> <table class="table table--compact">
<thead><tr><th></th><th>Kategorie</th><th class="numeric">Eintraege</th></tr></thead> <thead><tr><th></th><th>Kategorie</th><th class="numeric">Einträge</th></tr></thead>
<tbody> <tbody>
{% for entry in preview.summary %} {% for entry in preview.summary %}
<tr> <tr>
@@ -96,7 +96,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
</td> </td>
<td> <td>
{{ entry.label }} {{ entry.label }}
{% if entry.has_secrets %}{{ pill('mit Passwoertern', 'danger') }}{% endif %} {% if entry.has_secrets %}{{ pill('mit Passwörtern', 'danger') }}{% endif %}
</td> </td>
<td class="numeric">{{ entry.count }}</td> <td class="numeric">{{ entry.count }}</td>
</tr> </tr>
@@ -104,17 +104,17 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
</tbody> </tbody>
</table> </table>
</div> </div>
{{ alert('Bestehende Eintraege werden nach ihrem natuerlichen Schluessel abgeglichen {{ alert('Bestehende Einträge werden nach ihrem natürlichen Schlüssel abgeglichen
(Name, Hostname, MAC, Benutzername) und aktualisiert. Eintraege, die im Papierkorb (Name, Hostname, MAC, Benutzername) und aktualisiert. Einträge, die im Papierkorb
liegen, werden nicht wiederbelebt.', 'info') }} liegen, werden nicht wiederbelebt.', 'info') }}
</div> </div>
<div class="card__footer modal__footer--split"> <div class="card__footer modal__footer--split">
<button class="btn btn--sm btn--ghost" type="submit" <button class="btn btn--sm btn--ghost" type="submit"
formaction="{{ url_for('backup.import_discard') }}">Verwerfen</button> formaction="{{ url_for('backup.import_discard') }}">Verwerfen</button>
<button class="btn btn--primary" type="submit" <button class="btn btn--primary" type="submit"
data-confirm="Ausgewaehlte Kategorien jetzt uebernehmen? Bestehende Daten werden aktualisiert." data-confirm="Ausgewählte Kategorien jetzt übernehmen? Bestehende Daten werden aktualisiert."
data-confirm-title="Sicherung uebernehmen" data-confirm-ok="Uebernehmen" data-confirm-title="Sicherung übernehmen" data-confirm-ok="Übernehmen"
data-confirm-style="primary">Uebernehmen</button> data-confirm-style="primary">Übernehmen</button>
</div> </div>
</form> </form>
</section> </section>
@@ -123,7 +123,7 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Sicherung einspielen</div> <div class="card__title">{{ icon('upload', size=17) }} Sicherung einspielen</div>
<div class="card__sub">Erst oeffnen und pruefen, dann uebernehmen</div> <div class="card__sub">Erst öffnen und prüfen, dann übernehmen</div>
</div> </div>
</div> </div>
<form method="post" enctype="multipart/form-data" data-no-guard <form method="post" enctype="multipart/form-data" data-no-guard
@@ -135,8 +135,8 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
autocomplete='off') }} autocomplete='off') }}
</div> </div>
<div class="card__footer"> <div class="card__footer">
<button class="btn btn--primary" type="submit" data-busy-label="Oeffne ..."> <button class="btn btn--primary" type="submit" data-busy-label="Öffne ...">
Datei oeffnen Datei öffnen
</button> </button>
</div> </div>
</form> </form>
@@ -149,10 +149,10 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
</div> </div>
<div class="card__body"> <div class="card__body">
<ul class="stack stack--tight fs-sm muted"> <ul class="stack stack--tight fs-sm muted">
<li>Verzeichniskonten werden nie exportiert -- sie entstehen am Ziel bei der ersten Anmeldung neu.</li> <li>Verzeichniskonten werden nie exportiert – sie entstehen am Ziel bei der ersten Anmeldung neu.</li>
<li>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehoeren zur Maschine, nicht zur Sicherung.</li> <li>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehören zur Maschine, nicht zur Sicherung.</li>
<li>Sitzungen, Ratenbegrenzung und das Aenderungsprotokoll sind nicht Teil der 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> <li>Der Zwischenstand des Imports liegt in der Datenbank – ein Dienst-Neustall verliert ihn nicht.</li>
</ul> </ul>
</div> </div>
</section> </section>
@@ -18,21 +18,21 @@
{% block content %} {% block content %}
{% if not host_key_ready %} {% if not host_key_ready %}
{{ alert('Fuer dieses Ziel ist kein SSH-Host-Schluessel freigegeben. Die Konsole verweigert {{ 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.', den Aufbau, bis das geschehen ist -- die Freigabe erfolgt auf der Detailseite.',
'danger', 'Host-Schluessel fehlt') }} 'danger', 'Host-Schlüssel fehlt') }}
<p><a class="btn btn--primary" href="{{ back_url }}">Zur Detailseite und Schluessel freigeben</a></p> <p><a class="btn btn--primary" href="{{ back_url }}">Zur Detailseite und Schlüssel freigeben</a></p>
{% endif %} {% endif %}
{{ alert('Die Konsole ist zeilenorientiert: ein Kommando pro Zeile, Antwort darunter. Jede {{ alert('Die Konsole ist zeilenorientiert: ein Kommando pro Zeile, Antwort darunter. Jede
Eingabe und jede Ausgabe wird auf dem Server mitgeschrieben und im Aenderungsprotokoll Eingabe und jede Ausgabe wird auf dem Server mitgeschrieben und im Änderungsprotokoll
vermerkt. Bei Untaetigkeit trennt die Verbindung nach ' ~ idle_timeout ~ ' Sekunden.', vermerkt. Bei Untätigkeit trennt die Verbindung nach ' ~ idle_timeout ~ ' Sekunden.',
'warning', 'Mitschrift') }} 'warning', 'Mitschrift') }}
{% if not is_switch %} {% if not is_switch %}
{{ alert('Jedes Kommando laeuft in einer eigenen Sitzung. Das heisst: ein "cd" wirkt nicht {{ alert('Jedes Kommando läuft in einer eigenen Sitzung. Das heisst: ein "cd" wirkt nicht
auf das naechste Kommando, und interaktive Programme (Editoren, Passwortabfragen) laufen auf das nächste Kommando, und interaktive Programme (Editoren, Passwortabfragen) laufen
hier nicht. Fuer solche Arbeiten einen richtigen SSH-Client nehmen.', 'info', hier nicht. Für solche Arbeiten einen richtigen SSH-Client nehmen.', 'info',
'Ein Kommando, eine Sitzung') }} 'Ein Kommando, eine Sitzung') }}
{% endif %} {% endif %}
@@ -49,7 +49,7 @@ vermerkt. Bei Untaetigkeit trennt die Verbindung nach ' ~ idle_timeout ~ ' Sekun
{{ icon('copy', size=14) }} Mitschrift kopieren {{ icon('copy', size=14) }} Mitschrift kopieren
</button> </button>
</div> </div>
<pre class="log-view" id="console-output" style="max-height:56dvh"></pre> <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)"> <div class="card__footer" style="justify-content:stretch;gap:var(--sp-2)">
<select class="select" id="console-preset" style="max-width:280px"> <select class="select" id="console-preset" style="max-width:280px">
<option value="">Kommando aus der Liste ...</option> <option value="">Kommando aus der Liste ...</option>
@@ -8,8 +8,8 @@
</nav> </nav>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{{ alert('Dieser Aufruf steht mit Zeitpunkt, Benutzer und IP im Aenderungsprotokoll. {{ alert('Dieser Aufruf steht mit Zeitpunkt, Benutzer und IP im Änderungsprotokoll.
Schliessen Sie die Seite, sobald Sie den Wert uebernommen haben.', 'warning') }} Schliessen Sie die Seite, sobald Sie den Wert übernommen haben.', 'warning') }}
<section class="card" style="max-width:560px"> <section class="card" style="max-width:560px">
<div class="card__header"> <div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('key', size=17) }} {{ entry.name }}</div> <div class="flex-1"><div class="card__title">{{ icon('key', size=17) }} {{ entry.name }}</div>
@@ -5,9 +5,9 @@
{% macro credential_form(entry=None) %} {% macro credential_form(entry=None) %}
<div class="form-grid"> <div class="form-grid">
{{ field('name', 'Name', value=entry.name if entry else '', required=True, {{ field('name', 'Name', value=entry.name if entry else '', required=True,
hint_text='Frei waehlbar, z. B. "Switch-Admin" oder "Linux-Wartung".') }} hint_text='Frei wählbar, z. B. "Switch-Admin" oder "Linux-Wartung".') }}
{{ field('username', 'Benutzer', value=entry.username if entry else '', autocomplete='off') }} {{ field('username', 'Benutzer', value=entry.username if entry else '', autocomplete='off') }}
{{ field('secret', 'Passwort' ~ (' (leer lassen = unveraendert)' if entry else ''), {{ field('secret', 'Passwort' ~ (' (leer lassen = unverändert)' if entry else ''),
type='password', autocomplete='new-password', type='password', autocomplete='new-password',
required=False if entry else True) }} required=False if entry else True) }}
<label class="field"> <label class="field">
@@ -24,7 +24,7 @@
{% endmacro %} {% endmacro %}
{% block page_title %}Zugangsdaten{% endblock %} {% block page_title %}Zugangsdaten{% endblock %}
{% block page_sub %}{{ credentials | length }} Eintraege &middot; verschluesselt mit AES-256-GCM{% endblock %} {% block page_sub %}{{ credentials | length }} Einträge &middot; verschlüsselt mit AES-256-GCM{% endblock %}
{% block topbar_extra %} {% block topbar_extra %}
{{ search_box('credentials-table', 'Name, Benutzer ...') }} {{ search_box('credentials-table', 'Name, Benutzer ...') }}
@@ -36,10 +36,10 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{{ alert('Passwoerter liegen AES-256-GCM-verschluesselt und an ihren Verwendungszweck gebunden. {{ 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 Passwortbestaetigung und wird Sie im Klartext zu sehen braucht ein eigenes Recht, eine frische Passwortbestätigung und wird
protokolliert. Die Kategorie setzen Sie ausdruecklich -- sie wird bewusst nicht geraten, weil protokolliert. Die Kategorie setzen Sie ausdrücklich -- sie wird bewusst nicht geraten, weil
davon abhaengt, welche Geraete auf der Wartungsseite erscheinen.', 'info') }} davon abhängt, welche Geräte auf der Wartungsseite erscheinen.', 'info') }}
<section class="card"> <section class="card">
<div class="table-wrap"> <div class="table-wrap">
@@ -79,7 +79,7 @@ davon abhaengt, welche Geraete auf der Wartungsseite erscheinen.', 'info') }}
data-confirm-style="primary">{{ icon('eye', size=15) }}</button> data-confirm-style="primary">{{ icon('eye', size=15) }}</button>
</form> </form>
{% endif %} {% endif %}
{% if current_user.has('credentials.edit') %} {% if current_user.has('credentials.write') %}
<button class="btn btn--sm btn--ghost" type="button" <button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="credential-edit-{{ entry.id }}" aria-label="Bearbeiten"> data-dialog-open="credential-edit-{{ entry.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }} {{ icon('pencil', size=15) }}
@@ -89,10 +89,10 @@ davon abhaengt, welche Geraete auf der Wartungsseite erscheinen.', 'info') }}
<form method="post" action="{{ url_for('credentials.delete', credential_id=entry.id) }}" <form method="post" action="{{ url_for('credentials.delete', credential_id=entry.id) }}"
style="display:inline" data-no-guard> style="display:inline" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Loeschen" <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 }} Eintraegen verwendet"{% endif %} {% 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="{{ entry.name }} in den Papierkorb verschieben?"
data-confirm-title="Zugangsdaten loeschen" data-confirm-ok="Loeschen"> data-confirm-title="Zugangsdaten löschen" data-confirm-ok="Löschen">
{{ icon('trash', size=15) }}</button> {{ icon('trash', size=15) }}</button>
</form> </form>
{% endif %} {% endif %}
@@ -121,7 +121,7 @@ davon abhaengt, welche Geraete auf der Wartungsseite erscheinen.', 'info') }}
</form> </form>
{% endcall %} {% endcall %}
{% endif %} {% endif %}
{% if current_user.has('credentials.edit') %} {% if current_user.has('credentials.write') %}
{% for entry in credentials %} {% for entry in credentials %}
{% call modal('credential-edit-' ~ entry.id, entry.name) %} {% call modal('credential-edit-' ~ entry.id, entry.name) %}
<form method="post" action="{{ url_for('credentials.update', credential_id=entry.id) }}"> <form method="post" action="{{ url_for('credentials.update', credential_id=entry.id) }}">
@@ -1,22 +1,17 @@
{% extends "tesm_core/base.html" %} {% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import search_box %} {% from "tesm_core/macros.html" import search_box %}
{% block page_title %}Statusuebersicht{% endblock %} {% block page_title %}Statusübersicht{% endblock %}
{% block page_sub %} {% block page_sub %}
{{ counts.total }} Geraete &middot; Pruefintervall {{ interval }} s {{ counts.total }} Geräte &middot; Prüfintervall {{ interval }} s
{% if not detailed %}&middot; oeffentliche Ansicht{% endif %} {% if not detailed %}&middot; öffentliche Ansicht{% endif %}
{% endblock %} {% endblock %}
{% block topbar_extra %} {% block topbar_extra %}
{{ search_box('tile-sections', 'Geraet suchen ...') }} {{ search_box('tile-sections', 'Gerät suchen ...') }}
{% if current_user.has('settings_system.execute') %} {# Der Knopf "Jetzt prüfen" sass hier ein zweites Mal: die Timer-Pille in
<form method="post" action="{{ url_for('settings.run_check_now') }}" data-no-guard> der Topbar trägt ihn jetzt, und dort gehört er hin -- er setzt genau die
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> Angabe zurück, die daneben steht. #}
<button class="btn btn--sm" type="submit" title="Sofort pruefen">
{{ icon('refresh', size=15) }} Jetzt pruefen
</button>
</form>
{% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -11,12 +11,18 @@
{% endblock %} {% endblock %}
{% block topbar_extra %} {% block topbar_extra %}
{% if terminal_enabled and current_user.has('maintenance.execute') and device.credential_id %} {% 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) }}"> <a class="btn btn--sm" href="{{ url_for('terminal.device_console', device_id=device.id) }}">
{{ icon('terminal', size=15) }} Konsole {{ icon('terminal', size=15) }} Konsole
</a> </a>
{% endif %} {% endif %}
{% if current_user.has('devices.execute') and restart_methods %} {% 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 %} {% for method in restart_methods %}
<form method="post" style="display:inline" <form method="post" style="display:inline"
action="{{ url_for('devices.restart_device', device_id=device.id) }}" data-no-guard> action="{{ url_for('devices.restart_device', device_id=device.id) }}" data-no-guard>
@@ -24,7 +30,7 @@
<input type="hidden" name="method" value="{{ method.key }}"> <input type="hidden" name="method" value="{{ method.key }}">
<button class="btn btn--sm {{ 'btn--primary' if loop.first else 'btn--ghost' }}" <button class="btn btn--sm {{ 'btn--primary' if loop.first else 'btn--ghost' }}"
type="submit" title="{{ method.hint }}" data-busy-label="Startet ..." type="submit" title="{{ method.hint }}" data-busy-label="Startet ..."
data-confirm="{{ device.name }} ueber {{ method.label }} neu starten? {{ method.hint }}" data-confirm="{{ device.name }} über {{ method.label }} neu starten? {{ method.hint }}"
data-confirm-title="Neustart" data-confirm-ok="Neu starten" data-confirm-title="Neustart" data-confirm-ok="Neu starten"
data-confirm-style="primary"> data-confirm-style="primary">
{{ icon('power', size=15) }} {{ method.label }} {{ icon('power', size=15) }} {{ method.label }}
@@ -36,7 +42,7 @@
{% block content %} {% block content %}
{% if device.deleted_at %} {% if device.deleted_at %}
{{ alert('Dieses Geraet liegt im Papierkorb (geloescht am ' ~ (device.deleted_at | dt) ~ ').', {{ alert('Dieses Gerät liegt im Papierkorb (gelöscht am ' ~ (device.deleted_at | dt) ~ ').',
'warning') }} 'warning') }}
{% endif %} {% endif %}
@@ -58,8 +64,8 @@
<div class="grid grid--4"> <div class="grid grid--4">
<div class="stat"> <div class="stat">
<span class="stat__label">Antwortzeit</span> <span class="stat__label">Antwortzeit</span>
<span class="stat__value">{{ '%.1f' | format(device.latency_ms) if device.latency_ms else '&ndash;' | safe }}</span> <span class="stat__value">{{ device.latency_ms | millis if device.latency_ms else '&ndash;' | safe }}</span>
<span class="stat__hint">Millisekunden</span> <span class="stat__hint">letzte Messung</span>
</div> </div>
<div class="stat {{ 'stat--danger' if device.fail_count else '' }}"> <div class="stat {{ 'stat--danger' if device.fail_count else '' }}">
<span class="stat__label">Fehlversuche</span> <span class="stat__label">Fehlversuche</span>
@@ -88,37 +94,41 @@
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('power', size=17) }} Neustarts</div> <div class="card__title">{{ icon('power', size=17) }} Neustarts</div>
<div class="card__sub"> <div class="card__sub">
Die letzten {{ events | length }} Neustarts -- ueber den PoE-Port wie per SSH Die letzten {{ events | length }} Neustarts – über den PoE-Port wie per SSH
</div> </div>
</div> </div>
</div> </div>
{% if events %} {% if events %}
<div class="table-wrap"> <div class="table-wrap">
<table class="table table--compact"> <table class="table table--compact">
<thead><tr><th>Zeit</th><th>Methode</th><th>Ausloeser</th><th>Ergebnis</th><th class="numeric">Dauer</th><th>Detail</th></tr></thead> <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> <tbody>
{% for event in events %} {% for event in events %}
<tr> <tr>
<td class="fs-sm nowrap">{{ event.ts | dt }}</td> <td class="fs-sm nowrap">{{ event.ts | dt }}</td>
<td class="fs-sm"> <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') }} {% if event.method == 'ssh' %}{{ pill('SSH', 'info') }}
{% else %}{{ pill('PoE-Port', '') }}{% endif %} {% elif event.method == 'rpc' %}{{ pill('RPC', 'accent') }}
{% elif event.method == 'pö' %}{{ pill('PoE-Port', '') }}
{% else %}{{ pill(event.method or 'unbekannt', 'warning') }}{% endif %}
</td> </td>
<td class="fs-sm">{{ event.trigger }} <span class="faint">/ {{ event.actor }}</span></td> <td class="fs-sm">{{ event.trigger }} <span class="faint">/ {{ event.actor }}</span></td>
<td> <td>
{% if event.result == 'ok' %}{{ pill('erfolgreich', 'online') }} {% if event.result == 'ok' %}{{ pill('erfolgreich', 'online') }}
{% elif event.result == 'skipped' %}{{ pill('uebersprungen', 'warning') }} {% elif event.result == 'skipped' %}{{ pill('übersprungen', 'warning') }}
{% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %} {% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %}
</td> </td>
<td class="numeric fs-sm">{{ event.duration_ms or 0 }} ms</td> <td class="numeric fs-sm nowrap">{{ event.duration_ms | millis }}</td>
<td class="fs-xs muted break-all">{{ event.detail }}</td> <td class="fs-xs muted break-all">{{ event.detail | audit_detail }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% else %} {% else %}
{{ empty_state('Noch keine Neustarts', 'Dieses Geraet wurde noch nie neu gestartet.', 'power') }} {{ empty_state('Noch keine Neustarts', 'Dieses Gerät wurde noch nie neu gestartet.', 'power') }}
{% endif %} {% endif %}
</section> </section>
@@ -126,15 +136,15 @@
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title"> <div class="card__title">
{{ icon('shield', size=17) }} SSH-Host-Schluessel {{ icon('shield', size=17) }} SSH-Host-Schlüssel
{% if host_keys %}{{ pill('freigegeben', 'online') }} {% if host_keys %}{{ pill('freigegeben', 'online') }}
{% else %}{{ pill('offen', 'warning') }}{% endif %} {% else %}{{ pill('offen', 'warning') }}{% endif %}
</div> </div>
<div class="card__sub"> <div class="card__sub">
Ohne freigegebenen Schluessel verweigern Wartung und Konsole die Verbindung. Ohne freigegebenen Schlüssel verweigern Wartung und Konsole die Verbindung.
</div> </div>
</div> </div>
{% if current_user.has('devices.edit') %} {% if current_user.has('devices.write') %}
<form method="post" action="{{ url_for('devices.probe_host_key', device_id=device.id) }}" <form method="post" action="{{ url_for('devices.probe_host_key', device_id=device.id) }}"
data-no-guard> data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -147,15 +157,15 @@
<div class="card__body stack"> <div class="card__body stack">
{% set probed_type = request.args.get('probed_type') %} {% set probed_type = request.args.get('probed_type') %}
{% set probed_fingerprint = request.args.get('probed_fingerprint') %} {% set probed_fingerprint = request.args.get('probed_fingerprint') %}
{% if probed_type and probed_fingerprint and current_user.has('devices.edit') %} {% if probed_type and probed_fingerprint and current_user.has('devices.write') %}
<div class="alert alert--warning"> <div class="alert alert--warning">
{{ icon('alert', size=18) }} {{ icon('alert', size=18) }}
<div class="alert__body"> <div class="alert__body">
<div class="alert__title">Gelesener Schluessel</div> <div class="alert__title">Gelesener Schlüssel</div>
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div> <div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
<p class="fs-sm mt-2"> <p class="fs-sm mt-2">
Vergleichen Sie den Fingerprint mit einer unabhaengigen Quelle (Konsole am Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle (Konsole am
Geraet, Inventardokumentation), bevor Sie ihn freigeben. Gerät, Inventardokumentation), bevor Sie ihn freigeben.
</p> </p>
<form method="post" class="mt-2" <form method="post" class="mt-2"
action="{{ url_for('devices.trust_host_key', device_id=device.id) }}"> action="{{ url_for('devices.trust_host_key', device_id=device.id) }}">
@@ -163,8 +173,8 @@
<input type="hidden" name="key_type" value="{{ probed_type }}"> <input type="hidden" name="key_type" value="{{ probed_type }}">
<input type="hidden" name="fingerprint" value="{{ probed_fingerprint }}"> <input type="hidden" name="fingerprint" value="{{ probed_fingerprint }}">
<button class="btn btn--sm btn--primary" type="submit" <button class="btn btn--sm btn--primary" type="submit"
data-confirm="Diesen Fingerprint dauerhaft als vertrauenswuerdig hinterlegen?" data-confirm="Diesen Fingerprint dauerhaft als vertrauenswürdig hinterlegen?"
data-confirm-title="Host-Schluessel freigeben" data-confirm-ok="Freigeben" data-confirm-title="Host-Schlüssel freigeben" data-confirm-ok="Freigeben"
data-confirm-style="primary">Freigeben</button> data-confirm-style="primary">Freigeben</button>
</form> </form>
</div> </div>
@@ -178,21 +188,21 @@
<dd class="mono fs-xs break-all">{{ key.fingerprint }}</dd> <dd class="mono fs-xs break-all">{{ key.fingerprint }}</dd>
{% endfor %} {% endfor %}
</dl> </dl>
{% if current_user.has('devices.edit') %} {% if current_user.has('devices.write') %}
<form method="post" <form method="post"
action="{{ url_for('devices.forget_host_key', device_id=device.id) }}" action="{{ url_for('devices.forget_host_key', device_id=device.id) }}"
data-no-guard> data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost" type="submit" <button class="btn btn--sm btn--ghost" type="submit"
data-confirm="Gespeicherte Host-Schluessel dieses Geraets entfernen? Danach muss der Schluessel erneut freigegeben werden." data-confirm="Gespeicherte Host-Schlüssel dieses Geräts entfernen? Danach muss der Schlüssel erneut freigegeben werden."
data-confirm-title="Schluessel verwerfen" data-confirm-ok="Verwerfen"> data-confirm-title="Schlüssel verwerfen" data-confirm-ok="Verwerfen">
Verwerfen Verwerfen
</button> </button>
</form> </form>
{% endif %} {% endif %}
{% else %} {% else %}
<p class="muted fs-sm"> <p class="muted fs-sm">
Noch kein Schluessel hinterlegt. Erst lesen, Fingerprint pruefen, dann freigeben. Noch kein Schlüssel hinterlegt. Erst lesen, Fingerprint prüfen, dann freigeben.
</p> </p>
{% endif %} {% endif %}
</div> </div>
@@ -202,7 +212,7 @@
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Stammdaten</div></div> <div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Stammdaten</div></div>
{% if current_user.has('devices.edit') %} {% if current_user.has('devices.write') %}
<a class="btn btn--sm" href="{{ url_for('devices.overview') }}">Bearbeiten</a> <a class="btn btn--sm" href="{{ url_for('devices.overview') }}">Bearbeiten</a>
{% endif %} {% endif %}
</div> </div>
@@ -221,20 +231,43 @@
<dt>Kategorie</dt><dd>{{ device.category or '&ndash;' | safe }}</dd> <dt>Kategorie</dt><dd>{{ device.category or '&ndash;' | safe }}</dd>
<dt>Standort</dt><dd>{{ device.location or '&ndash;' | safe }}</dd> <dt>Standort</dt><dd>{{ device.location or '&ndash;' | safe }}</dd>
<dt>Zugangsdaten</dt><dd>{{ device.credential_name or 'keine' }}</dd> <dt>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>Automatischer Neustart</dt><dd>{{ device.auto_restart | yes_no }}</dd>
<dt>Angelegt</dt><dd>{{ device.created_at | dt }}</dd> <dt>Angelegt</dt><dd>{{ device.created_at | dt }}</dd>
<dt>Geaendert</dt> <dt>Geändert</dt>
<dd>{{ device.updated_at | dt }} <span class="faint">von {{ device.updated_by or '-' }}</span></dd> <dd>{{ device.updated_at | dt }} <span class="faint">von {{ device.updated_by or '-' }}</span></dd>
</dl> </dl>
{% if device.notes %}<p class="fs-sm muted mt-3">{{ device.notes }}</p>{% endif %} {% if device.notes %}<p class="fs-sm muted mt-3">{{ device.notes }}</p>{% endif %}
</div> </div>
{% if current_user.has('devices.edit') %} {% if current_user.has('devices.write') %}
<div class="card__footer"> <div class="card__footer">
<form method="post" action="{{ url_for('devices.toggle_active', device_id=device.id) }}" data-no-guard> <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="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="active" value="{{ '0' if device.is_active else '1' }}"> <input type="hidden" name="active" value="{{ '0' if device.is_active else '1' }}">
<button class="btn btn--sm" type="submit"> <button class="btn btn--sm" type="submit">
{{ 'Ueberwachung aussetzen' if device.is_active else 'Ueberwachung aktivieren' }} {{ 'Überwachung aussetzen' if device.is_active else 'Überwachung aktivieren' }}
</button> </button>
</form> </form>
</div> </div>
+29 -28
View File
@@ -4,10 +4,10 @@
{# Neustart-Bedienelement. {# Neustart-Bedienelement.
Ein einziger moeglicher Weg: ein Knopf, der ihn benennt. Mehrere: ein Knopf Ein einziger möglicher Weg: ein Knopf, der ihn benennt. Mehrere: ein Knopf
fuer den bevorzugten Weg (PoE, weil er auch bei haengendem Betriebssystem 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 wirkt) und daneben die Auswahl. Kein Weg: gar nichts -- ein Knopf, der
verlaesslich nichts bewirkt, ist schlimmer als kein Knopf. #} verlässlich nichts bewirkt, ist schlimmer als kein Knopf. #}
{% macro restart_control(device, methods, compact=False) %} {% macro restart_control(device, methods, compact=False) %}
{% if methods %} {% if methods %}
{% set first = methods[0] %} {% set first = methods[0] %}
@@ -17,7 +17,7 @@
<input type="hidden" name="method" value="{{ first.key }}"> <input type="hidden" name="method" value="{{ first.key }}">
<button class="btn btn--sm{{ ' btn--ghost' if compact else '' }}" type="submit" <button class="btn btn--sm{{ ' btn--ghost' if compact else '' }}" type="submit"
title="{{ first.hint }}" data-busy-label="Startet ..." title="{{ first.hint }}" data-busy-label="Startet ..."
data-confirm="{{ device.name }} ueber {{ first.label }} neu starten? {{ first.hint }}" data-confirm="{{ device.name }} über {{ first.label }} neu starten? {{ first.hint }}"
data-confirm-title="Neustart" data-confirm-ok="Neu starten" data-confirm-title="Neustart" data-confirm-ok="Neu starten"
data-confirm-style="primary"> data-confirm-style="primary">
{{ icon('power', size=15) }}{% if not compact %} {{ first.label }}{% endif %} {{ icon('power', size=15) }}{% if not compact %} {{ first.label }}{% endif %}
@@ -31,7 +31,7 @@
<input type="hidden" name="method" value="{{ other.key }}"> <input type="hidden" name="method" value="{{ other.key }}">
<button class="btn btn--sm btn--ghost" type="submit" title="{{ other.hint }}" <button class="btn btn--sm btn--ghost" type="submit" title="{{ other.hint }}"
data-busy-label="Startet ..." data-busy-label="Startet ..."
data-confirm="{{ device.name }} ueber {{ other.label }} neu starten? {{ other.hint }}" data-confirm="{{ device.name }} über {{ other.label }} neu starten? {{ other.hint }}"
data-confirm-title="Neustart" data-confirm-ok="Neu starten" data-confirm-title="Neustart" data-confirm-ok="Neu starten"
data-confirm-style="primary"> data-confirm-style="primary">
{{ other.label }} {{ other.label }}
@@ -65,7 +65,7 @@
placeholder='1 oder 1/1/3', placeholder='1 oder 1/1/3',
hint_text='Pflicht, sobald ein Switch zugeordnet ist.') }} hint_text='Pflicht, sobald ein Switch zugeordnet ist.') }}
<label class="field"> <label class="field">
<span class="field__label">Zugangsdaten (fuer Wartung)</span> <span class="field__label">Zugangsdaten (für Wartung)</span>
<select class="select" name="credential_id" data-behavior="toggle-target" <select class="select" name="credential_id" data-behavior="toggle-target"
data-toggle-value="__neu__" data-toggle-target="#new-credential-{{ scope }}"> data-toggle-value="__neu__" data-toggle-target="#new-credential-{{ scope }}">
<option value="">keine</option> <option value="">keine</option>
@@ -85,8 +85,8 @@
<legend class="field__label">Neue Zugangsdaten</legend> <legend class="field__label">Neue Zugangsdaten</legend>
<div class="form-grid"> <div class="form-grid">
{{ field('new_credential_name', 'Bezeichnung', placeholder='z. B. Wartung Linux', {{ field('new_credential_name', 'Bezeichnung', placeholder='z. B. Wartung Linux',
hint_text='Der Eintrag wird zusaetzlich unter Zugangsdaten gespeichert und hint_text='Der Eintrag wird zusätzlich unter Zugangsdaten gespeichert und
ist danach fuer weitere Geraete verwendbar.') }} ist danach für weitere Geräte verwendbar.') }}
{{ field('new_credential_username', 'Benutzer') }} {{ field('new_credential_username', 'Benutzer') }}
{{ field('new_credential_secret', 'Passwort', type='password') }} {{ field('new_credential_secret', 'Passwort', type='password') }}
<label class="field"> <label class="field">
@@ -101,7 +101,7 @@
</div> </div>
</fieldset> </fieldset>
{% endif %} {% endif %}
{{ field('ssh_port', 'SSH-Port des Geraets', value=device.ssh_port if device else 22, type='number') }} {{ field('ssh_port', 'SSH-Port des Geräts', value=device.ssh_port if device else 22, type='number') }}
<label class="field"> <label class="field">
<span class="field__label">Kategorie</span> <span class="field__label">Kategorie</span>
<select class="select" name="category"> <select class="select" name="category">
@@ -115,23 +115,23 @@
extra_class='field--full') }} extra_class='field--full') }}
</div> </div>
<div class="stack stack--tight"> <div class="stack stack--tight">
{{ checkbox('is_active', 'Geraet wird ueberwacht', checked=device.is_active if device else True) }} {{ checkbox('is_active', 'Gerät wird überwacht', checked=device.is_active if device else True) }}
{{ checkbox('auto_restart', 'Automatischer PoE-Neustart bei Ausfall', {{ checkbox('auto_restart', 'Automatischer PoE-Neustart bei Ausfall',
checked=device.auto_restart if device else True, checked=device.auto_restart if device else True,
hint_text='Greift nur mit gueltiger Lizenz und zugeordnetem Switch-Port.') }} hint_text='Greift nur mit gültiger Lizenz und zugeordnetem Switch-Port.') }}
</div> </div>
{% endmacro %} {% endmacro %}
{% block page_title %}Clients{% endblock %} {% block page_title %}Clients{% endblock %}
{% block page_sub %} {% block page_sub %}
{{ devices | length }} Geraete &middot; {{ counts.online }} erreichbar, {{ counts.offline }} nicht erreichbar {{ devices | length }} Geräte &middot; {{ counts.online }} erreichbar, {{ counts.offline }} nicht erreichbar
{% endblock %} {% endblock %}
{% block topbar_extra %} {% block topbar_extra %}
{{ search_box('devices-table', 'Name, IP, MAC, Switch ...', count_id='devices-visible') }} {{ search_box('devices-table', 'Name, IP, MAC, Switch ...', count_id='devices-visible') }}
{% if current_user.has('devices.create') %} {% if current_user.has('devices.create') %}
<button class="btn btn--primary btn--sm" type="button" data-dialog-open="device-new"> <button class="btn btn--primary btn--sm" type="button" data-dialog-open="device-new">
{{ icon('plus', size=15) }} Geraet anlegen {{ icon('plus', size=15) }} Gerät anlegen
</button> </button>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@@ -155,8 +155,8 @@
<th class="sortable">MAC</th> <th class="sortable">MAC</th>
<th class="sortable">Zugangsdaten</th> <th class="sortable">Zugangsdaten</th>
<th class="sortable">Switch / Port</th> <th class="sortable">Switch / Port</th>
<th class="sortable">Letzte Pruefung</th> <th class="sortable">Letzte Prüfung</th>
<th class="sortable">Host-Schluessel</th> <th class="sortable">Host-Schlüssel</th>
<th class="actions">Aktion</th> <th class="actions">Aktion</th>
</tr> </tr>
</thead> </thead>
@@ -167,13 +167,14 @@
<a class="fw-600" href="{{ url_for('devices.detail', device_id=device.id) }}">{{ device.name }}</a> <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.is_active %}{{ pill('deaktiviert', '') }}{% endif %}
{% if not device.auto_restart %} {% if not device.auto_restart %}
<span class="hint-icon" title="Automatischer PoE-Neustart ist fuer dieses Geraet abgeschaltet">!</span> <span class="hint-icon" title="Automatischer PoE-Neustart ist für dieses Gerät abgeschaltet">!</span>
{% endif %} {% endif %}
{% if device.location %}<div class="fs-xs faint">{{ device.location }}</div>{% endif %} {% if device.location %}<div class="fs-xs faint">{{ device.location }}</div>{% endif %}
</td> </td>
<td data-sort-value="{{ device.state }}"> <td data-sort-value="{{ device.state }}">
{% if not device.is_active %}{{ pill('aus', '') }} {% if not device.is_active %}{{ pill('aus', '') }}
{% elif device.state == 'online' %}{{ pill('online', 'online') }} {% elif device.state == 'online' %}{{ pill('online', 'online') }}
{% elif device.state == 'restarting' %}{{ pill('startet neu', 'warning') }}
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }} {% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %} {% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
{% if device.fail_count %}<span class="fs-xs faint">({{ device.fail_count }}&times;)</span>{% endif %} {% if device.fail_count %}<span class="fs-xs faint">({{ device.fail_count }}&times;)</span>{% endif %}
@@ -200,8 +201,8 @@
</td> </td>
<td data-sort-value="{{ '1' if device.host_key_known else '0' }}"> <td data-sort-value="{{ '1' if device.host_key_known else '0' }}">
{% if device.credential_category == 'windows' %} {% if device.credential_category == 'windows' %}
<span class="faint fs-xs" title="Windows wird nicht ueber SSH verwaltet"> <span class="faint fs-xs" title="Windows wird nicht über SSH verwaltet">
nicht noetig nicht nötig
</span> </span>
{% elif device.host_key_known %}{{ pill('freigegeben', 'online') }} {% elif device.host_key_known %}{{ pill('freigegeben', 'online') }}
{% else %} {% else %}
@@ -212,10 +213,10 @@
{% endif %} {% endif %}
</td> </td>
<td class="actions"> <td class="actions">
{% if current_user.has('devices.execute') %} {% if restart_methods.get(device.id) %}
{{ restart_control(device, restart_methods.get(device.id, []), compact=True) }} {{ restart_control(device, restart_methods.get(device.id, []), compact=True) }}
{% endif %} {% endif %}
{% if current_user.has('devices.edit') %} {% if current_user.has('devices.write') %}
<button class="btn btn--sm btn--ghost" type="button" <button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="device-edit-{{ device.id }}" aria-label="Bearbeiten"> data-dialog-open="device-edit-{{ device.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }} {{ icon('pencil', size=15) }}
@@ -225,9 +226,9 @@
<form method="post" action="{{ url_for('devices.delete', device_id=device.id) }}" <form method="post" action="{{ url_for('devices.delete', device_id=device.id) }}"
style="display:inline" data-no-guard> style="display:inline" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Loeschen" <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="{{ device.name }} in den Papierkorb verschieben? Die MAC bleibt dabei reserviert."
data-confirm-title="Geraet loeschen" data-confirm-ok="Loeschen"> data-confirm-title="Gerät löschen" data-confirm-ok="Löschen">
{{ icon('trash', size=15) }}</button> {{ icon('trash', size=15) }}</button>
</form> </form>
{% endif %} {% endif %}
@@ -238,19 +239,19 @@
</table> </table>
</div> </div>
{% if not devices %} {% if not devices %}
{{ empty_state('Keine Geraete', 'Legen Sie das erste ueberwachte Geraet an.', 'cpu') }} {{ empty_state('Keine Geräte', 'Legen Sie das erste überwachte Gerät an.', 'cpu') }}
{% endif %} {% endif %}
</section> </section>
{{ alert('Ein PoE-Neustart setzt Switch und Port voraus, ein Neustart ueber SSH oder {{ alert('Ein PoE-Neustart setzt Switch und Port voraus, ein Neustart über SSH oder
RPC setzt passende Zugangsdaten voraus. Ist keine Methode moeglich, erscheint der Knopf RPC setzt passende Zugangsdaten voraus. Ist keine Methode möglich, erscheint der Knopf
nicht -- damit kein Neustart vorgetaeuscht wird, der nichts bewirkt.', 'info') }} nicht -- damit kein Neustart vorgetäuscht wird, der nichts bewirkt.', 'info') }}
{% endblock %} {% endblock %}
{% block dialogs %} {% block dialogs %}
{% if current_user.has('devices.create') %} {% if current_user.has('devices.create') %}
{% call modal('device-new', 'Geraet anlegen', 'MAC und IP sind Pflicht', 'wide') %} {% call modal('device-new', 'Gerät anlegen', 'MAC und IP sind Pflicht', 'wide') %}
<form method="post" action="{{ url_for('devices.create') }}"> <form method="post" action="{{ url_for('devices.create') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">{{ device_form(scope='neu') }}</div> <div class="modal__body">{{ device_form(scope='neu') }}</div>
@@ -262,7 +263,7 @@ nicht -- damit kein Neustart vorgetaeuscht wird, der nichts bewirkt.', 'info') }
{% endcall %} {% endcall %}
{% endif %} {% endif %}
{% if current_user.has('devices.edit') %} {% if current_user.has('devices.write') %}
{% for device in devices %} {% for device in devices %}
{% call modal('device-edit-' ~ device.id, device.name, device.mac, 'wide') %} {% call modal('device-edit-' ~ device.id, device.name, device.mac, 'wide') %}
<form method="post" action="{{ url_for('devices.update', device_id=device.id) }}"> <form method="post" action="{{ url_for('devices.update', device_id=device.id) }}">
+35 -35
View File
@@ -9,7 +9,7 @@
{% block content %} {% block content %}
{% for warning in warnings %}{{ alert(warning, 'warning') }}{% endfor %} {% for warning in warnings %}{{ alert(warning, 'warning') }}{% endfor %}
{% if not sysops_available %} {% if not sysops_available %}
{{ alert('Systemhelfer nicht verfuegbar -- Konfiguration und Dienstzustand koennen hier nur {{ alert('Systemhelfer nicht verfügbar -- Konfiguration und Dienstzustand können hier nur
angezeigt, nicht geschrieben werden.', 'warning') }} angezeigt, nicht geschrieben werden.', 'warning') }}
{% endif %} {% endif %}
@@ -33,13 +33,13 @@
<span class="stat__label">Leases</span> <span class="stat__label">Leases</span>
<span class="stat__value">{{ leases | rejectattr('is_expired') | list | length }}</span> <span class="stat__value">{{ leases | rejectattr('is_expired') | list | length }}</span>
<span class="stat__hint"> <span class="stat__hint">
gueltig{% if leases | selectattr('is_expired') | list %}, gültig{% if leases | selectattr('is_expired') | list %},
{{ leases | selectattr('is_expired') | list | length }} abgelaufen{% endif %} {{ leases | selectattr('is_expired') | list | length }} abgelaufen{% endif %}
</span> </span>
</div> </div>
</div> </div>
{% if current_user.has('dhcp.execute') %} {% if current_user.has('dhcp_service.execute') %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
@@ -58,7 +58,7 @@
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm {{ 'btn--' ~ style if style }}" type="submit" <button class="btn btn--sm {{ 'btn--' ~ style if style }}" type="submit"
data-busy-label="Arbeitet ..." data-confirm="{{ question }}" data-busy-label="Arbeitet ..." data-confirm="{{ question }}"
data-confirm-title="{{ label }}" data-confirm-ok="Ausfuehren" data-confirm-title="{{ label }}" data-confirm-ok="Ausführen"
data-confirm-style="{{ style or 'primary' }}">{{ label }}</button> data-confirm-style="{{ style or 'primary' }}">{{ label }}</button>
</form> </form>
{% endfor %} {% endfor %}
@@ -72,7 +72,7 @@
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('network', size=17) }} Subnetze</div> <div class="card__title">{{ icon('network', size=17) }} Subnetze</div>
<div class="card__sub"> <div class="card__sub">
Subnetz und Maske kommen vom laufenden System -- eingegeben wird nur der Adressbereich. Subnetz und Maske kommen vom laufenden System – eingegeben wird nur der Adressbereich.
</div> </div>
</div> </div>
</div> </div>
@@ -89,7 +89,7 @@
<td class="mono fs-sm">{{ subnet.dns or '&ndash;' | safe }}</td> <td class="mono fs-sm">{{ subnet.dns or '&ndash;' | safe }}</td>
<td>{% if subnet.enabled %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('pausiert', '') }}{% endif %}</td> <td>{% if subnet.enabled %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('pausiert', '') }}{% endif %}</td>
<td class="actions"> <td class="actions">
{% if current_user.has('dhcp.edit') %} {% if current_user.has('dhcp_subnets.write') %}
<form method="post" action="{{ url_for('dhcp.delete_subnet', subnet_id=subnet.id) }}" <form method="post" action="{{ url_for('dhcp.delete_subnet', subnet_id=subnet.id) }}"
style="display:inline" data-no-guard> style="display:inline" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -109,7 +109,7 @@
{{ empty_state('Kein Subnetz', 'Ohne Subnetz startet Kea nicht.', 'network') }} {{ empty_state('Kein Subnetz', 'Ohne Subnetz startet Kea nicht.', 'network') }}
{% endif %} {% endif %}
{% if current_user.has('dhcp.edit') %} {% if current_user.has('dhcp_subnets.create') %}
<form method="post" action="{{ url_for('dhcp.add_subnet') }}"> <form method="post" action="{{ url_for('dhcp.add_subnet') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body"> <div class="card__body">
@@ -135,7 +135,7 @@
</section> </section>
{# -- Globale Einstellungen --------------------------------------------- #} {# -- Globale Einstellungen --------------------------------------------- #}
{% if current_user.has('dhcp.edit') %} {% if current_user.has('dhcp_subnets.write') %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div> <div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div>
@@ -164,8 +164,8 @@
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('puzzle', size=17) }} DHCP-Optionen</div> <div class="card__title">{{ icon('puzzle', size=17) }} DHCP-Optionen</div>
<div class="card__sub"> <div class="card__sub">
{{ options | length }} Optionen &middot; Standardoptionen sind nicht loeschbar, ihr Wert {{ options | length }} Optionen &middot; Standardoptionen sind nicht löschbar, ihr Wert
laesst sich leeren lässt sich leeren
</div> </div>
</div> </div>
{{ search_box('options-table', 'Option suchen ...') }} {{ search_box('options-table', 'Option suchen ...') }}
@@ -183,7 +183,7 @@
</td> </td>
<td class="fs-xs mono">{{ option.type }}</td> <td class="fs-xs mono">{{ option.type }}</td>
<td> <td>
{% if current_user.has('dhcp.edit') %} {% if current_user.has('dhcp_options.write') %}
<form method="post" action="{{ url_for('dhcp.set_option_value') }}" class="row row--tight" <form method="post" action="{{ url_for('dhcp.set_option_value') }}" class="row row--tight"
data-no-guard> data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -197,13 +197,13 @@
{% endif %} {% endif %}
</td> </td>
<td class="actions"> <td class="actions">
{% if current_user.has('dhcp.edit') and not option.is_standard %} {% 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) }}" <form method="post" action="{{ url_for('dhcp.delete_custom_option', option_id=option.id) }}"
data-no-guard> data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit" <button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Eigene Option {{ option.name }} loeschen?" data-confirm="Eigene Option {{ option.name }} löschen?"
data-confirm-title="Option loeschen" data-confirm-ok="Loeschen"> data-confirm-title="Option löschen" data-confirm-ok="Löschen">
{{ icon('trash', size=14) }}</button> {{ icon('trash', size=14) }}</button>
</form> </form>
{% endif %} {% endif %}
@@ -213,7 +213,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% if current_user.has('dhcp.edit') %} {% if current_user.has('dhcp_options.write') %}
<form method="post" action="{{ url_for('dhcp.add_custom_option') }}"> <form method="post" action="{{ url_for('dhcp.add_custom_option') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body"> <div class="card__body">
@@ -232,16 +232,16 @@
<div class="card__footer"><button class="btn" type="submit">Eigene Option anlegen</button></div> <div class="card__footer"><button class="btn" type="submit">Eigene Option anlegen</button></div>
</form> </form>
{# Der Wert einer Option gilt sonst fuer alle. Genau dafuer gibt es {# Der Wert einer Option gilt sonst für alle. Genau dafür gibt es
``dhcp_option_values.device_id``: ein eigener Wert je Geraet, der in ``dhcp_option_values.device_id``: ein eigener Wert je Gerät, der in
dessen Reservierung geschrieben wird -- etwa eine Terminal-URL, die je dessen Reservierung geschrieben wird -- etwa eine Terminal-URL, die je
Geraet anders lautet. Auswertbar war das von Anfang an, eingeben liess Gerät anders lautet. Auswertbar war das von Anfang an, eingeben liess
es sich nicht. #} es sich nicht. #}
<div class="card__header" style="border-top:1px solid var(--border-subtle)"> <div class="card__header" style="border-top:1px solid var(--border-subtle)">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('sliders', size=17) }} Option nur fuer ein Geraet</div> <div class="card__title">{{ icon('sliders', size=17) }} Option nur für ein Gerät</div>
<div class="card__sub"> <div class="card__sub">
Der Wert landet in der Reservierung dieses Geraets und ueberschreibt dort den Der Wert landet in der Reservierung dieses Geräts und überschreibt dort den
globalen Wert. Leeres Feld entfernt ihn wieder. globalen Wert. Leeres Feld entfernt ihn wieder.
</div> </div>
</div> </div>
@@ -251,7 +251,7 @@
<div class="card__body"> <div class="card__body">
<div class="form-grid"> <div class="form-grid">
<label class="field"> <label class="field">
<span class="field__label">Geraet<span class="req" aria-hidden="true">*</span></span> <span class="field__label">Gerät<span class="req" aria-hidden="true">*</span></span>
<select class="select" name="device_id" required> <select class="select" name="device_id" required>
{% for reservation in reservations if reservation.device_id %} {% for reservation in reservations if reservation.device_id %}
<option value="{{ reservation.device_id }}"> <option value="{{ reservation.device_id }}">
@@ -269,11 +269,11 @@
</select> </select>
</label> </label>
{{ field('value', 'Wert', extra_class='field--full', {{ field('value', 'Wert', extra_class='field--full',
hint_text='Leer = Wert fuer dieses Geraet entfernen') }} hint_text='Leer = Wert für dieses Gerät entfernen') }}
</div> </div>
</div> </div>
<div class="card__footer"> <div class="card__footer">
<button class="btn" type="submit">Fuer dieses Geraet speichern</button> <button class="btn" type="submit">Für dieses Gerät speichern</button>
</div> </div>
</form> </form>
{% endif %} {% endif %}
@@ -286,16 +286,16 @@
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('credit-card', size=17) }} Reservierungen</div> <div class="card__title">{{ icon('credit-card', size=17) }} Reservierungen</div>
<div class="card__sub"> <div class="card__sub">
Automatisch aus dem Geraetebestand; bei MAC-Kollision gewinnt der Geraetebestand. Automatisch aus dem Gerätebestand; bei MAC-Kollision gewinnt der Gerätebestand.
Nur Adressen aus eingerichteten Bereichen. Nur Adressen aus eingerichteten Bereichen.
</div> </div>
</div> </div>
</div> </div>
{% if skipped_reservations %} {% if skipped_reservations %}
{# Nicht verschweigen, sondern benennen: vorher standen diese Eintraege in {# Nicht verschweigen, sondern benennen: vorher standen diese Einträge in
der Liste, obwohl Kea sie nie ausgeliefert hat. #} der Liste, obwohl Kea sie nie ausgeliefert hat. #}
<div class="card__body pt-0"> <div class="card__body pt-0">
{{ alert(skipped_reservations | length ~ ' Geraet(e) liegen in keinem eingerichteten {{ alert(skipped_reservations | length ~ ' Gerät(e) liegen in keinem eingerichteten
Bereich und bekommen keine Reservierung. Legen Sie den passenden Bereich an, wenn sie Bereich und bekommen keine Reservierung. Legen Sie den passenden Bereich an, wenn sie
eine bekommen sollen.', 'warning') }} eine bekommen sollen.', 'warning') }}
<details class="fs-xs faint"> <details class="fs-xs faint">
@@ -339,7 +339,7 @@
{% endif %} {% endif %}
</td> </td>
<td class="actions"> <td class="actions">
{% if reservation.source == 'manuell' and current_user.has('dhcp.edit') %} {% if reservation.source == 'manuell' and current_user.has('dhcp_reservations.delete') %}
<form method="post" data-no-guard <form method="post" data-no-guard
action="{{ url_for('dhcp.delete_reservation', reservation_id=reservation.reservation_id) }}"> action="{{ url_for('dhcp.delete_reservation', reservation_id=reservation.reservation_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -355,7 +355,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% if current_user.has('dhcp.edit') %} {% if current_user.has('dhcp_reservations.create') %}
<form method="post" action="{{ url_for('dhcp.add_reservation') }}"> <form method="post" action="{{ url_for('dhcp.add_reservation') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body"> <div class="card__body">
@@ -381,20 +381,20 @@
</div> </div>
</div> </div>
{% if leases and service.get('active') != 'active' %} {% if leases and service.get('active') != 'active' %}
{# Sonst sieht man alte Eintraege und haelt sie fuer den aktuellen Stand: {# 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 ohne laufenden Dienst wird keine Lease erneuert, und die Datei
veraltet still. #} veraltet still. #}
<div class="card__body pt-0"> <div class="card__body pt-0">
{{ alert('Der DHCP-Dienst laeuft nicht (' ~ service.get('active', 'unbekannt') ~ '). {{ 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 Es wird keine Lease erneuert -- die Liste zeigt den letzten Stand der Datei, nicht die
aktuelle Lage. Der Grund steht im Kea-Protokoll.', 'warning') }} aktülle Lage. Der Grund steht im Kea-Protokoll.', 'warning') }}
</div> </div>
{% endif %} {% endif %}
{% if leases %} {% if leases %}
<div class="table-wrap" style="max-height:420px;overflow:auto"> <div class="table-wrap" style="max-height:420px;overflow:auto">
<table class="table table--compact"> <table class="table table--compact">
<thead><tr><th>IP</th><th>MAC</th><th>Hostname</th><th>Art</th><th>Zustand</th> <thead><tr><th>IP</th><th>MAC</th><th>Hostname</th><th>Art</th><th>Zustand</th>
<th>Laeuft ab</th></tr></thead> <th>Läuft ab</th></tr></thead>
<tbody> <tbody>
{% for lease in leases %} {% for lease in leases %}
<tr> <tr>
@@ -407,7 +407,7 @@
</td> </td>
<td> <td>
{% if lease.is_expired %}{{ pill('abgelaufen', 'warning') }} {% if lease.is_expired %}{{ pill('abgelaufen', 'warning') }}
{% elif lease.is_active %}{{ pill('gueltig', 'online') }} {% elif lease.is_active %}{{ pill('gültig', 'online') }}
{% else %}{{ pill('inaktiv', '') }}{% endif %} {% else %}{{ pill('inaktiv', '') }}{% endif %}
</td> </td>
<td class="fs-sm">{{ lease.expires_text }} <td class="fs-sm">{{ lease.expires_text }}
@@ -429,12 +429,12 @@
<summary class="card__header" style="cursor:pointer"> <summary class="card__header" style="cursor:pointer">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte Kea-Konfiguration</div> <div class="card__title">{{ icon('file-text', size=17) }} Erzeugte Kea-Konfiguration</div>
<div class="card__sub">Vorschau &ndash; so wuerde die Datei geschrieben</div> <div class="card__sub">Vorschau &ndash; so würde die Datei geschrieben</div>
</div> </div>
{{ icon('chevron-down', size=16) }} {{ icon('chevron-down', size=16) }}
</summary> </summary>
<pre class="log-view">{{ preview }}</pre> <pre class="log-view">{{ preview }}</pre>
{% if current_user.has('dhcp.edit') %} {% if current_user.has('dhcp_config.execute') %}
<div class="card__footer"> <div class="card__footer">
<form method="post" action="{{ url_for('dhcp.write_config') }}" data-no-guard> <form method="post" action="{{ url_for('dhcp.write_config') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
+278 -27
View File
@@ -5,7 +5,7 @@
{% block page_sub %}{{ mounts | length }} eingebundene Freigabe(n){% endblock %} {% block page_sub %}{{ mounts | length }} eingebundene Freigabe(n){% endblock %}
{% block topbar_extra %} {% block topbar_extra %}
{% if current_user.has('fileshare.edit') %} {% if current_user.has('fileshare.write') %}
<a class="btn btn--sm" href="{{ url_for('fileshare.mappings') }}"> <a class="btn btn--sm" href="{{ url_for('fileshare.mappings') }}">
{{ icon('settings', size=15) }} Zuordnungen {{ icon('settings', size=15) }} Zuordnungen
</a> </a>
@@ -23,7 +23,7 @@
<div class="tabs"> <div class="tabs">
{% for mount in mounts %} {% for mount in mounts %}
<a class="tab {{ 'is-active' if mount.token == token else '' }}" <a class="tab {{ 'is-active' if mount.token == token else '' }}"
href="{{ url_for('fileshare.browse', freigabe=mount.token) }}">{{ mount.label }}</a> href="{{ url_for('fileshare.browse', share=mount.token) }}">{{ mount.label }}</a>
{% endfor %} {% endfor %}
</div> </div>
@@ -33,9 +33,9 @@
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<nav class="breadcrumb"> <nav class="breadcrumb">
<span><a href="{{ url_for('fileshare.browse', freigabe=token) }}">{{ current.label }}</a></span> <span><a href="{{ url_for('fileshare.browse', share=token) }}">{{ current.label }}</a></span>
{% for crumb in listing.breadcrumb %} {% for crumb in listing.breadcrumb %}
<span><a href="{{ url_for('fileshare.browse', freigabe=token, pfad=crumb.relative) }}">{{ crumb.name }}</a></span> <span><a href="{{ url_for('fileshare.browse', share=token, path=crumb.relative) }}">{{ crumb.name }}</a></span>
{% endfor %} {% endfor %}
</nav> </nav>
<div class="card__sub mono fs-xs">{{ current.unc }}</div> <div class="card__sub mono fs-xs">{{ current.unc }}</div>
@@ -59,8 +59,8 @@
<tr> <tr>
{% if current_user.has('fileshare.delete') %}<th style="width:40px"></th>{% endif %} {% if current_user.has('fileshare.delete') %}<th style="width:40px"></th>{% endif %}
<th>Name</th> <th>Name</th>
<th class="numeric">Groesse</th> <th class="numeric">Grösse</th>
<th>Geaendert</th> <th>Geändert</th>
<th class="actions">Aktion</th> <th class="actions">Aktion</th>
</tr> </tr>
</thead> </thead>
@@ -69,9 +69,9 @@
<tr> <tr>
{% if current_user.has('fileshare.delete') %}<td></td>{% endif %} {% if current_user.has('fileshare.delete') %}<td></td>{% endif %}
<td colspan="4"> <td colspan="4">
<a href="{{ url_for('fileshare.browse', freigabe=token, <a class="link-icon" href="{{ url_for('fileshare.browse', share=token,
pfad=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}"> path=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}">
{{ icon('chevron-right', size=14) }} eine Ebene hoeher {{ icon('chevron-right', size=14) }} eine Ebene höher
</a> </a>
</td> </td>
</tr> </tr>
@@ -79,13 +79,26 @@
{% for entry in listing.directories %} {% for entry in listing.directories %}
<tr> <tr>
{% if current_user.has('fileshare.delete') %} {% if current_user.has('fileshare.delete') %}
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswaehlen</span></label></td> <td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
{% endif %} {% endif %}
<td> {# 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) }} {{ icon('folder', size=15) }}
<a href="{{ url_for('fileshare.browse', freigabe=token, pfad=entry.relative) }}">{{ entry.name }}</a> <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 -%}
&ndash;
{%- elif entry.entries >= 500 -%}
500+ Einträge
{%- elif entry.entries == 1 -%}
1 Eintrag
{%- else -%}
{{ entry.entries }} Einträge
{%- endif -%}
</td> </td>
<td class="numeric faint">&ndash;</td>
<td class="fs-sm">{{ entry.modified | dt }}</td> <td class="fs-sm">{{ entry.modified | dt }}</td>
<td class="actions"></td> <td class="actions"></td>
</tr> </tr>
@@ -93,20 +106,65 @@
{% for entry in listing.files %} {% for entry in listing.files %}
<tr> <tr>
{% if current_user.has('fileshare.delete') %} {% if current_user.has('fileshare.delete') %}
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswaehlen</span></label></td> <td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
{% endif %} {% endif %}
<td>{{ icon('file-text', size=15) }} {{ entry.name }}</td> {# 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="numeric fs-sm">{{ entry.size | bytes }}</td>
<td class="fs-sm">{{ entry.modified | dt }}</td> <td class="fs-sm">{{ entry.modified | dt }}</td>
<td class="actions"> <td class="actions">
{% if entry.previewable %} {% if entry.previewable %}
<a class="btn btn--sm btn--ghost" target="_blank" rel="noopener" <button class="btn btn--sm btn--ghost" type="button"
href="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}" data-dialog-open="file-preview"
aria-label="Vorschau">{{ icon('eye', size=15) }}</a> 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 %} {% endif %}
<a class="btn btn--sm btn--ghost"
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -119,9 +177,9 @@
{% if current_user.has('fileshare.delete') %} {% if current_user.has('fileshare.delete') %}
<div class="card__footer"> <div class="card__footer">
<button class="btn btn--sm btn--ghost danger-text" type="submit" <button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Die ausgewaehlten Eintraege endgueltig loeschen? Auf einer Netzwerkfreigabe gibt es keinen Papierkorb." data-confirm="Die ausgewählten Einträge endgültig löschen? Auf einer Netzwerkfreigabe gibt es keinen Papierkorb."
data-confirm-title="Loeschen" data-confirm-ok="Loeschen"> data-confirm-title="Löschen" data-confirm-ok="Löschen">
Ausgewaehlte loeschen Ausgewählte löschen
</button> </button>
</div> </div>
{% endif %} {% endif %}
@@ -139,10 +197,10 @@
<form method="post" enctype="multipart/form-data" data-no-guard <form method="post" enctype="multipart/form-data" data-no-guard
action="{{ url_for('fileshare.upload', token=token) }}"> action="{{ url_for('fileshare.upload', token=token) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="pfad" value="{{ listing.relative }}"> <input type="hidden" name="path" value="{{ listing.relative }}">
<div class="card__body row"> <div class="card__body row">
<input class="input" type="file" name="files" multiple required style="flex:1"> <input class="input" type="file" name="files" multiple required style="flex:1">
<button class="btn btn--primary" type="submit" data-busy-label="Laedt ...">Hochladen</button> <button class="btn btn--primary" type="submit" data-busy-label="Lädt ...">Hochladen</button>
</div> </div>
</form> </form>
</section> </section>
@@ -156,7 +214,7 @@
{% call modal('folder-new', 'Ordner anlegen', listing.relative or 'Wurzelverzeichnis', 'narrow') %} {% call modal('folder-new', 'Ordner anlegen', listing.relative or 'Wurzelverzeichnis', 'narrow') %}
<form method="post" action="{{ url_for('fileshare.make_directory', token=token) }}"> <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="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="pfad" value="{{ listing.relative }}"> <input type="hidden" name="path" value="{{ listing.relative }}">
<div class="modal__body">{{ field('name', 'Name', required=True) }}</div> <div class="modal__body">{{ field('name', 'Name', required=True) }}</div>
<div class="modal__footer"> <div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button> <button class="btn" type="button" data-dialog-close>Abbrechen</button>
@@ -165,4 +223,197 @@
</form> </form>
{% endcall %} {% endcall %}
{% endif %} {% 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 %} {% endblock %}
@@ -1,13 +1,13 @@
{% extends "tesm_core/base.html" %} {% extends "tesm_core/base.html" %}
{% from "tesm_core/_group_picker.html" import group_picker %} {% from "tesm_core/_group_picker.html" import group_picker %}
{% from "tesm_core/macros.html" import field, empty_state, alert %} {% from "tesm_core/macros.html" import field, empty_state, alert, modal %}
{% block page_title %}Freigaben einrichten{% endblock %} {% block page_title %}Freigaben einrichten{% endblock %}
{% block page_sub %}{{ mappings | length }} Zuordnungen &middot; Wurzel {{ mount_root }}{% endblock %} {% block page_sub %}{{ mappings | length }} Zuordnungen &middot; Wurzel {{ mount_root }}{% endblock %}
{% block content %} {% block content %}
{{ alert('Eine Freigabe wird bei der Anmeldung eingebunden, wenn der Benutzer Mitglied der {{ alert('Eine Freigabe wird bei der Anmeldung eingebunden, wenn der Benutzer Mitglied der
zugeordneten Verzeichnisgruppe ist. Eingebunden wird mit dem Konto dieses Benutzers -- deshalb zugeordneten Verzeichnisgruppe ist. Eingebunden wird mit dem Konto dieses Benutzers -- deshalb
ist die Verzeichnisgruppe das einzige moegliche Ziel, und lokale Konten bekommen keine ist die Verzeichnisgruppe das einzige mögliche Ziel, und lokale Konten bekommen keine
Einbindungen.', 'info') }} Einbindungen.', 'info') }}
{% if not directory_enabled %} {% if not directory_enabled %}
@@ -33,6 +33,11 @@ Einbindungen.', 'info') }}
<td class="fs-sm">{{ entry.ad_group_name }} <td class="fs-sm">{{ entry.ad_group_name }}
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td> <div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
<td class="actions"> <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> <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() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit" <button class="btn btn--sm btn--ghost danger-text" type="submit"
@@ -61,7 +66,7 @@ Einbindungen.', 'info') }}
<div class="form-grid"> <div class="form-grid">
{{ field('label', 'Anzeigename', required=True, placeholder='Abteilung Technik') }} {{ field('label', 'Anzeigename', required=True, placeholder='Abteilung Technik') }}
{{ field('unc', 'UNC-Pfad', required=True, placeholder='\\\\dc2022\\daten', {{ field('unc', 'UNC-Pfad', required=True, placeholder='\\\\dc2022\\daten',
hint_text='Schraegstriche gehen auch: //dc2022/daten') }} hint_text='Schrägstriche gehen auch: //dc2022/daten') }}
{{ group_picker('ad_group_name', 'ad_group_dn', 'share', required=True, {{ group_picker('ad_group_name', 'ad_group_dn', 'share', required=True,
label='Verzeichnisgruppe, die diese Freigabe bekommt') }} label='Verzeichnisgruppe, die diese Freigabe bekommt') }}
</div> </div>
@@ -70,3 +75,33 @@ Einbindungen.', 'info') }}
</form> </form>
</section> </section>
{% endblock %} {% 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 %}
+25 -21
View File
@@ -1,40 +1,44 @@
{% extends "tesm_core/base.html" %} {% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %} {% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %}
{% block page_title %}Host-Schluessel{% endblock %} {% block page_title %}Host-Schlüssel{% endblock %}
{% block page_sub %} {% block page_sub %}
{{ targets | length }} Ziele mit SSH-Zugang {{ targets | length }} Ziele mit SSH-Zugang
{%- if open_count %} &middot; {{ open_count }} ohne Freigabe{% endif %} {%- if open_count %} &middot; {{ open_count }} ohne Freigabe{% endif %}
{% endblock %} {% endblock %}
{% block topbar_extra %}{{ search_box('hostkey-table', 'Geraet, IP, Fingerprint ...') }}{% endblock %} {% block topbar_extra %}{{ search_box('hostkey-table', 'Gerät, IP, Fingerprint ...') }}{% endblock %}
{% block content %} {% block content %}
{% if open_count %} {% if open_count %}
{{ alert('Fuer ' ~ open_count ~ ' Ziel(e) ist noch kein Schluessel freigegeben. Wartung, {# Der PoE-Neustart hängt am Schlüssel des *Switches*, nicht am Schlüssel des
Konsole und PoE-Neustart brechen dort mit "Schluessel unbekannt" ab -- absichtlich: ein Clients: geschaltet wird der Port auf dem Switch. Der alte Satz warf beides
Hintergrundauftrag darf keinen fremden Schluessel annehmen.', 'warning', zusammen und liess vermuten, ein Client ohne freigegebenen Schlüssel sei
'Freigaben offen') }} 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 %} {% else %}
{{ alert('Fuer jedes Ziel mit SSH-Zugang liegt ein freigegebener Schluessel vor.', {{ alert('Für jedes Ziel mit SSH-Zugang liegt ein freigegebener Schlüssel vor.',
'success', 'Vollstaendig') }} 'success', 'Vollständig') }}
{% endif %} {% endif %}
{{ alert('Ein Schluessel wird in zwei Schritten uebernommen: erst auslesen, dann -- nach {{ alert('Ein Schlüssel wird in zwei Schritten übernommen: erst auslesen, dann -- nach
Vergleich mit einer unabhaengigen Quelle -- freigeben. Gespeichert wird nach IP und Port; Vergleich mit einer unabhängigen Quelle -- freigeben. Gespeichert wird nach IP und Port;
aendert sich die Adresse eines Geraets, ist erneut freizugeben. Ein *geaenderter* Schluessel ändert sich die Adresse eines Geräts, ist erneut freizugeben. Ein *geänderter* Schlüssel
wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff auftritt.', wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff auftritt.',
'info', 'So funktioniert die Freigabe') }} 'info', 'So funktioniert die Freigabe') }}
{% if probed_fingerprint %} {% if probed_fingerprint %}
<div class="alert alert--warning"> <div class="alert alert--warning">
{{ icon('alert', size=18) }} {{ icon('alert', size=18) }}
<div class="alert__body"> <div class="alert__body">
<div class="alert__title">Gelesener Schluessel von {{ probed_host }}:{{ probed_port }}</div> <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> <div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
<p class="fs-sm mt-2"> <p class="fs-sm mt-2">
Vergleichen Sie den Fingerprint mit einer unabhaengigen Quelle -- Konsolenzugang am Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle | Konsolenzugang am
Geraet, Inventardokumentation --, bevor Sie ihn unten freigeben. Gerät, Inventardokumentation --, bevor Sie ihn unten freigeben.
</p> </p>
</div> </div>
</div> </div>
@@ -102,8 +106,8 @@ wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff au
<input type="hidden" name="key_type" value="{{ probed_type }}"> <input type="hidden" name="key_type" value="{{ probed_type }}">
<input type="hidden" name="fingerprint" value="{{ probed_fingerprint }}"> <input type="hidden" name="fingerprint" value="{{ probed_fingerprint }}">
<button class="btn btn--sm btn--primary" type="submit" <button class="btn btn--sm btn--primary" type="submit"
data-confirm="Fingerprint {{ probed_fingerprint }} fuer {{ row.label }} dauerhaft als vertrauenswuerdig hinterlegen?" data-confirm="Fingerprint {{ probed_fingerprint }} für {{ row.label }} dauerhaft als vertrauenswürdig hinterlegen?"
data-confirm-title="Host-Schluessel freigeben" data-confirm-title="Host-Schlüssel freigeben"
data-confirm-ok="Freigeben" data-confirm-style="primary"> data-confirm-ok="Freigeben" data-confirm-style="primary">
{{ icon('check', size=14) }} Freigeben {{ icon('check', size=14) }} Freigeben
</button> </button>
@@ -114,7 +118,7 @@ wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff au
action="{{ url_for('hostkeys.forget', kind=row.kind, target_id=row.id) }}"> action="{{ url_for('hostkeys.forget', kind=row.kind, target_id=row.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost" type="submit" <button class="btn btn--sm btn--ghost" type="submit"
data-confirm="Freigabe fuer {{ row.label }} entfernen? Wartung und Konsole verweigern danach die Verbindung, bis erneut freigegeben wurde." data-confirm="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"> data-confirm-title="Freigabe verwerfen" data-confirm-ok="Verwerfen">
Verwerfen Verwerfen
</button> </button>
@@ -141,7 +145,7 @@ wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff au
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('alert', size=17) }} Verwaiste Freigaben</div> <div class="card__title">{{ icon('alert', size=17) }} Verwaiste Freigaben</div>
<div class="card__sub"> <div class="card__sub">
Zu diesen Eintraegen gibt es kein Ziel mehr -- geloescht oder mit neuer IP. Zu diesen Einträgen gibt es kein Ziel mehr – gelöscht oder mit neuer IP.
</div> </div>
</div> </div>
</div> </div>
@@ -156,13 +160,13 @@ wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff au
<td class="mono fs-xs break-all">{{ row.fingerprint }}</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 class="fs-sm">{{ row.added_at | dt }} <span class="faint">von {{ row.added_by }}</span></td>
<td> <td>
{% if current_user.has('devices.edit') or current_user.has('switches.edit') %} {% if current_user.has('devices.write') or current_user.has('switches.write') %}
<form method="post" action="{{ url_for('hostkeys.forget_orphan') }}" data-no-guard> <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="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="host" value="{{ row.host }}"> <input type="hidden" name="host" value="{{ row.host }}">
<input type="hidden" name="port" value="{{ row.port }}"> <input type="hidden" name="port" value="{{ row.port }}">
<button class="btn btn--sm btn--ghost" type="submit" <button class="btn btn--sm btn--ghost" type="submit"
data-confirm="Verwaiste Freigabe fuer {{ row.host }}:{{ row.port }} entfernen?" data-confirm="Verwaiste Freigabe für {{ row.host }}:{{ row.port }} entfernen?"
data-confirm-title="Eintrag entfernen" data-confirm-ok="Entfernen"> data-confirm-title="Eintrag entfernen" data-confirm-ok="Entfernen">
Entfernen Entfernen
</button> </button>
+45 -32
View File
@@ -4,8 +4,8 @@
{% block page_title %}Lizenz{% endblock %} {% block page_title %}Lizenz{% endblock %}
{% block page_sub %} {% block page_sub %}
{% if state and state.valid and state.activated %}aktiv {% if state and state.valid and state.activated %}aktiv
{% elif state and state.valid %}gueltig, noch nicht aktiviert {% elif state and state.valid %}gültig, noch nicht aktiviert
{% elif state and state.present %}ungueltig {% elif state and state.present %}ungültig
{% else %}keine Lizenz installiert{% endif %} {% else %}keine Lizenz installiert{% endif %}
{% endblock %} {% endblock %}
@@ -37,7 +37,7 @@
<dt>Lizenz-ID</dt><dd class="mono fs-xs break-all">{{ state.status.license_id }}</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>Revision</dt><dd>{{ state.status.seq }}</dd>
<dt>Ausgestellt</dt><dd>{{ state.status.issued_at | dt }}</dd> <dt>Ausgestellt</dt><dd>{{ state.status.issued_at | dt }}</dd>
<dt>Laeuft ab</dt> <dt>Läuft ab</dt>
<dd> <dd>
{% if state.status.lifetime %}{{ pill('unbefristet', 'online') }} {% if state.status.lifetime %}{{ pill('unbefristet', 'online') }}
{% else %} {% else %}
@@ -46,7 +46,7 @@
{{ pill('abgelaufen seit ' ~ state.status.days_since_expiry ~ ' Tagen', 'danger') }} {{ pill('abgelaufen seit ' ~ state.status.days_since_expiry ~ ' Tagen', 'danger') }}
{% if state.status.grace_active %} {% if state.status.grace_active %}
<div class="fs-xs warning-text"> <div class="fs-xs warning-text">
Kulanzfrist laeuft ({{ grace_days }} Tage) &ndash; Funktionen bleiben vorerst nutzbar. Kulanzfrist läuft ({{ grace_days }} Tage) &ndash; Funktionen bleiben vorerst nutzbar.
</div> </div>
{% endif %} {% endif %}
{% elif state.status.expiring_soon %} {% elif state.status.expiring_soon %}
@@ -60,9 +60,18 @@
<dd> <dd>
{% if state.modules %} {% if state.modules %}
{% for module in modules %} {% for module in modules %}
{% if module in state.modules %}{{ pill(module_labels[module], 'online') }} {% 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 %} {% else %}{{ pill(module_labels[module], '') }}{% endif %}
{% endfor %} {% 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 %} {% else %}<span class="faint">keine Zusatzmodule</span>{% endif %}
</dd> </dd>
<dt>Lizenzserver</dt> <dt>Lizenzserver</dt>
@@ -76,7 +85,7 @@
</dd> </dd>
</dl> </dl>
</div> </div>
{% if current_user.has('settings_license.edit') %} {% if current_user.has('settings_license.write') %}
<div class="card__footer"> <div class="card__footer">
{% if not state.activated %} {% if not state.activated %}
<form method="post" action="{{ url_for('license.activate') }}" data-no-guard> <form method="post" action="{{ url_for('license.activate') }}" data-no-guard>
@@ -88,7 +97,7 @@
{% else %} {% else %}
<form method="post" action="{{ url_for('license.heartbeat') }}" data-no-guard> <form method="post" action="{{ url_for('license.heartbeat') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Jetzt pruefen</button> <button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Jetzt prüfen</button>
</form> </form>
<form method="post" action="{{ url_for('license.deactivate') }}" data-no-guard> <form method="post" action="{{ url_for('license.deactivate') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -118,14 +127,14 @@
{% if pending.reason %} {% if pending.reason %}
{{ alert(pending.reason, 'warning', 'Warum der Online-Weg nicht ging') }} {{ alert(pending.reason, 'warning', 'Warum der Online-Weg nicht ging') }}
{% endif %} {% endif %}
{{ alert('Beide Codes sind signiert -- sie lassen sich unterwegs nicht veraendern. {{ alert('Beide Codes sind signiert -- sie lassen sich unterwegs nicht verändern.
Der Anfragecode gehoert zu genau dieser Maschine und genau dieser Anfrage; eine Der Anfragecode gehört zu genau dieser Maschine und genau dieser Anfrage; eine
Antwort auf einen anderen Code wird abgelehnt.', 'info') }} Antwort auf einen anderen Code wird abgelehnt.', 'info') }}
{% if pending.code %} {% if pending.code %}
<div class="field"> <div class="field">
<span class="field__label"> <span class="field__label">
Anfragecode -- an den Anbieter uebermitteln Anfragecode – an den Anbieter übermitteln
</span> </span>
<pre class="log-view" id="offline-request" style="max-height:14dvh">{{ pending.code }}</pre> <pre class="log-view" id="offline-request" style="max-height:14dvh">{{ pending.code }}</pre>
<div class="row row--end mt-2"> <div class="row row--end mt-2">
@@ -135,8 +144,8 @@
</div> </div>
</div> </div>
{% else %} {% else %}
{{ alert('Zu dieser Anfrage liegt kein Code vor -- sie stammt aus einer aelteren {{ alert('Zu dieser Anfrage liegt kein Code vor -- sie stammt aus einer älteren
Fassung. Loesen Sie die Aktion erneut aus, dann entsteht ein neuer Code.', Fassung. Lösen Sie die Aktion erneut aus, dann entsteht ein neuer Code.',
'warning') }} 'warning') }}
{% endif %} {% endif %}
@@ -145,31 +154,31 @@
<label class="field"> <label class="field">
<span class="field__label">Antwortcode des Lizenzservers</span> <span class="field__label">Antwortcode des Lizenzservers</span>
<textarea class="textarea input--mono" name="code" rows="4" required <textarea class="textarea input--mono" name="code" rows="4" required
placeholder="Code einfuegen ..."></textarea> placeholder="Code einfügen ..."></textarea>
</label> </label>
<div class="row row--end mt-3"> <div class="row row--end mt-3">
<button class="btn btn--primary" type="submit">Antwort uebernehmen</button> <button class="btn btn--primary" type="submit">Antwort übernehmen</button>
</div> </div>
</form> </form>
</div> </div>
</section> </section>
{% endif %} {% endif %}
{% if current_user.has('settings_license.edit') %} {% if current_user.has('settings_license.write') %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Zertifikat des Lizenzservers</div> <div class="card__title">{{ icon('shield', size=17) }} Zertifikat des Lizenzservers</div>
<div class="card__sub"> <div class="card__sub">
Nur noetig, wenn der Lizenzserver ein eigenes Zertifikat benutzt Nur nötig, wenn der Lizenzserver ein eigenes Zertifikat benutzt
</div> </div>
</div> </div>
</div> </div>
<div class="card__body stack"> <div class="card__body stack">
{{ alert('Ein Lizenzserver im eigenen Netz hat meist ein selbstsigniertes {{ alert('Ein Lizenzserver im eigenen Netz hat meist ein selbstsigniertes
Zertifikat. Ohne Anker scheitert die Pruefung, und jeder Versuch faellt auf den Zertifikat. Ohne Anker scheitert die Prüfung, und jeder Versuch fällt auf den
Offline-Weg zurueck. Die Pruefung abzuschalten waere der falsche Weg -- hinterlegen Offline-Weg zurück. Die Prüfung abzuschalten wäre der falsche Weg -- hinterlegen
Sie stattdessen das Zertifikat, dann wird gegen genau dieses geprueft.', 'info') }} Sie stattdessen das Zertifikat, dann wird gegen genau dieses geprüft.', 'info') }}
<form method="post" action="{{ url_for('license.server_certificate') }}"> <form method="post" action="{{ url_for('license.server_certificate') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<label class="field"> <label class="field">
@@ -178,7 +187,7 @@
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----" placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
>{{ server_certificate }}</textarea> >{{ server_certificate }}</textarea>
<span class="field__hint"> <span class="field__hint">
Leer lassen, um wieder gegen die Systemanker zu pruefen. Auf dem Lizenzserver Leer lassen, um wieder gegen die Systemanker zu prüfen. Auf dem Lizenzserver
liegt es unter <span class="mono">/etc/tesm/certs/&lt;name&gt;/fullchain.pem</span>. liegt es unter <span class="mono">/etc/tesm/certs/&lt;name&gt;/fullchain.pem</span>.
</span> </span>
</label> </label>
@@ -190,13 +199,13 @@
</section> </section>
{% endif %} {% endif %}
{% if current_user.has('settings_license.edit') %} {% if current_user.has('settings_license.write') %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Lizenzdatei einspielen</div> <div class="card__title">{{ icon('upload', size=17) }} Lizenzdatei einspielen</div>
<div class="card__sub"> <div class="card__sub">
Die Datei enthaelt das Lizenzdokument und den zugehoerigen Client-Schluessel Die Datei enthält das Lizenzdokument und den zugehörigen Client-Schlüssel
</div> </div>
</div> </div>
</div> </div>
@@ -206,7 +215,7 @@
<div class="card__body stack"> <div class="card__body stack">
<input class="input" type="file" name="license_file" accept=".json,.lic" required> <input class="input" type="file" name="license_file" accept=".json,.lic" required>
{% if trusted_keys and current_user.is_admin %} {% if trusted_keys and current_user.is_admin %}
{{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdruecklich zulassen', {{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdrücklich zulassen',
hint_text='Nur ankreuzen, wenn die neue Lizenz bewusst von einem anderen hint_text='Nur ankreuzen, wenn die neue Lizenz bewusst von einem anderen
Lizenzserver kommt.') }} Lizenzserver kommt.') }}
{% endif %} {% endif %}
@@ -233,8 +242,8 @@
<dt>Fingerprint</dt><dd class="mono fs-xs break-all">{{ fingerprint }}</dd> <dt>Fingerprint</dt><dd class="mono fs-xs break-all">{{ fingerprint }}</dd>
</dl> </dl>
<p class="fs-xs faint mt-3"> <p class="fs-xs faint mt-3">
Der Fingerprint stammt aus der stabilen Maschinenidentitaet. Eine Umbenennung des Hosts Der Fingerprint stammt aus der stabilen Maschinenidentität. Eine Umbenennung des Hosts
aendert ihn <strong>nicht</strong> mehr -- im Vorgaenger fuehrte genau das zu ändert ihn <strong>nicht</strong> mehr – im Vorgänger führte genau das zu
&bdquo;bereits anderweitig gebunden&ldquo;. &bdquo;bereits anderweitig gebunden&ldquo;.
</p> </p>
</div> </div>
@@ -243,28 +252,32 @@
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswuerdige Aussteller</div> <div class="card__title">{{ icon('shield', size=17) }} Vertrauenswürdige Aussteller</div>
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schluessel</div> <div class="card__sub">{{ trusted_keys | length }} gepinnte Schlüssel</div>
</div> </div>
</div> </div>
{% if trusted_keys %} {% if trusted_keys %}
<div class="table-wrap"> <div class="table-wrap">
<table class="table table--compact"> <table class="table table--compact">
<thead><tr><th>Schluessel-ID</th><th>Bezeichnung</th><th>Quelle</th><th class="actions"></th></tr></thead> <thead><tr><th>Schlüssel-ID</th><th>Bezeichnung</th><th>Quelle</th><th class="actions"></th></tr></thead>
<tbody> <tbody>
{% for key in trusted_keys %} {% for key in trusted_keys %}
<tr> <tr>
<td class="mono fs-xs">{{ key.key_id }}</td> <td class="mono fs-xs">{{ key.key_id }}</td>
<td class="fs-sm">{{ key.label }} <td class="fs-sm">{{ key.label }}
<div class="fs-xs faint">{{ key.added_at | dt }}</div></td> <div class="fs-xs faint">{{ key.added_at | dt }}</div></td>
<td class="fs-xs">{{ key.source }}</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"> <td class="actions">
{% if current_user.is_admin %} {% if current_user.is_admin %}
<form method="post" data-no-guard <form method="post" data-no-guard
action="{{ url_for('license.remove_trusted_key', key_id=key.key_id) }}"> action="{{ url_for('license.remove_trusted_key', key_id=key.key_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit" <button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Diesen Aussteller-Schluessel entfernen? Danach gilt die von ihm ausgestellte Lizenz als nicht vertrauenswuerdig." data-confirm="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"> data-confirm-title="Aussteller entfernen" data-confirm-ok="Entfernen">
{{ icon('trash', size=14) }}</button> {{ icon('trash', size=14) }}</button>
</form> </form>
@@ -277,7 +290,7 @@
</div> </div>
{% else %} {% else %}
{{ empty_state('Kein Aussteller gepinnt', {{ empty_state('Kein Aussteller gepinnt',
'Beim ersten Import wird der Schluessel des Lizenzservers uebernommen und danach 'Beim ersten Import wird der Schlüssel des Lizenzservers übernommen und danach
erwartet.', 'shield') }} erwartet.', 'shield') }}
{% endif %} {% endif %}
</section> </section>
@@ -303,7 +316,7 @@
</ul> </ul>
<p class="fs-xs faint mt-3"> <p class="fs-xs faint mt-3">
Diese Liste ist die einzige Wahrheit: was hier steht, wird serverseitig durchgesetzt -- Diese Liste ist die einzige Wahrheit: was hier steht, wird serverseitig durchgesetzt --
nicht nur in der Oberflaeche ausgegraut. nicht nur in der Oberfläche ausgegraut.
</p> </p>
</div> </div>
</section> </section>
@@ -1,6 +1,6 @@
{% extends "tesm_core/base.html" %} {% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import alert %} {% from "tesm_core/macros.html" import alert %}
{% block page_title %}Kea-DHCP-Protokoll{% endblock %} {% block page_title %}DHCP-Protokoll{% endblock %}
{% block page_sub %}{{ path }}{% endblock %} {% block page_sub %}{{ path }}{% endblock %}
{% block content %} {% block content %}
{% if error %}{{ alert(error, 'warning') }}{% endif %} {% if error %}{{ alert(error, 'warning') }}{% endif %}
@@ -2,11 +2,11 @@
{% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %} {% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %}
{% block page_title %}Neustarts{% endblock %} {% block page_title %}Neustarts{% endblock %}
{% block page_sub %}{{ rows | length }} Neustarts (neueste zuerst){% endblock %} {% block page_sub %}{{ rows | length }} Neustarts (neueste zuerst){% endblock %}
{% block topbar_extra %}{{ search_box('restart-table', 'Geraet, Ergebnis, Methode ...') }}{% endblock %} {% block topbar_extra %}{{ search_box('restart-table', 'Gerät, Ergebnis, Methode ...') }}{% endblock %}
{% block content %} {% block content %}
{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Ausloeser festgehalten -- automatisch {{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Auslöser festgehalten -- automatisch
wie manuell, ueber den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung. wie manuell, über den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung.
Der Vorgaenger protokollierte Neustarts ausdruecklich nicht.', 'info') }} Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
@@ -15,7 +15,7 @@ Der Vorgaenger protokollierte Neustarts ausdruecklich nicht.', 'info') }}
<div class="card__sub"> <div class="card__sub">
{% for value, label in methods %} {% for value, label in methods %}
<a class="btn btn--sm {{ 'btn--ghost' if method != value else '' }}" <a class="btn btn--sm {{ 'btn--ghost' if method != value else '' }}"
href="{{ url_for('tesmlogs.restarts', methode=value) }}">{{ label }}</a> href="{{ url_for('tesmlogs.restarts', method=value) }}">{{ label }}</a>
{% endfor %} {% endfor %}
<a class="btn btn--sm {{ 'btn--ghost' if method else '' }}" <a class="btn btn--sm {{ 'btn--ghost' if method else '' }}"
href="{{ url_for('tesmlogs.restarts') }}">Alle</a> href="{{ url_for('tesmlogs.restarts') }}">Alle</a>
@@ -25,8 +25,8 @@ Der Vorgaenger protokollierte Neustarts ausdruecklich nicht.', 'info') }}
<div class="table-wrap"> <div class="table-wrap">
<table class="table table--compact" id="restart-table" data-behavior="sortable"> <table class="table table--compact" id="restart-table" data-behavior="sortable">
<thead><tr> <thead><tr>
<th class="sortable">Zeit</th><th class="sortable">Geraet</th> <th class="sortable">Zeit</th><th class="sortable">Gerät</th>
<th class="sortable">Methode</th><th class="sortable">Ausloeser</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">Benutzer</th><th class="sortable">Ergebnis</th>
<th class="sortable numeric">Dauer</th><th>Detail</th> <th class="sortable numeric">Dauer</th><th>Detail</th>
</tr></thead> </tr></thead>
@@ -36,17 +36,22 @@ Der Vorgaenger protokollierte Neustarts ausdruecklich nicht.', 'info') }}
<td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td> <td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td>
<td class="fw-600 fs-sm">{{ row.device_name or '&ndash;' | safe }}</td> <td class="fw-600 fs-sm">{{ row.device_name or '&ndash;' | safe }}</td>
<td class="fs-sm"> <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') }} {% if row.method == 'ssh' %}{{ pill('SSH', 'info') }}
{% else %}{{ pill('PoE-Port', '') }}{% endif %} {% elif row.method == 'rpc' %}{{ pill('RPC', 'accent') }}
{% elif row.method == 'pö' %}{{ pill('PoE-Port', '') }}
{% else %}{{ pill(row.method or 'unbekannt', 'warning') }}{% endif %}
</td> </td>
<td class="fs-sm">{{ row.trigger }}</td> <td class="fs-sm">{{ row.trigger }}</td>
<td class="fs-sm">{{ row.actor }}</td> <td class="fs-sm">{{ row.actor }}</td>
<td> <td>
{% if row.result == 'ok' %}{{ pill('erfolgreich', 'online') }} {% if row.result == 'ok' %}{{ pill('erfolgreich', 'online') }}
{% elif row.result == 'skipped' %}{{ pill('uebersprungen', 'warning') }} {% elif row.result == 'skipped' %}{{ pill('übersprungen', 'warning') }}
{% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %} {% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %}
</td> </td>
<td class="numeric fs-sm">{{ row.duration_ms or 0 }} ms</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> <td class="fs-xs muted break-all" style="max-width:420px">{{ row.detail }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -3,29 +3,29 @@
{% block page_title %}Wartung{% endblock %} {% block page_title %}Wartung{% endblock %}
{% block page_sub %} {% block page_sub %}
{{ devices | length }} Geraete mit Zugangsdaten der Kategorie {{ devices | length }} Geräte mit Zugangsdaten der Kategorie
&bdquo;Linux-Client&ldquo; oder &bdquo;Windows-Client&ldquo; &bdquo;Linux-Client&ldquo; oder &bdquo;Windows-Client&ldquo;
{% endblock %} {% endblock %}
{% block topbar_extra %}{{ search_box('maintenance-table', 'Geraet suchen ...') }}{% endblock %} {% block topbar_extra %}{{ search_box('maintenance-table', 'Gerät suchen ...') }}{% endblock %}
{% block content %} {% block content %}
{% if devices | rejectattr('host_key_ready') | list %} {% if devices | rejectattr('host_key_ready') | list %}
{{ alert('Fuer mindestens ein Geraet ist der SSH-Host-Schluessel noch nicht freigegeben. {{ alert('Für mindestens ein Gerät ist der SSH-Host-Schlüssel noch nicht freigegeben.
Die Wartung bricht dort mit "Schluessel unbekannt" ab. Die Freigabe erfolgt auf der Die Wartung bricht dort mit "Schlüssel unbekannt" ab. Die Freigabe erfolgt auf der
Detailseite des Geraets -- erst auslesen, Fingerprint pruefen, dann freigeben.', Detailseite des Geräts -- erst auslesen, Fingerprint prüfen, dann freigeben.',
'warning', 'Host-Schluessel offen') }} 'warning', 'Host-Schlüssel offen') }}
{% endif %} {% endif %}
{% if windows_tool_missing %} {% if windows_tool_missing %}
{# Die Meldung im Auftrag nennt das fehlende Paket -- ohne diesen Knopf waere sie {# Die Meldung im Auftrag nennt das fehlende Paket -- ohne diesen Knopf wäre sie
eine Sackgasse. #} eine Sackgasse. #}
<div class="alert alert--warning"> <div class="alert alert--warning">
{{ icon('alert', size=18) }} {{ icon('alert', size=18) }}
<div class="alert__body"> <div class="alert__body">
<strong>Windows-Neustart nicht moeglich</strong> <strong>Windows-Neustart nicht möglich</strong>
Auf diesem Server fehlt das Paket <code>{{ windows_package }}</code>. Es bringt Auf diesem Server fehlt das Paket <code>{{ windows_package }}</code>. Es bringt
<code>net</code> mit, ueber das der Neustart eines Windows-Geraetes laeuft. <code>net</code> mit, über das der Neustart eines Windows-Gerätes läuft.
</div> </div>
<form method="post" action="{{ url_for('maintenance.install_windows_tool') }}"> <form method="post" action="{{ url_for('maintenance.install_windows_tool') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -38,10 +38,10 @@
</div> </div>
{% endif %} {% endif %}
{{ alert('Welche Geraete hier erscheinen und was mit ihnen moeglich ist, entscheidet die {{ alert('Welche Geräte hier erscheinen und was mit ihnen möglich ist, entscheidet die
Kategorie ihrer Zugangsdaten. Linux-Clients werden ueber SSH aktualisiert und neu gestartet. Kategorie ihrer Zugangsdaten. Linux-Clients werden über SSH aktualisiert und neu gestartet.
Windows-Clients werden nur neu gestartet, und zwar ueber RPC (wie "shutdown /m") -- sie Windows-Clients werden nur neu gestartet, und zwar über RPC (wie "shutdown /m") -- sie
brauchen dafuer weder SSH noch einen Host-Schluessel, sondern die offene Datei- und 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.', Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Port.',
'info') }} 'info') }}
@@ -51,20 +51,20 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<div class="table-toolbar"> <div class="table-toolbar">
<label class="check"> <label class="check">
<input type="checkbox" id="select-all"> <input type="checkbox" id="select-all">
<span>Alle auswaehlen</span> <span>Alle auswählen</span>
</label> </label>
<span class="table-toolbar__spacer"></span> <span class="table-toolbar__spacer"></span>
{% if current_user.has('maintenance.execute') %} {% if current_user.has('servicing_update.execute') %}
<button class="btn btn--sm btn--primary" type="submit" <button class="btn btn--sm btn--primary" type="submit"
formaction="{{ url_for('maintenance.start_update') }}" formaction="{{ url_for('maintenance.start_update') }}"
title="Nur fuer Linux-Clients -- Windows-Geraete werden dabei uebersprungen." title="Nur für Linux-Clients -- Windows-Geräte werden dabei übersprungen."
data-confirm="Paketaktualisierung auf den ausgewaehlten Geraeten starten?" data-confirm="Paketaktualisierung auf den ausgewählten Geräten starten?"
data-confirm-title="Aktualisieren" data-confirm-ok="Starten" data-confirm-style="primary"> data-confirm-title="Aktualisieren" data-confirm-ok="Starten" data-confirm-style="primary">
{{ icon('download', size=15) }} Aktualisieren {{ icon('download', size=15) }} Aktualisieren
</button> </button>
<button class="btn btn--sm btn--danger" type="submit" <button class="btn btn--sm btn--danger" type="submit"
formaction="{{ url_for('maintenance.start_reboot') }}" formaction="{{ url_for('maintenance.start_reboot') }}"
data-confirm="Die ausgewaehlten Geraete jetzt neu starten? Sie sind dabei kurzzeitig nicht erreichbar." 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"> data-confirm-title="Neustarten" data-confirm-ok="Neu starten">
{{ icon('refresh', size=15) }} Neustarten {{ icon('refresh', size=15) }} Neustarten
</button> </button>
@@ -76,11 +76,11 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<thead> <thead>
<tr> <tr>
<th style="width:44px"></th> <th style="width:44px"></th>
<th class="sortable">Geraet</th> <th class="sortable">Gerät</th>
<th class="sortable">IP</th> <th class="sortable">IP</th>
<th class="sortable">Zustand</th> <th class="sortable">Zustand</th>
<th class="sortable">Zugangsdaten</th> <th class="sortable">Zugangsdaten</th>
<th class="sortable">Host-Schluessel</th> <th class="sortable">Host-Schlüssel</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -91,8 +91,8 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<label class="check"> <label class="check">
<input type="checkbox" name="device_ids" value="{{ device.id }}" <input type="checkbox" name="device_ids" value="{{ device.id }}"
class="device-select" data-category="{{ device.category }}" class="device-select" data-category="{{ device.category }}"
{% if not current_user.has('maintenance.execute') %}disabled{% endif %}> {% if not current_user.has('servicing_update.execute') %}disabled{% endif %}>
<span class="sr-only">{{ device.name }} auswaehlen</span> <span class="sr-only">{{ device.name }} auswählen</span>
</label> </label>
</td> </td>
<td class="fw-600">{{ device.name }}</td> <td class="fw-600">{{ device.name }}</td>
@@ -108,20 +108,20 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
</td> </td>
<td> <td>
{% if not device.ssh_managed %} {% if not device.ssh_managed %}
<span class="faint fs-xs" title="Windows wird ueber RPC verwaltet, nicht ueber SSH"> <span class="faint fs-xs" title="Windows wird über RPC verwaltet, nicht über SSH">
nicht noetig nicht nötig
</span> </span>
{% elif device.host_key_ready %}{{ pill('freigegeben', 'online') }} {% elif device.host_key_ready %}{{ pill('freigegeben', 'online') }}
{% else %} {% else %}
<a href="{{ url_for('devices.detail', device_id=device.id) }}" <a href="{{ url_for('devices.detail', device_id=device.id) }}"
title="Ohne freigegebenen Schluessel bricht die Wartung ab"> title="Ohne freigegebenen Schlüssel bricht die Wartung ab">
{{ pill('offen', 'danger') }} {{ pill('offen', 'danger') }}
</a> </a>
{% endif %} {% endif %}
</td> </td>
<td class="nowrap"> <td class="nowrap">
{% if terminal_enabled and device.ssh_managed {% if terminal_enabled and device.ssh_managed
and current_user.has('maintenance.execute') %} and current_user.has('servicing_update.execute') %}
<a class="btn btn--sm btn--ghost" <a class="btn btn--sm btn--ghost"
href="{{ url_for('terminal.device_console', device_id=device.id) }}"> href="{{ url_for('terminal.device_console', device_id=device.id) }}">
{{ icon('terminal', size=14) }} Konsole {{ icon('terminal', size=14) }} Konsole
@@ -134,9 +134,9 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
</table> </table>
</div> </div>
{% if not devices %} {% if not devices %}
{{ empty_state('Keine passenden Geraete', {{ empty_state('Keine passenden Geräte',
'Setzen Sie bei den Zugangsdaten die Kategorie "Linux-Client" und ordnen Sie 'Setzen Sie bei den Zugangsdaten die Kategorie "Linux-Client" und ordnen Sie
sie einem Geraet zu.', 'wrench') }} sie einem Gerät zu.', 'wrench') }}
{% endif %} {% endif %}
</section> </section>
</form> </form>
@@ -144,21 +144,21 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{# Kein data-refresh-url: die Schleife in app.js schreibt die Antwort in {# Kein data-refresh-url: die Schleife in app.js schreibt die Antwort in
innerHTML. Hier zeigte sie auf die Seite selbst -- die komplette innerHTML. Hier zeigte sie auf die Seite selbst -- die komplette
Wartungsseite landete damit in dieser Karte, mitsamt Seitenleiste und Wartungsseite landete damit in dieser Karte, mitsamt Seitenleiste und
Suchfeld. Aktualisiert wird ueber die Abfrage unten, die bei laufenden Suchfeld. Aktualisiert wird über die Abfrage unten, die bei laufenden
Auftraegen die ganze Seite neu laedt. #} Aufträgen die ganze Seite neu lädt. #}
<section class="card" id="jobs"> <section class="card" id="jobs">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('clock', size=17) }} Auftraege</div> <div class="card__title">{{ icon('clock', size=17) }} Aufträge</div>
<div class="card__sub"> <div class="card__sub">
Zustand liegt in der Datenbank -- ein Dienst-Neustart verliert ihn nicht mehr Zustand liegt in der Datenbank – ein Dienst-Neustart verliert ihn nicht mehr
</div> </div>
</div> </div>
</div> </div>
{% if jobs %} {% if jobs %}
<div class="table-wrap"> <div class="table-wrap">
<table class="table table--compact"> <table class="table table--compact">
<thead><tr><th>Gestartet</th><th>Geraet</th><th>Art</th><th>Zustand</th><th>Fortschritt</th><th>Meldung</th></tr></thead> <thead><tr><th>Gestartet</th><th>Gerät</th><th>Art</th><th>Zustand</th><th>Fortschritt</th><th>Meldung</th></tr></thead>
<tbody> <tbody>
{% for job in jobs %} {% for job in jobs %}
<tr> <tr>
@@ -170,7 +170,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<td> <td>
{% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }} {% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }}
{% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }} {% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }}
{% elif job.state == 'running' %}{{ pill('laeuft', 'info') }} {% elif job.state == 'running' %}{{ pill('läuft', 'info') }}
{% elif job.state == 'unknown' %}{{ pill('unklar', 'warning') }} {% elif job.state == 'unknown' %}{{ pill('unklar', 'warning') }}
{% else %}{{ pill('wartet', '') }}{% endif %} {% else %}{{ pill('wartet', '') }}{% endif %}
</td> </td>
@@ -186,16 +186,16 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
</table> </table>
</div> </div>
{% else %} {% else %}
{{ empty_state('Keine Auftraege', 'Es wurde noch keine Wartung gestartet.', 'clock') }} {{ empty_state('Keine Aufträge', 'Es wurde noch keine Wartung gestartet.', 'clock') }}
{% endif %} {% endif %}
</section> </section>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script type="module" nonce="{{ csp_nonce }}"> <script type="module" nonce="{{ csp_nonce }}">
// Auswahl beim Laden bewusst zuruecksetzen: Browser stellen den Checked-Zustand // Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand
// sonst aus dem Verlauf wieder her -- und dann wird bei "Neustarten" // sonst aus dem Verlauf wieder her – und dann wird bei "Neustarten"
// versehentlich das falsche Geraet erwischt. // versehentlich das falsche Gerät erwischt.
const boxes = Array.from(document.querySelectorAll(".device-select")); const boxes = Array.from(document.querySelectorAll(".device-select"));
boxes.forEach((box) => (box.checked = false)); boxes.forEach((box) => (box.checked = false));
@@ -205,13 +205,13 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
.forEach((box) => (box.checked = all.checked)); .forEach((box) => (box.checked = all.checked));
}); });
// Auftragsliste nachladen, solange etwas laeuft. // Auftragsliste nachladen, solange etwas läuft.
async function poll() { async function poll() {
try { try {
const state = await window.tesm.api("{{ url_for('maintenance.status') }}"); const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
if (state.running) window.setTimeout(() => location.reload(), state.poll_ms); if (state.running) window.setTimeout(() => location.reload(), state.poll_ms);
} catch { } catch {
/* stumm bleiben -- die Seite ist auch ohne Aktualisierung nutzbar */ /* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */
} }
} }
poll(); poll();
@@ -15,7 +15,7 @@
{{ icon('wrench', size=17) }} Auftrag {{ icon('wrench', size=17) }} Auftrag
{% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }} {% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }}
{% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }} {% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }}
{% elif job.state == 'running' %}{{ pill('laeuft', 'info') }} {% elif job.state == 'running' %}{{ pill('läuft', 'info') }}
{% elif job.state == 'unknown' %}{{ pill('unklar', 'warning') }} {% elif job.state == 'unknown' %}{{ pill('unklar', 'warning') }}
{% else %}{{ pill('wartet', '') }}{% endif %} {% else %}{{ pill('wartet', '') }}{% endif %}
</div> </div>
@@ -37,14 +37,14 @@
</div> </div>
</section> </section>
<div class="row row--end"> <div class="row row--end">
<a class="btn" href="{{ url_for('maintenance.overview') }}">Zurueck</a> <a class="btn" href="{{ url_for('maintenance.overview') }}">Zurück</a>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script type="module" nonce="{{ csp_nonce }}"> <script type="module" nonce="{{ csp_nonce }}">
// Beim Oeffnen ans Ende springen: bei einem laufenden Auftrag ist die // Beim Öffnen ans Ende springen: bei einem laufenden Auftrag ist die
// juengste Zeile die interessante, nicht die erste. Das Nachfuehren beim // jüngste Zeile die interessante, nicht die erste. Das Nachführen beim
// Aktualisieren erledigt die Schleife in app.js. // Aktualisieren erledigt die Schleife in app.js.
const view = document.querySelector("#job-output .log-view"); const view = document.querySelector("#job-output .log-view");
if (view) view.scrollTop = view.scrollHeight; if (view) view.scrollTop = view.scrollHeight;
@@ -0,0 +1,99 @@
{% 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 %}
Angebotene Anmeldeverfahren:
{% for nummer in zugang.verfahren %}{{ verfahren_name(nummer) }}{% if not loop.last %}, {% endif %}{% endfor %}.
{% 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>
<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 %}
@@ -1,13 +1,13 @@
{% extends "tesm_core/base.html" %} {% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill %} {% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill, nav_order_card %}
{% block page_title %}Systemeinstellungen{% endblock %} {% block page_title %}Systemeinstellungen{% endblock %}
{% block page_sub %}Ueberwachung, Protokolle, Host, Navigation{% endblock %} {% block page_sub %}Überwachung, Protokolle, Host, Navigation{% endblock %}
{% block content %} {% block content %}
{% if not sysops_available %} {% if not sysops_available %}
{{ alert('Systemhelfer nicht verfuegbar -- Hostname und Zeitzone koennen hier nur angezeigt, {{ alert('Systemhelfer nicht verfügbar -- Hostname und Zeitzone können hier nur angezeigt,
nicht geaendert werden. In einer Entwicklungsumgebung ist das der erwartete Zustand.', nicht geändert werden. In einer Entwicklungsumgebung ist das der erwartete Zustand.',
'info') }} 'info') }}
{% endif %} {% endif %}
@@ -16,13 +16,13 @@
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('activity', size=17) }} Ueberwachung</div> <div class="card__title">{{ icon('activity', size=17) }} Überwachung</div>
<div class="card__sub">Wie oft geprueft und wann automatisch geschaltet wird</div> <div class="card__sub">Wie oft geprüft und wann automatisch geschaltet wird</div>
</div> </div>
{% if current_user.has('settings_system.execute') %} {% if current_user.has('settings_system.execute') %}
<form method="post" action="{{ url_for('settings.run_check_now') }}" data-no-guard> <form method="post" action="{{ url_for('settings.run_check_now') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Jetzt pruefen</button> <button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Jetzt prüfen</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>
@@ -31,7 +31,7 @@
<div class="card__body stack"> <div class="card__body stack">
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %} {% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
<div class="form-grid"> <div class="form-grid">
{{ field('monitor_interval_seconds', 'Pruefintervall (Sekunden)', {{ field('monitor_interval_seconds', 'Prüfintervall (Sekunden)',
value=values.monitor_interval_seconds, type='number', value=values.monitor_interval_seconds, type='number',
disabled=bool(license_hint), disabled=bool(license_hint),
hint_text=specs['monitor_interval_seconds'].hint) }} hint_text=specs['monitor_interval_seconds'].hint) }}
@@ -41,11 +41,77 @@
</div> </div>
{{ checkbox('monitor_auto_restart', 'Automatischen PoE-Neustart bei Ausfall aktivieren', {{ checkbox('monitor_auto_restart', 'Automatischen PoE-Neustart bei Ausfall aktivieren',
checked=values.monitor_auto_restart, checked=values.monitor_auto_restart,
hint_text='Zusaetzlich lizenzpflichtig. Der manuelle Knopf im Dashboard hint_text='Zusätzlich lizenzpflichtig. Der manuelle Knopf im Dashboard
funktioniert unabhaengig davon.') }} funktioniert unabhängig davon.') }}
{{ checkbox('dashboard_public', 'Statusuebersicht ohne Anmeldung erreichbar', {{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
checked=values.dashboard_public, checked=values.dashboard_public,
hint_text=specs['dashboard_public'].hint) }} 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>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div> <div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form> </form>
@@ -54,20 +120,52 @@
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('terminal', size=17) }} Switch-Konsole</div> <div class="card__title">{{ icon('pencil', size=17) }} Unterschriften</div>
<div class="card__sub"> <div class="card__sub">
Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestaetigung, Der Name in der Unterschrift ist eine Angabe des Unterschreibenden – wie auf
vollstaendige Mitschrift 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> </div>
</div> </div>
<form method="post" action="{{ url_for('settings.system_save') }}"> <form method="post" action="{{ url_for('settings.system_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack"> <div class="card__body stack">
{{ checkbox('terminal_enabled', 'Browser-Konsole fuer Switche zulassen', {{ checkbox('signature_witness_enabled', 'Unterschriften mit dem ablegenden Konto bezeugen',
checked=values.terminal_enabled) }} checked=values.signature_witness_enabled,
{{ field('terminal_idle_timeout', 'Trennen bei Untaetigkeit (Sekunden)', hint_text=specs['signature_witness_enabled'].hint) }}
value=values.terminal_idle_timeout, type='number') }} </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>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div> <div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form> </form>
@@ -130,39 +228,8 @@
</section> </section>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<section class="card"> {{ nav_order_card(nav_baum, nav_order, nav_child_order,
<div class="card__header"> url_for('admin.nav_order'), csrf_field_name, csrf_token()) }}
<div class="flex-1">
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
<div class="card__sub">Gilt fuer alle Benutzer; jeder sieht weiter nur, was er darf</div>
</div>
</div>
<form method="post" action="{{ url_for('admin.nav_order') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<ol class="stack stack--tight">
{% for key in nav_order %}
{% set item = nav_items | selectattr('key', 'equalto', key) | list | first %}
{% if item %}
<li class="row row--tight">
<input class="input" type="number" name="position_{{ item.key }}" value="{{ loop.index }}"
min="1" style="width:70px" disabled>
<input type="hidden" name="order" value="{{ item.key }}">
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
</li>
{% endif %}
{% endfor %}
</ol>
<p class="fs-xs faint mt-3">
Die Reihenfolge folgt der Liste. Zum Umsortieren die Eintraege in der gewuenschten
Reihenfolge speichern (Drag-and-drop folgt in einer spaeteren Version).
</p>
</div>
<div class="card__footer">
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
</div>
</form>
</section>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -18,6 +18,6 @@
<pre class="log-view" id="cmd-output">{{ output }}</pre> <pre class="log-view" id="cmd-output">{{ output }}</pre>
</section> </section>
<div class="row row--end"> <div class="row row--end">
<a class="btn" href="{{ url_for('switches.detail', switch_id=switch.id) }}">Zurueck</a> <a class="btn" href="{{ url_for('switches.detail', switch_id=switch.id) }}">Zurück</a>
</div> </div>
{% endblock %} {% endblock %}
@@ -11,7 +11,7 @@
{% endblock %} {% endblock %}
{% block topbar_extra %} {% block topbar_extra %}
{% if terminal_enabled and current_user.has('switches.execute') and host_keys %} {% 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) }}"> <a class="btn btn--sm btn--primary" href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
{{ icon('terminal', size=15) }} Konsole {{ icon('terminal', size=15) }} Konsole
</a> </a>
@@ -25,14 +25,14 @@
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title"> <div class="card__title">
{{ icon('lock', size=17) }} SSH-Host-Schluessel {{ icon('lock', size=17) }} SSH-Host-Schlüssel
{% if host_keys %}{{ pill('freigegeben', 'online') }}{% else %}{{ pill('offen', 'danger') }}{% endif %} {% if host_keys %}{{ pill('freigegeben', 'online') }}{% else %}{{ pill('offen', 'danger') }}{% endif %}
</div> </div>
<div class="card__sub"> <div class="card__sub">
Ohne freigegebenen Schluessel verweigert die Anwendung jeden Zugriff auf diesen Switch. Ohne freigegebenen Schlüssel verweigert die Anwendung jeden Zugriff auf diesen Switch.
</div> </div>
</div> </div>
{% if current_user.has('switches.edit') %} {% if current_user.has('switches.write') %}
<form method="post" action="{{ url_for('switches.probe_host_key', switch_id=switch.id) }}" <form method="post" action="{{ url_for('switches.probe_host_key', switch_id=switch.id) }}"
data-no-guard> data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -45,23 +45,23 @@
<div class="alert alert--warning"> <div class="alert alert--warning">
{{ icon('alert', size=18) }} {{ icon('alert', size=18) }}
<div class="alert__body"> <div class="alert__body">
<div class="alert__title">Gelesener Schluessel</div> <div class="alert__title">Gelesener Schlüssel</div>
<div class="mono fs-sm break-all"> <div class="mono fs-sm break-all">
{{ request.args.get('probed_type') }} {{ request.args.get('probed_fingerprint') }} {{ request.args.get('probed_type') }} {{ request.args.get('probed_fingerprint') }}
</div> </div>
<p class="fs-sm mt-2"> <p class="fs-sm mt-2">
Vergleichen Sie den Fingerprint mit einer unabhaengigen Quelle (Konsolenzugang am Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle (Konsolenzugang am
Switch, Inventardokumentation), bevor Sie ihn freigeben. Switch, Inventardokumentation), bevor Sie ihn freigeben.
</p> </p>
{% if current_user.has('switches.edit') %} {% if current_user.has('switches.write') %}
<form method="post" class="mt-2" <form method="post" class="mt-2"
action="{{ url_for('switches.trust_host_key', switch_id=switch.id) }}"> 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="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="key_type" value="{{ request.args.get('probed_type') }}"> <input type="hidden" name="key_type" value="{{ request.args.get('probed_type') }}">
<input type="hidden" name="fingerprint" value="{{ request.args.get('probed_fingerprint') }}"> <input type="hidden" name="fingerprint" value="{{ request.args.get('probed_fingerprint') }}">
<button class="btn btn--sm btn--primary" type="submit" <button class="btn btn--sm btn--primary" type="submit"
data-confirm="Diesen Fingerprint dauerhaft als vertrauenswuerdig hinterlegen?" data-confirm="Diesen Fingerprint dauerhaft als vertrauenswürdig hinterlegen?"
data-confirm-title="Host-Schluessel freigeben" data-confirm-ok="Freigeben" data-confirm-title="Host-Schlüssel freigeben" data-confirm-ok="Freigeben"
data-confirm-style="primary">Freigeben</button> data-confirm-style="primary">Freigeben</button>
</form> </form>
{% endif %} {% endif %}
@@ -78,30 +78,30 @@
</dd> </dd>
{% endfor %} {% endfor %}
</dl> </dl>
{% if current_user.has('switches.edit') %} {% if current_user.has('switches.write') %}
<form method="post" action="{{ url_for('switches.forget_host_key', switch_id=switch.id) }}" <form method="post" action="{{ url_for('switches.forget_host_key', switch_id=switch.id) }}"
data-no-guard> data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit" <button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Gespeicherte Host-Schluessel entfernen? Danach ist kein Zugriff mehr moeglich, bis erneut freigegeben wird." data-confirm="Gespeicherte Host-Schlüssel entfernen? Danach ist kein Zugriff mehr möglich, bis erneut freigegeben wird."
data-confirm-title="Schluessel verwerfen" data-confirm-ok="Verwerfen"> data-confirm-title="Schlüssel verwerfen" data-confirm-ok="Verwerfen">
Schluessel verwerfen Schlüssel verwerfen
</button> </button>
</form> </form>
{% endif %} {% endif %}
{% else %} {% else %}
<p class="muted fs-sm">Noch kein Schluessel hinterlegt. Bitte auslesen und pruefen.</p> <p class="muted fs-sm">Noch kein Schlüssel hinterlegt. Bitte auslesen und prüfen.</p>
{% endif %} {% endif %}
</div> </div>
</section> </section>
{% if current_user.has('switches.execute') %} {% if current_user.has('switch_console.execute') %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('terminal', size=17) }} Kommando ausfuehren</div> <div class="card__title">{{ icon('terminal', size=17) }} Kommando ausführen</div>
<div class="card__sub"> <div class="card__sub">
Positivliste lesender Kommandos &ndash; verlangt eine frische Passwortbestaetigung Positivliste lesender Kommandos &ndash; verlangt eine frische Passwortbestätigung
</div> </div>
</div> </div>
</div> </div>
@@ -118,8 +118,8 @@
</label> </label>
</div> </div>
<div class="card__footer"> <div class="card__footer">
<button class="btn btn--primary" type="submit" data-busy-label="Fuehre aus ..."> <button class="btn btn--primary" type="submit" data-busy-label="Führe aus ...">
Ausfuehren Ausführen
</button> </button>
</div> </div>
</form> </form>
@@ -140,7 +140,7 @@
<dt>Zugangsdaten</dt><dd>{{ switch.credential_name or 'keine' }}</dd> <dt>Zugangsdaten</dt><dd>{{ switch.credential_name or 'keine' }}</dd>
<dt>Standort</dt><dd>{{ switch.location or '&ndash;' | safe }}</dd> <dt>Standort</dt><dd>{{ switch.location or '&ndash;' | safe }}</dd>
<dt>Zuletzt gesehen</dt><dd>{{ switch.last_seen_at | dt }}</dd> <dt>Zuletzt gesehen</dt><dd>{{ switch.last_seen_at | dt }}</dd>
<dt>Geaendert</dt><dd>{{ switch.updated_at | dt }} <span class="faint">von {{ switch.updated_by or '-' }}</span></dd> <dt>Geändert</dt><dd>{{ switch.updated_at | dt }} <span class="faint">von {{ switch.updated_by or '-' }}</span></dd>
</dl> </dl>
{% if switch.notes %}<p class="fs-sm muted mt-3">{{ switch.notes }}</p>{% endif %} {% if switch.notes %}<p class="fs-sm muted mt-3">{{ switch.notes }}</p>{% endif %}
</div> </div>
@@ -149,14 +149,14 @@
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('cpu', size=17) }} Angeschlossene Geraete</div> <div class="card__title">{{ icon('cpu', size=17) }} Angeschlossene Geräte</div>
<div class="card__sub">{{ devices | length }} zugeordnet</div> <div class="card__sub">{{ devices | length }} zugeordnet</div>
</div> </div>
</div> </div>
{% if devices %} {% if devices %}
<div class="table-wrap"> <div class="table-wrap">
<table class="table table--compact"> <table class="table table--compact">
<thead><tr><th>Port</th><th>Geraet</th><th>Zustand</th></tr></thead> <thead><tr><th>Port</th><th>Gerät</th><th>Zustand</th></tr></thead>
<tbody> <tbody>
{% for device in devices %} {% for device in devices %}
<tr> <tr>
@@ -176,7 +176,7 @@
</table> </table>
</div> </div>
{% else %} {% else %}
{{ empty_state('Keine Geraete', 'Diesem Switch ist noch kein Geraet zugeordnet.', 'cpu') }} {{ empty_state('Keine Geräte', 'Diesem Switch ist noch kein Gerät zugeordnet.', 'cpu') }}
{% endif %} {% endif %}
</section> </section>
</div> </div>
+15 -37
View File
@@ -39,9 +39,9 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{{ alert('Vor dem ersten Zugriff muss der SSH-Host-Schluessel eines Switches ausdruecklich {{ alert('Vor dem ersten Zugriff muss der SSH-Host-Schlüssel eines Switches ausdrücklich
freigegeben werden. Unbekannte oder geaenderte Schluessel werden abgewiesen -- nicht freigegeben werden. Unbekannte oder geänderte Schlüssel werden abgewiesen -- nicht
stillschweigend uebernommen.', 'info', 'Host-Schluessel') }} stillschweigend übernommen.', 'info', 'Host-Schlüssel') }}
<section class="card"> <section class="card">
<div class="table-wrap"> <div class="table-wrap">
@@ -52,8 +52,8 @@ stillschweigend uebernommen.', 'info', 'Host-Schluessel') }}
<th class="sortable">IP</th> <th class="sortable">IP</th>
<th class="sortable numeric">Port</th> <th class="sortable numeric">Port</th>
<th class="sortable">Zugangsdaten</th> <th class="sortable">Zugangsdaten</th>
<th class="sortable numeric">Geraete</th> <th class="sortable numeric">Geräte</th>
<th class="sortable">Host-Schluessel</th> <th class="sortable">Host-Schlüssel</th>
<th class="actions">Aktion</th> <th class="actions">Aktion</th>
</tr> </tr>
</thead> </thead>
@@ -83,13 +83,13 @@ stillschweigend uebernommen.', 'info', 'Host-Schluessel') }}
{% endif %} {% endif %}
</td> </td>
<td class="actions"> <td class="actions">
{% if terminal_enabled and current_user.has('switches.execute') and switch.host_key_known %} {% if terminal_enabled and current_user.has('switch_console.execute') and switch.host_key_known %}
<a class="btn btn--sm btn--ghost" title="Konsole" <a class="btn btn--sm btn--ghost" title="Konsole"
href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}"> href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
{{ icon('terminal', size=15) }} {{ icon('terminal', size=15) }}
</a> </a>
{% endif %} {% endif %}
{% if current_user.has('switches.edit') %} {% if current_user.has('switches.write') %}
<button class="btn btn--sm btn--ghost" type="button" <button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="switch-edit-{{ switch.id }}" aria-label="Bearbeiten"> data-dialog-open="switch-edit-{{ switch.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }} {{ icon('pencil', size=15) }}
@@ -99,9 +99,9 @@ stillschweigend uebernommen.', 'info', 'Host-Schluessel') }}
<form method="post" action="{{ url_for('switches.delete', switch_id=switch.id) }}" <form method="post" action="{{ url_for('switches.delete', switch_id=switch.id) }}"
style="display:inline" data-no-guard> style="display:inline" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Loeschen" <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="{{ switch.hostname }} in den Papierkorb verschieben?"
data-confirm-title="Switch loeschen" data-confirm-ok="Loeschen"> data-confirm-title="Switch löschen" data-confirm-ok="Löschen">
{{ icon('trash', size=15) }}</button> {{ icon('trash', size=15) }}</button>
</form> </form>
{% endif %} {% endif %}
@@ -116,37 +116,15 @@ stillschweigend uebernommen.', 'info', 'Host-Schluessel') }}
{% endif %} {% endif %}
</section> </section>
{% if host_keys %} {# Die freigegebenen Host-Schlüssel standen hier ein zweites Mal. Gepflegt
<section class="card"> werden sie unter Überwachung -> Host-Schlüssel: dort mit Auslesen,
<div class="card__header"> Fingerprint-Vergleich, Freigeben, Verwerfen und Aufräumen. Eine reine
<div class="flex-1"> Zweitanzeige lädt dazu ein, sie an der falschen Stelle zu suchen. #}
<div class="card__title">{{ icon('lock', size=17) }} Freigegebene Host-Schluessel</div>
<div class="card__sub">{{ host_keys | length }} Eintraege</div>
</div>
</div>
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Host</th><th>Port</th><th>Typ</th><th>Fingerprint</th><th>Freigegeben</th></tr></thead>
<tbody>
{% for key in host_keys %}
<tr>
<td class="mono fs-sm">{{ key.host }}</td>
<td class="fs-sm">{{ key.port }}</td>
<td class="fs-sm">{{ key.key_type }}</td>
<td class="mono fs-xs break-all">{{ key.fingerprint }}</td>
<td class="fs-sm">{{ key.added_at | dt }} <span class="faint">von {{ key.added_by }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
{% endblock %} {% endblock %}
{% block dialogs %} {% block dialogs %}
{% if current_user.has('switches.create') %} {% if current_user.has('switches.create') %}
{% call modal('switch-new', 'Switch anlegen', 'Host-Schluessel wird danach freigegeben') %} {% call modal('switch-new', 'Switch anlegen', 'Host-Schlüssel wird danach freigegeben') %}
<form method="post" action="{{ url_for('switches.create') }}"> <form method="post" action="{{ url_for('switches.create') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">{{ switch_form() }}</div> <div class="modal__body">{{ switch_form() }}</div>
@@ -157,7 +135,7 @@ stillschweigend uebernommen.', 'info', 'Host-Schluessel') }}
</form> </form>
{% endcall %} {% endcall %}
{% endif %} {% endif %}
{% if current_user.has('switches.edit') %} {% if current_user.has('switches.write') %}
{% for switch in switches %} {% for switch in switches %}
{% call modal('switch-edit-' ~ switch.id, switch.hostname, switch.ip) %} {% call modal('switch-edit-' ~ switch.id, switch.hostname, switch.ip) %}
<form method="post" action="{{ url_for('switches.update', switch_id=switch.id) }}"> <form method="post" action="{{ url_for('switches.update', switch_id=switch.id) }}">
+15 -19
View File
@@ -22,12 +22,11 @@ from types import FrameType
from tesm_core.extension import core from tesm_core.extension import core
from . import create_app from . import create_app
from .services import fileshare, inventory, monitor from .services import inventory, monitor, vnc
logger = logging.getLogger("tesm.monitor") logger = logging.getLogger("tesm.monitor")
MIN_INTERVAL = 30 MIN_INTERVAL = 30
FILESHARE_SWEEP_EVERY = 30 * 60
class MonitorDaemon: class MonitorDaemon:
@@ -35,10 +34,9 @@ class MonitorDaemon:
self.app = create_app() self.app = create_app()
self.extension = core(self.app) self.extension = core(self.app)
self.running = True self.running = True
self._last_fileshare_sweep = 0.0
def stop(self, signum: int, _frame: FrameType | None) -> None: def stop(self, signum: int, _frame: FrameType | None) -> None:
logger.info("Signal %s empfangen -- beende nach dem laufenden Durchlauf.", signum) logger.info("Signal %s empfangen – beende nach dem laufenden Durchlauf.", signum)
self.running = False self.running = False
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -70,8 +68,8 @@ class MonitorDaemon:
def run(self) -> int: def run(self) -> int:
live = self.extension.registry["live_log"] live = self.extension.registry["live_log"]
live.info("monitor", "Ueberwachungsprozess gestartet") live.info("monitor", "Überwachungsprozess gestartet")
logger.info("Ueberwachung laeuft, Datenbank: %s", self.extension.config.db_path) logger.info("Überwachung läuft, Datenbank: %s", self.extension.config.db_path)
while self.running: while self.running:
interval = self._interval() interval = self._interval()
@@ -84,10 +82,11 @@ class MonitorDaemon:
keystore=self.extension.keystore, keystore=self.extension.keystore,
license_allows_auto_restart=self._auto_restart_allowed(), license_allows_auto_restart=self._auto_restart_allowed(),
failures_before_restart=self._failures_before_restart(), failures_before_restart=self._failures_before_restart(),
vnc_port=vnc.port_aus_einstellungen(self.extension),
) )
logger.info( logger.info(
"Durchlauf: %s geprueft, %s online, %s offline, %s Neustarts (%s erfolgreich), " "Durchlauf: %s geprüft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
"%s ohne Lizenz uebersprungen, %sms", "%s ohne Lizenz übersprungen, %sms",
report.checked, report.checked,
report.online, report.online,
report.offline, report.offline,
@@ -98,17 +97,14 @@ class MonitorDaemon:
) )
except Exception: # noqa: BLE001 - der Dienst darf nie sterben except Exception: # noqa: BLE001 - der Dienst darf nie sterben
logger.exception("Durchlauf fehlgeschlagen") logger.exception("Durchlauf fehlgeschlagen")
live.error("monitor", "Durchlauf fehlgeschlagen -- siehe app.log") live.error("monitor", "Durchlauf fehlgeschlagen – siehe app.log")
if time.monotonic() - self._last_fileshare_sweep > FILESHARE_SWEEP_EVERY: # Das Aufraeumen abgelaufener Freigabe-Einbindungen laeuft **nicht**
self._last_fileshare_sweep = time.monotonic() # hier, sondern im Webprozess: eingebunden wird in dessen
try: # Mount-Namensraum, und dieser Dienst sieht diese Einbindungen nicht
with self.app.app_context(): # einmal. Ein umount von hier waere ein Aufruf ins Leere -- die
released = fileshare.sweep_expired(self.extension.database) # Datenbankzeile als geloest markiert, die Freigabe aber weiter
if released: # eingebunden. Siehe tesm/__init__.py::_start_fileshare_sweep.
live.info("fileshare", f"{released} abgelaufene Einbindungen geloest")
except Exception: # noqa: BLE001
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen")
elapsed = time.monotonic() - started elapsed = time.monotonic() - started
remaining = max(1.0, interval - elapsed) remaining = max(1.0, interval - elapsed)
@@ -117,7 +113,7 @@ class MonitorDaemon:
time.sleep(min(1.0, remaining)) time.sleep(min(1.0, remaining))
remaining -= 1.0 remaining -= 1.0
live.info("monitor", "Ueberwachungsprozess beendet") live.info("monitor", "Überwachungsprozess beendet")
return 0 return 0
+244 -20
View File
@@ -28,6 +28,9 @@ set -euo pipefail
APP="" APP=""
INSTANCE="" INSTANCE=""
#: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die
#: Portsuche in einem Test wirklich ausfuehren laesst.
ETC_TESM="${TESM_ETC_DIR:-/etc/tesm}"
PORT="" PORT=""
HTTP_PORT="" HTTP_PORT=""
SKIP_PACKAGES=0 SKIP_PACKAGES=0
@@ -46,6 +49,62 @@ warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; } fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
info() { printf ' %s\n' "$*"; } 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() { usage() {
cat <<'USAGE' cat <<'USAGE'
Verwendung: install.sh --app <tesm|tesm-license> [Optionen] Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
@@ -109,13 +168,67 @@ LOG_DIR="/var/log/$NAME"
SERVICE_USER="$APP" SERVICE_USER="$APP"
UNIT="$NAME.service" UNIT="$NAME.service"
MONITOR_UNIT="$NAME-monitor.service" MONITOR_UNIT="$NAME-monitor.service"
CLEANUP_UNIT="$NAME-cleanup.service"
CLEANUP_TIMER="$NAME-cleanup.timer"
NGINX_SITE="/etc/nginx/sites-available/$NAME" NGINX_SITE="/etc/nginx/sites-available/$NAME"
ENV_PREFIX="TESM_" ENV_PREFIX="TESM_"
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_" [[ "$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 if [[ -z "$PORT" ]]; then
PORT=5000 BASISPORT=5000
[[ "$APP" == "tesm-license" ]] && PORT=5001 [[ "$APP" == "tesm-license" ]] && BASISPORT=5001
[[ -n "$INSTANCE" ]] && PORT=$((PORT + 100)) 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 fi
if [[ -z "$HTTP_PORT" ]]; then if [[ -z "$HTTP_PORT" ]]; then
HTTP_PORT=80 HTTP_PORT=80
@@ -134,7 +247,7 @@ fi
# Browser sendet das Cookie dann weiter nur ueber HTTPS, die Anwendung setzt es # Browser sendet das Cookie dann weiter nur ueber HTTPS, die Anwendung setzt es
# aber ohne das Flag neu, und beide reden aneinander vorbei. # aber ohne das Flag neu, und beide reden aneinander vorbei.
COOKIE_SECURE=0 COOKIE_SECURE=0
if [[ -f "/etc/tesm/$NAME.env" ]] && grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "/etc/tesm/$NAME.env"; then if [[ -f "$ENV_FILE" ]] && grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "$ENV_FILE"; then
COOKIE_SECURE=1 COOKIE_SECURE=1
fi fi
[[ $HTTPS -eq 1 ]] && COOKIE_SECURE=1 [[ $HTTPS -eq 1 ]] && COOKIE_SECURE=1
@@ -197,12 +310,41 @@ fi
# Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte # Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte
# gibt es ausschliesslich ueber den Helfer. # 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 ------------------------------------------------------------------ # -- Backup ------------------------------------------------------------------
if [[ $IS_UPDATE -eq 1 ]]; then if [[ $IS_UPDATE -eq 1 ]]; then
step "Sicherung vor der Aktualisierung" step "Sicherung vor der Aktualisierung"
systemctl stop "$UNIT" 2>/dev/null || true systemctl stop "$UNIT" 2>/dev/null || true
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true [[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
cp -a "$ROOT" "$BACKUP_DIR" cp -a "$ROOT" "$BACKUP_DIR"
SICHERUNG_STEHT=1
info "Sicherung: $BACKUP_DIR" info "Sicherung: $BACKUP_DIR"
fi fi
@@ -280,6 +422,42 @@ install -o root -g root -m 0440 "$TMP_SUDO" "/etc/sudoers.d/$APP"
rm -f "$TMP_SUDO" rm -f "$TMP_SUDO"
info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen" 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 ------------------------------------------------------------------ # -- Rechte ------------------------------------------------------------------
step "Dateirechte" step "Dateirechte"
chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR" chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR"
@@ -296,8 +474,8 @@ step "Umgebung"
# EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das # EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das
# wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere # wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere
# Datenbank im Arbeitsverzeichnis anlegen. # Datenbank im Arbeitsverzeichnis anlegen.
install -d -o root -g root -m 0755 /etc/tesm install -d -o root -g root -m 0755 "$ETC_TESM"
ENV_FILE="/etc/tesm/$NAME.env" ENV_FILE="$ETC_TESM/$NAME.env"
TMP_ENV="$(mktemp)" TMP_ENV="$(mktemp)"
{ {
echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten." echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten."
@@ -363,6 +541,11 @@ render_unit "$ROOT/deploy/systemd/$APP.service" "/etc/systemd/system/$UNIT"
if [[ "$APP" == "tesm" ]]; then if [[ "$APP" == "tesm" ]]; then
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT" render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
fi 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" \ [[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \
|| info "Units unveraendert" || info "Units unveraendert"
@@ -435,12 +618,18 @@ systemctl enable --now "$UNIT" >/dev/null
if [[ "$APP" == "tesm" ]]; then if [[ "$APP" == "tesm" ]]; then
systemctl enable --now "$MONITOR_UNIT" >/dev/null systemctl enable --now "$MONITOR_UNIT" >/dev/null
fi 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 ---------------------------------------------- # -- Health-Check und Rollback ----------------------------------------------
step "Gesundheitspruefung" step "Gesundheitspruefung"
HEALTHY=0 HEALTHY=0
for attempt in $(seq 1 20); do for attempt in $(seq 1 20); do
if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then # 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 HEALTHY=1
info "Antwort nach ${attempt}x" info "Antwort nach ${attempt}x"
break break
@@ -451,13 +640,9 @@ done
if [[ $HEALTHY -eq 0 ]]; then if [[ $HEALTHY -eq 0 ]]; then
warn "Die Anwendung antwortet nicht." warn "Die Anwendung antwortet nicht."
if [[ $IS_UPDATE -eq 1 ]]; then if [[ $IS_UPDATE -eq 1 ]]; then
warn "Rollback wird ausgefuehrt." # Dieselbe Funktion wie die EXIT-Falle -- zwei Wege, die dasselbe tun
systemctl stop "$UNIT" 2>/dev/null || true # sollen, laufen sonst irgendwann auseinander.
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true zurueckrollen_install
mv "$ROOT" "$FAILED_DIR"
cp -a "$BACKUP_DIR" "$ROOT"
systemctl start "$UNIT" || true
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" || true
fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt. fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt.
Fehlgeschlagene Version : $FAILED_DIR Fehlgeschlagene Version : $FAILED_DIR
Sicherung : $BACKUP_DIR Sicherung : $BACKUP_DIR
@@ -517,12 +702,41 @@ if [[ $HTTPS -eq 1 ]]; then
fi 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 if [[ $IS_UPDATE -eq 1 ]]; then
rm -rf "$BACKUP_DIR" rm -rf "$BACKUP_DIR"
info "Sicherung entfernt (Aktualisierung erfolgreich)" info "Sicherung entfernt (Aktualisierung erfolgreich)"
fi fi
# -- Abschluss --------------------------------------------------------------- # -- Abschluss ---------------------------------------------------------------
INSTALL_FERTIG=1
step "Fertig" step "Fertig"
ADMIN_CMD="tesm-admin" ADMIN_CMD="tesm-admin"
[[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin" [[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin"
@@ -538,14 +752,24 @@ cat <<SUMMARY
Naechste Schritte: Naechste Schritte:
SUMMARY SUMMARY
if [[ "$APP" == "tesm-license" ]]; then if [[ "$APP" == "tesm-license" && "$EDITION" == "master" ]]; then
cat <<SUMMARY cat <<SUMMARY
1. Signaturschluessel erzeugen und sofort sichern: 1. Administrator anlegen:
sudo -u $SERVICE_USER $NAME-admin init-key
2. Eigene Master-Lizenz ausstellen:
sudo -u $SERVICE_USER $NAME-admin bootstrap
3. Administrator anlegen:
sudo -u $SERVICE_USER $NAME-admin create-admin 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 SUMMARY
else else
cat <<SUMMARY cat <<SUMMARY
+12 -3
View File
@@ -14,12 +14,21 @@ TAG="${1:?Verwendung: release.sh vX.Y.Z [name]}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# Vorgabe: der Name der einzigen Anwendung im Baum, sonst der Verzeichnisname. # 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 if [[ -n "${2:-}" ]]; then
NAME="$2" NAME="$2"
else else
mapfile -t APPS < <(ls -1 "$REPO_DIR/apps" 2>/dev/null || true) HERKUNFT="$(git -C "$REPO_DIR" config --get remote.origin.url 2>/dev/null || true)"
if [[ ${#APPS[@]} -eq 1 ]]; then NAME="${APPS[0]}"; else NAME="$(basename "$REPO_DIR")"; fi 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 fi
OUT="$NAME-$TAG.tar.gz" OUT="$NAME-$TAG.tar.gz"
+359
View File
@@ -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."
+7 -1
View File
@@ -5,5 +5,11 @@
# #
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe # Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
# Problem, indem die gesamte Anwendung als root lief. # Problem, indem die gesamte Anwendung als root lief.
Defaults:tesm !requiretty #
# 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 tesm ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
+7 -1
View File
@@ -5,5 +5,11 @@
# #
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe # Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
# Problem, indem die gesamte Anwendung als root lief. # Problem, indem die gesamte Anwendung als root lief.
Defaults:tesm-license !requiretty #
# 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 tesm-license ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
+34
View File
@@ -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
+18
View File
@@ -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
+18
View File
@@ -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
+14 -1
View File
@@ -59,7 +59,20 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
LockPersonality=yes LockPersonality=yes
MemoryDenyWriteExecute=no MemoryDenyWriteExecute=no
SystemCallArchitectures=native SystemCallArchitectures=native
SystemCallFilter=@system-service # "@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 SystemCallErrorNumber=EPERM
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht # Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt, # CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
+38 -23
View File
@@ -47,7 +47,7 @@ check_in_list() {
} }
# Name einer Installation auf diesem Host: entweder die Hauptinstallation # Name einer Installation auf diesem Host: entweder die Hauptinstallation
# ("tesm") oder eine benannte Instanz daneben ("tesm-opus"). Mehr als diese # ("tesm") oder eine benannte Instanz daneben ("tesm-test"). Mehr als diese
# beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen # beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen
# Pfad ausserhalb des vorgesehenen Namensraums erzeugen. # Pfad ausserhalb des vorgesehenen Namensraums erzeugen.
check_app_key() { check_app_key() {
@@ -258,19 +258,17 @@ case "$verb" in
# Kommandozeile (Prozessliste) und nie ueber die Umgebung # Kommandozeile (Prozessliste) und nie ueber die Umgebung
# (/proc/<pid>/environ). # (/proc/<pid>/environ).
read_stdin_limited > "$creds" read_stdin_limited > "$creds"
# Der aufrufende Dienst laeuft in einem eigenen Mount-Namensraum # Eingebunden wird im Mount-Namensraum des aufrufenden Dienstes -- absichtlich:
# (ProtectSystem, PrivateTmp, ReadWritePaths). Ein Einbinden *darin* # nur dieser Dienst liest die Dateien, und mit ihm verschwinden die
# scheitert mit "mount error(1): Operation not permitted" -- gemessen: mit # Einbindungen wieder. Damit der Syscall durchkommt, braucht die Unit
# denselben Optionen und denselben Zugangsdaten gelingt es aus einer # "@mount" im SystemCallFilter. Ohne das scheitert es mit
# normalen Root-Shell sofort. Deshalb wird im Namensraum des Wirts # "mount error(1): Operation not permitted"; gemessen war der Syscall-Filter
# eingebunden; von dort wird der Mount in den Namensraum des Dienstes # die Ursache, nicht der Namensraum. Ein Umweg ueber nsenter in den
# weitergegeben (er haengt als "slave" darunter), die Anwendung sieht ihn # Namensraum des Wirts ist keine Option -- "RestrictNamespaces=yes"
# also. Die Zugangsdatei unter /run ist ueber die Grenze lesbar, /run ist # verbietet setns, und das Recht dafuer wieder aufzugeben waere teurer als
# nicht privatisiert. # der Gewinn.
im_wirt=(nsenter --mount=/proc/1/ns/mnt --) mkdir -p "$target"
command -v nsenter >/dev/null 2>&1 || im_wirt=() if mount -t cifs "$share" "$target" \
"${im_wirt[@]}" mkdir -p "$target"
if "${im_wirt[@]}" 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" -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 then
shred -u "$creds" 2>/dev/null || rm -f "$creds" shred -u "$creds" 2>/dev/null || rm -f "$creds"
@@ -282,13 +280,12 @@ case "$verb" in
umount) umount)
check_mount_target "${1:?Ziel fehlt}" check_mount_target "${1:?Ziel fehlt}"
# Gegenstueck zu mount-cifs: der Mount liegt im Namensraum des Wirts, also # Gegenstueck zu mount-cifs, und deshalb im selben Namensraum: loesen kann
# muss er auch dort geloest werden. Ein umount im Dienst-Namensraum wuerde # nur, wer die Einbindung auch sieht. Ein Aufruf aus einem anderen Dienst
# ihn nur dort ausblenden und die Freigabe bliebe eingebunden. # greift ins Leere -- genau deshalb laeuft die Aufraeumaufgabe fuer
im_wirt=(nsenter --mount=/proc/1/ns/mnt --) # abgelaufene Einbindungen im Webprozess und nicht im Ueberwachungsdienst.
command -v nsenter >/dev/null 2>&1 || im_wirt=() umount -l "$1" || true
"${im_wirt[@]}" umount -l "$1" || true rmdir "$1" 2>/dev/null || true
"${im_wirt[@]}" rmdir "$1" 2>/dev/null || true
;; ;;
apt-install) apt-install)
@@ -296,9 +293,27 @@ case "$verb" in
for package in "$@"; do for package in "$@"; do
check_in_list "$package" "${PACKAGES_ALLOWED[@]}" || fail "Paket nicht erlaubt: $package" check_in_list "$package" "${PACKAGES_ALLOWED[@]}" || fail "Paket nicht erlaubt: $package"
done 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 export DEBIAN_FRONTEND=noninteractive
apt-get update -qq if command -v systemd-run >/dev/null 2>&1 && [[ -S /run/dbus/system_bus_socket ]]; then
apt-get install -y -qq "$@" # 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) certbot-issue)
+185
View File
@@ -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)
+179 -36
View File
@@ -1,56 +1,199 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================= # =============================================================================
# Bootstrapper: laedt ein Release herunter und ruft dessen install.sh auf. # Aktualisierung: laedt das aktuelle Release und ruft dessen install.sh auf.
# Enthaelt selbst keinerlei Installationslogik. # Enthaelt selbst keinerlei Installationslogik.
# #
# Zwei Fallen des Vorgaengers sind hier bewusst vermieden: # Eine einzelne Datei genuegt -- sie holt sich alles Weitere:
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die #
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein # curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Assets. # sudo bash update.sh tesm
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert -- faengt #
# denselben Fehler ein zweites Mal ab. # 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 set -euo pipefail
APP="${1:-}" APP="${1:-}"
TAG="${2:-latest}" TAG="${2:-latest}"
BASE="${TESM_RELEASE_BASE:-https://gitea.example.invalid}"
OWNER="${TESM_RELEASE_OWNER:-wis}" # Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
REPO="${TESM_RELEASE_REPO:-tesm}" # 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" ]] || { [[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
echo "Verwendung: update.sh <tesm|tesm-license> [tag]" >&2 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 exit 2
} }
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)" # Repositorium: die Master-Ausgabe des Lizenzservers liegt in einem eigenen,
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt # privaten Repositorium, heisst als Installation aber weiter "tesm-license".
# Erkannt wird sie an der Installation und nicht an einem Schalter -- wer
if [[ "$TAG" == "latest" ]]; then # aktualisiert, soll nicht erst wissen muessen, welche Ausgabe hier laeuft.
echo "==> Ermittle aktuellen Release-Tag ..." # Sonst holte der Aufruf die Ausgabe fuer Sub-Lizenzserver, und der Installer
TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$REPO/releases/latest" \ # muesste sie abweisen.
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" REPO="$APP"
echo " $TAG" 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 fi
ASSET="$REPO-$TAG.tar.gz" # --8<-- Bootstrap: Release holen --------------------------------------------
echo "==> Lade $ASSET ..." # Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET" # 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}"
echo "==> Entpacke ..." # Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
tar -xzf "$WORK/$ASSET" -C "$WORK" # Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
SRC="$(find "$WORK" -maxdepth 2 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)" # der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; } # dem Server liegt, ist keine Entscheidung eines Skripts.
SRC="$(cd "$SRC" && pwd)" TOKEN="${TESM_RELEASE_TOKEN:-}"
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..." TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
if file "$SRC/deploy/install.sh" | grep -q CRLF; then
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
echo "'git -c core.autocrlf=false archive'." >&2
exit 1
fi fi
read -r -p "Version $TAG fuer $APP installieren? [j/N] " answer # Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0 # ("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"
}
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes 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[@]}"}
+687
View File
@@ -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
+286 -66
View File
@@ -2,7 +2,7 @@
Dieses Dokument richtet sich an die Person, die die Anlage installiert, Dieses Dokument richtet sich an die Person, die die Anlage installiert,
aktualisiert und im Fehlerfall wieder zum Laufen bringt. Es beschreibt, was auf aktualisiert und im Fehlerfall wieder zum Laufen bringt. Es beschreibt, was auf
dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird. dem Host tatsächlich passiert -- nicht, wie die Oberfläche bedient wird.
--- ---
@@ -11,7 +11,7 @@ dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird.
| Zweck | Pfad | | Zweck | Pfad |
|---|---| |---|---|
| Anwendung (Code, venv, statische Dateien) | `/srv/<name>/` | | Anwendung (Code, venv, statische Dateien) | `/srv/<name>/` |
| Instanzdaten (Datenbank, Schluessel, Lizenz) | `/srv/<name>/data/` | | Instanzdaten (Datenbank, Schlüssel, Lizenz) | `/srv/<name>/data/` |
| Protokolle | `/var/log/<name>/` | | Protokolle | `/var/log/<name>/` |
| Umgebungsdatei | `/etc/tesm/<name>.env` | | Umgebungsdatei | `/etc/tesm/<name>.env` |
| Zertifikate (selbstsigniert) | `/etc/tesm/certs/<name>/` | | Zertifikate (selbstsigniert) | `/etc/tesm/certs/<name>/` |
@@ -20,13 +20,13 @@ dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird.
| Privilegierter Helfer | `/usr/local/lib/tesm/tesm-helper` | | Privilegierter Helfer | `/usr/local/lib/tesm/tesm-helper` |
| Verwaltungswerkzeug | `/usr/local/bin/<name>-admin` | | Verwaltungswerkzeug | `/usr/local/bin/<name>-admin` |
`<name>` ist `tesm` bzw. `tesm-license` -- oder, bei einer zusaetzlichen `<name>` ist `tesm` bzw. `tesm-license` -- oder, bei einer zusätzlichen
Instanz, `tesm-<instanz>`. Dieser Name (der **SITE_KEY**) benennt alles, was Instanz, `tesm-<instanz>`. Dieser Name (der **SITE_KEY**) benennt alles, was
ausserhalb von `/srv/<name>` liegt. Deshalb koennen zwei Installationen ausserhalb von `/srv/<name>` liegt. Deshalb können zwei Installationen
derselben Anwendung nebeneinander laufen, ohne sich gegenseitig die derselben Anwendung nebeneinander laufen, ohne sich gegenseitig die
nginx-Site, die netplan-Datei oder das Sitzungscookie zu ueberschreiben. nginx-Site, die netplan-Datei oder das Sitzungscookie zu überschreiben.
Alles, was ein Mensch verlieren wuerde, liegt unter `data/`. Das Verzeichnis Alles, was ein Mensch verlieren würde, liegt unter `data/`. Das Verzeichnis
wird von `install.sh` nie angefasst. wird von `install.sh` nie angefasst.
--- ---
@@ -43,10 +43,10 @@ Wichtige Optionen:
| Option | Wirkung | | Option | Wirkung |
|---|---| |---|---|
| `--https` | Port 443 mit TLS, Port 80 leitet dorthin um. Ohne `--domain` entsteht ein selbstsigniertes Zertifikat. | | `--https` | Port 443 mit TLS, Port 80 leitet dorthin um. Ohne `--domain` entsteht ein selbstsigniertes Zertifikat. |
| `--domain <name>` | Oeffentlicher Name. Setzt `server_name`; die Site ist dann **nicht** mehr die Vorgabe-Site. | | `--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 bestaetigen. | | `--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>` | Zusaetzliche Instanz neben der Hauptinstallation. | | `--instance <name>` | Zusätzliche Instanz neben der Hauptinstallation. |
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur fuer Testinstanzen sinnvoll). | | `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur für Testinstanzen sinnvoll). |
| `--skip-packages` | Keine Systempakete installieren. | | `--skip-packages` | Keine Systempakete installieren. |
Danach: Danach:
@@ -55,17 +55,17 @@ Danach:
sudo -u tesm tesm-admin create-admin sudo -u tesm tesm-admin create-admin
``` ```
Beim Lizenzserver zusaetzlich, **in dieser Reihenfolge**: Beim Lizenzserver zusätzlich, **in dieser Reihenfolge**:
```bash ```bash
sudo -u tesm-license tesm-license-admin init-key # Signaturschluessel -- sofort sichern! 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 bootstrap # eigene Master-Lizenz
sudo -u tesm-license tesm-license-admin create-admin sudo -u tesm-license tesm-license-admin create-admin
``` ```
> **Der Signaturschluessel ist nicht wiederherstellbar.** Geht > **Der Signaturschlüssel ist nicht wiederherstellbar.** Geht
> `data/master_signing_key.json` verloren, ist keine ausgestellte Lizenz mehr > `data/master_signing_key.json` verloren, ist keine ausgestellte Lizenz mehr
> pruefbar. Sichern Sie ihn getrennt vom Server. > prüfbar. Sichern Sie ihn getrennt vom Server.
### Zwei Anwendungen auf einem Host ### Zwei Anwendungen auf einem Host
@@ -78,7 +78,7 @@ sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.loc
Die Installation **ohne** Domain wird zur Vorgabe-Site (`default_server`) und Die Installation **ohne** Domain wird zur Vorgabe-Site (`default_server`) und
beantwortet alles, was zu keinem Namen passt. Zwei namenlose Sites auf beantwortet alles, was zu keinem Namen passt. Zwei namenlose Sites auf
demselben Port weist nginx ab -- richtig so, und beim Uebernehmen sofort demselben Port weist nginx ab -- richtig so, und beim Übernehmen sofort
sichtbar. sichtbar.
--- ---
@@ -90,19 +90,162 @@ sudo bash deploy/install.sh --app tesm
``` ```
Derselbe Befehl. Das Skript erkennt an der vorhandenen Datenbank, dass es eine Derselbe Befehl. Das Skript erkennt an der vorhandenen Datenbank, dass es eine
Aktualisierung ist, und tut dann zusaetzlich: Aktualisierung ist, und tut dann zusätzlich:
1. **Vollstaendige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` -- 1. **Vollständige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` --
unabhaengig davon, ob sich das Schema geaendert hat. unabhängig davon, ob sich das Schema geändert hat.
2. Dateiabgleich; `data/` bleibt unangetastet. 2. Dateiabgleich; `data/` bleibt unangetastet.
3. Schema-Migrationen beim Start (versioniert, mit Pruefsumme). 3. Schema-Migrationen beim Start (versioniert, mit Prüfsumme).
4. **Gesundheitspruefung.** Antwortet die Anwendung nicht, wird der alte Stand 4. **Gesundheitsprüfung.** Antwortet die Anwendung nicht, wird der alte Stand
automatisch zurueckgeholt; die fehlgeschlagene Version bleibt unter automatisch zurückgeholt; die fehlgeschlagene Version bleibt unter
`/srv/<name>-failed-<zeitstempel>` zur Analyse liegen. `/srv/<name>-failed-<zeitstempel>` zur Analyse liegen.
Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
`--https`. `--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 ## 4. HTTPS
@@ -114,7 +257,7 @@ Drei Wege dorthin:
**a) Bei der Installation** -- siehe oben, `--https`. **a) Bei der Installation** -- siehe oben, `--https`.
**b) Nachtraeglich ueber die Kommandozeile:** **b) Nachträglich über die Kommandozeile:**
```bash ```bash
sudo -u tesm tesm-admin web-setup --https --self-signed --apply sudo -u tesm tesm-admin web-setup --https --self-signed --apply
@@ -124,42 +267,119 @@ sudo -u tesm tesm-admin web-setup --https --domain tesm.firma.de \
`--print-config` zeigt die Datei, ohne etwas zu schreiben. `--print-config` zeigt die Datei, ohne etwas zu schreiben.
**c) In der Oberflaeche** unter *Einstellungen -> Webserver und TLS*. **c) In der Oberfläche** unter *Einstellungen -> Webserver und TLS*.
Danach muss `COOKIE_SECURE` in `/etc/tesm/<name>.env` auf `1` stehen (alle drei Danach muss `COOKIE_SECURE` in `/etc/tesm/<name>.env` auf `1` stehen (alle drei
Wege erledigen das selbst) und der Dienst neu gestartet werden. Wege erledigen das selbst) und der Dienst neu gestartet werden.
### Die Site-Datei wird immer neu erzeugt ### Die Site-Datei wird immer neu erzeugt
Sie ist **kein** Ort fuer Handarbeit. Jede Uebernahme schreibt sie vollstaendig Sie ist **kein** Ort für Handarbeit. Jede Übernahme schreibt sie vollständig
aus der Vorlage neu. Was frueher von Hand hineingeschrieben wurde -- Domain, aus der Vorlage neu. Was früher von Hand hineingeschrieben wurde -- Domain,
Ports, Zertifikatspfade, HSTS, Upload-Grenze -- ist heute Einstellung in der Ports, Zertifikatspfade, HSTS, Upload-Grenze -- ist heute Einstellung in der
Anwendung. Anwendung.
Der Grund steht im Vorgaengerprojekt: dort wurde die Datei nur angelegt, wenn Der Grund steht im Vorgängerprojekt: dort wurde die Datei nur angelegt, wenn
sie fehlte. Ein einmal falscher `alias`-Pfad ueberlebte dadurch jahrelang jedes sie fehlte. Ein einmal falscher `alias`-Pfad überlebte dadurch jahrelang jedes
Update, und die Oberflaeche kam die ganze Zeit ohne Design. Update, und die Oberfläche kam die ganze Zeit ohne Design.
### Selbstsigniert oder Let's Encrypt? ### Selbstsigniert oder Let's Encrypt?
* **Oeffentlich erreichbar, echter DNS-Name:** Let's Encrypt. Nur dann ist HSTS * **Öffentlich erreichbar, echter DNS-Name:** Let's Encrypt. Nur dann ist HSTS
sinnvoll. sinnvoll.
* **Geschlossenes Netz:** selbstsigniert. Browser zeigen eine Warnung, die * **Geschlossenes Netz:** selbstsigniert. Browser zeigen eine Warnung, die
Verbindung ist trotzdem verschluesselt und das Sitzungscookie darf `Secure` Verbindung ist trotzdem verschlüsselt und das Sitzungscookie darf `Secure`
tragen. **HSTS hier nicht einschalten** -- der Browser merkt sich die Vorgabe tragen. **HSTS hier nicht einschalten** -- der Browser merkt sich die Vorgabe
monatelang und die Warnung laesst sich dann nicht mehr wegklicken. monatelang und die Warnung lässt sich dann nicht mehr wegklicken.
Die Erneuerung von Let's Encrypt laeuft ueber `certbot certonly --webroot` Die Erneuerung von Let's Encrypt läuft über `certbot certonly --webroot`
(bewusst nicht ueber das nginx-Plugin, das die erzeugte Site umschreiben (bewusst nicht über das nginx-Plugin, das die erzeugte Site umschreiben
wuerde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar. 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 ## 5. Dienste
```bash ```bash
systemctl status tesm.service # Weboberflaeche (gunicorn) systemctl status tesm.service # Weboberfläche (gunicorn)
systemctl status tesm-monitor.service # Ueberwachungsschleife (nur TESM) systemctl status tesm-monitor.service # Überwachungsschleife (nur TESM)
systemctl status tesm-license.service systemctl status tesm-license.service
journalctl -u tesm -n 100 -f journalctl -u tesm -n 100 -f
``` ```
@@ -179,8 +399,8 @@ Die Anwendung hat keine. Alles, was Root braucht, geht durch **einen** Pfad:
/usr/local/lib/tesm/tesm-helper <verb> [argumente] /usr/local/lib/tesm/tesm-helper <verb> [argumente]
``` ```
Der Helfer gehoert `root`, ist per sudoers ausschliesslich fuer genau diesen Der Helfer gehört `root`, ist per sudoers ausschliesslich für genau diesen
Pfad mit `NOPASSWD` freigegeben und prueft jedes Argument gegen eine Pfad mit `NOPASSWD` freigegeben und prüft jedes Argument gegen eine
Positivliste. Er reicht nie etwas an eine Shell weiter (kein `eval`, kein Positivliste. Er reicht nie etwas an eine Shell weiter (kein `eval`, kein
`sh -c`). `sh -c`).
@@ -191,16 +411,16 @@ sudo /usr/local/lib/tesm/tesm-helper
grep -n ')$' /usr/local/lib/tesm/tesm-helper grep -n ')$' /usr/local/lib/tesm/tesm-helper
``` ```
Regel fuer Aenderungen: **Jedes neue Verb braucht eine Argumentpruefung.** Regel für Änderungen: **Jedes neue Verb braucht eine Argumentprüfung.**
`tests/test_repo_hygiene.py` erzwingt, dass jedes der Anwendung bekannte Verb `tests/test_repo_hygiene.py` erzwingt, dass jedes der Anwendung bekannte Verb
im Helfer vorkommt und dass jede Pfadpruefung `..` abweist. im Helfer vorkommt und dass jede Pfadprüfung `..` abweist.
--- ---
## 7. Sicherung und Wiederherstellung ## 7. Sicherung und Wiederherstellung
In der Oberflaeche unter *Sicherung* -- verschluesselte Ausfuhr (Argon2id + In der Oberfläche unter *Sicherung* -- verschlüsselte Ausfuhr (Argon2id +
AES-256-GCM) mit einem selbst gewaehlten Kennwort. Der Import zeigt zuerst eine AES-256-GCM) mit einem selbst gewählten Kennwort. Der Import zeigt zuerst eine
Vorschau, bevor irgendetwas geschrieben wird. Vorschau, bevor irgendetwas geschrieben wird.
Auf dem Host reicht: Auf dem Host reicht:
@@ -214,10 +434,10 @@ sudo systemctl start tesm
Unverzichtbar: Unverzichtbar:
* `data/app.db` -- alle Daten * `data/app.db` -- alle Daten
* `data/secret.key` -- ohne sie sind alle Sitzungen ungueltig * `data/secret.key` -- ohne sie sind alle Sitzungen ungültig
* `data/data.keys` -- ohne sie sind **alle gespeicherten Geheimnisse verloren** * `data/data.keys` -- ohne sie sind **alle gespeicherten Geheimnisse verloren**
* `data/license.json` und `data/license_key.json` * `data/license.json` und `data/license_key.json`
* Lizenzserver zusaetzlich: `data/master_signing_key.json` * Lizenzserver zusätzlich: `data/master_signing_key.json`
--- ---
@@ -226,21 +446,21 @@ Unverzichtbar:
**Die Anwendung antwortet nicht.** **Die Anwendung antwortet nicht.**
```bash ```bash
journalctl -u tesm -n 200 --no-pager journalctl -u tesm -n 200 --no-pager
curl -s http://127.0.0.1:5000/gesundheit curl -s http://127.0.0.1:5000/health
``` ```
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx. Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
**nginx laesst sich nicht neu laden.** **nginx lässt sich nicht neu laden.**
```bash ```bash
sudo nginx -t sudo nginx -t
``` ```
Die Uebernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst Die Übernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
zurueck. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft zurück. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
`install.sh`, das eine unbrauchbare Site durch die Vorlage ersetzt. `install.sh`, das eine unbrauchbare Site durch die Vorlage ersetzt.
**Anmeldung schlaegt ohne Fehlermeldung fehl.** **Anmeldung schlägt ohne Fehlermeldung fehl.**
Fast immer `COOKIE_SECURE=1` ohne HTTPS. Der Browser bekommt dann ein Cookie, Fast immer `COOKIE_SECURE=1` ohne HTTPS. Der Browser bekommt dann ein Cookie,
das er ueber HTTP nie zuruecksendet. das er über HTTP nie zurücksendet.
```bash ```bash
grep COOKIE_SECURE /etc/tesm/tesm.env grep COOKIE_SECURE /etc/tesm/tesm.env
``` ```
@@ -250,38 +470,38 @@ Der Reverse Proxy reicht den Host falsch durch. In der Site-Datei muss
`proxy_set_header Host $http_host;` stehen -- **nicht** `$host`, das verwirft `proxy_set_header Host $http_host;` stehen -- **nicht** `$host`, das verwirft
den Port. den Port.
**Statische Dateien fehlen, die Oberflaeche ist unformatiert.** **Statische Dateien fehlen, die Oberfläche ist unformatiert.**
```bash ```bash
grep alias /etc/nginx/sites-available/tesm grep alias /etc/nginx/sites-available/tesm
ls /srv/tesm/static /srv/tesm/static-core ls /srv/tesm/static /srv/tesm/static-core
``` ```
Beide `alias`-Pfade muessen in das Installationsverzeichnis zeigen. Beide `alias`-Pfade müssen in das Installationsverzeichnis zeigen.
**Zwei Instanzen stoeren sich.** **Zwei Instanzen stören sich.**
`SITE_KEY` in beiden `/etc/tesm/*.env` vergleichen -- er muss sich `SITE_KEY` in beiden `/etc/tesm/*.env` vergleichen -- er muss sich
unterscheiden. Sonst teilen sich beide nginx-Site und Sitzungscookie. unterscheiden. Sonst teilen sich beide nginx-Site und Sitzungscookie.
**Die Wartung meldet "Der SSH-Host-Schluessel ist unbekannt".** **Die Wartung meldet "Der SSH-Host-Schlüssel ist unbekannt".**
Kein Fehler, sondern die Absicht: ein Hintergrundauftrag darf keinen fremden Kein Fehler, sondern die Absicht: ein Hintergrundauftrag darf keinen fremden
Schluessel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf Schlüssel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
der Detailseite des Geraets (*Clients -> Geraet -> SSH-Host-Schluessel*): der Detailseite des Geräts (*Clients -> Gerät -> SSH-Host-Schlüssel*):
erst **Auslesen**, dann den Fingerprint mit einer unabhaengigen Quelle erst **Auslesen**, dann den Fingerprint mit einer unabhängigen Quelle
vergleichen, dann **Freigeben**. Die Wartungsuebersicht zeigt in einer eigenen vergleichen, dann **Freigeben**. Die Wartungsübersicht zeigt in einer eigenen
Spalte, fuer welche Geraete das noch offen ist. Fuer Switche liegt dieselbe Spalte, für welche Geräte das noch offen ist. Für Switche liegt dieselbe
Freigabe auf der Switch-Detailseite. Freigabe auf der Switch-Detailseite.
Der Schluessel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM Der Schlüssel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
verbindet sich immer ueber die hinterlegte IP-Adresse. Aendert sich die IP verbindet sich immer über die hinterlegte IP-Adresse. Ändert sich die IP
eines Geraets, ist der Schluessel erneut freizugeben. eines Geräts, ist der Schlüssel erneut freizugeben.
**Die Pruefkette des Aenderungsprotokolls ist gebrochen.** **Die Prüfkette des Änderungsprotokolls ist gebrochen.**
Unter *Diagnose* sichtbar. Das Protokoll ist verkettet gehasht; ein Bruch heisst, Unter *Diagnose* sichtbar. Das Protokoll ist verkettet gehasht; ein Bruch heisst,
dass jemand direkt in die Datenbank geschrieben hat. Der Eintrag, ab dem es dass jemand direkt in die Datenbank geschrieben hat. Der Eintrag, ab dem es
nicht mehr stimmt, wird genannt. nicht mehr stimmt, wird genannt.
--- ---
## 9. Zurueck auf eine aeltere Version ## 9. Zurück auf eine ältere Version
```bash ```bash
sudo systemctl stop tesm sudo systemctl stop tesm
@@ -290,6 +510,6 @@ sudo cp -a /srv/tesm-backup-<zeitstempel> /srv/tesm
sudo systemctl start tesm sudo systemctl start tesm
``` ```
Schema-Migrationen laufen nur vorwaerts. Ein Ruecksprung ueber eine Schema-Migrationen laufen nur vorwärts. Ein Rücksprung über eine
Schema-Aenderung hinweg braucht deshalb auch die Datenbank aus der Sicherung -- Schema-Änderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
genau das liegt im Backup-Verzeichnis. genau das liegt im Backup-Verzeichnis.
+61 -56
View File
@@ -1,6 +1,6 @@
# Sicherheit # Sicherheit
Was geschuetzt wird, womit, und wo bewusst Grenzen gezogen sind. Was geschützt wird, womit, und wo bewusst Grenzen gezogen sind.
--- ---
@@ -9,35 +9,36 @@ Was geschuetzt wird, womit, und wo bewusst Grenzen gezogen sind.
| | | | | |
|---|---| |---|---|
| Speicherung | Argon2id (argon2-cffi) | | Speicherung | Argon2id (argon2-cffi) |
| Altbestand | bcrypt wird noch geprueft und bei der naechsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt | | Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
| Zweiter Faktor | TOTP, optional erzwingbar fuer Administratoren | | 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 Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
| Sperre je Adresse | zusaetzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst laesst sich die Kontosperre umgehen, indem man viele Konten je einmal probiert | | 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 ueber TLS, Zertifikat wird geprueft. Ohne gueltiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet | | 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 Nach einer Anmeldung wird die Sitzungskennung neu vergeben (gegen Session
Fixation) und das CSRF-Token gewechselt. Fixation) und das CSRF-Token gewechselt.
### Erneute Bestaetigung ("sudo-Modus") ### Erneute Bestätigung ("sudo-Modus")
Alles, was Geheimnisse offenlegt oder Daten aus dem System heraustraegt, Alles, was Geheimnisse offenlegt oder Daten aus dem System herausträgt,
verlangt eine frische Passworteingabe: Klartextanzeige von Zugangsdaten, verlangt eine frische Passworteingabe: Klartextanzeige von Zugangsdaten,
Ausfuhr, Herunterladen eines Lizenzbundles, Schluesselrotation, Ausfuhr, Herunterladen eines Lizenzbundles, Schlüsselrotation,
2FA-Aenderungen, Deaktivierung einer Lizenz. 2FA-Änderungen, Deaktivierung einer Lizenz.
Ein uebernommenes Sitzungscookie allein reicht dafuer nicht. Ein übernommenes Sitzungscookie allein reicht dafür nicht.
--- ---
## 2. Sitzungen ## 2. Sitzungen
Serverseitig in SQLite, nicht im Cookie. Das Cookie traegt nur eine Serverseitig in SQLite, nicht im Cookie. Das Cookie trägt nur eine
Zufallskennung. Zufallskennung.
* `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist * `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist
* Leerlaufzeit **und** absolute Hoechstdauer * Leerlaufzeit **und** absolute Höchstdauer
* Der Cookiename enthaelt den `SITE_KEY` -- zwei Installationen auf einem Host * Der Cookiename enthält den `SITE_KEY` -- zwei Installationen auf einem Host
koennen sich die Sitzung nicht gegenseitig ueberschreiben (Cookies können sich die Sitzung nicht gegenseitig überschreiben (Cookies
unterscheiden keine Ports) unterscheiden keine Ports)
* Aktive Sitzungen sind einsehbar und einzeln beendbar * Aktive Sitzungen sind einsehbar und einzeln beendbar
@@ -45,22 +46,22 @@ Zufallskennung.
## 3. CSRF ## 3. CSRF
Drei voneinander unabhaengige Schichten: Drei voneinander unabhängige Schichten:
1. `SameSite=Lax` am Sitzungscookie. 1. `SameSite=Lax` am Sitzungscookie.
2. Ein Token je Sitzung, in jedem Formular, in konstanter Zeit verglichen. 2. Ein Token je Sitzung, in jedem Formular, in konstanter Zeit verglichen.
3. Origin-/Referer-Pruefung gegen den Host der Anfrage. 3. Origin-/Referer-Prüfung gegen den Host der Anfrage.
Die dritte Schicht akzeptiert den Host mit und ohne Port sowie die Die dritte Schicht akzeptiert den Host mit und ohne Port sowie die
Standardports -- **nicht** aber beliebige Ports: ein anderer Dienst auf Standardports -- **nicht** aber beliebige Ports: ein anderer Dienst auf
demselben Rechner ist ein anderer Ursprung. demselben Rechner ist ein anderer Ursprung.
Der Vorgaenger hatte gar keinen CSRF-Schutz. Eine praeparierte Seite konnte im Der Vorgänger hatte gar keinen CSRF-Schutz. Eine präparierte Seite konnte im
Namen eines angemeldeten Administrators Geraete loeschen oder Benutzer anlegen. Namen eines angemeldeten Administrators Geräte löschen oder Benutzer anlegen.
Ausnahmen (`@csrf.exempt`) gibt es nur fuer Maschinenschnittstellen, die sich Ausnahmen (`@csrf.exempt`) gibt es nur für Maschinenschnittstellen, die sich
**nicht** ueber Cookies autorisieren. Eine cookie-autorisierte Route ohne **nicht** über Cookies autorisieren. Eine cookie-autorisierte Route ohne
CSRF-Schutz waere eine Luecke. CSRF-Schutz wäre eine Lücke.
--- ---
@@ -69,75 +70,75 @@ CSRF-Schutz waere eine Luecke.
Bei jeder Antwort: Bei jeder Antwort:
* `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein** * `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein**
`unsafe-inline` fuer Skripte -- deshalb gibt es im ganzen Projekt kein `unsafe-inline` für Skripte -- deshalb gibt es im ganzen Projekt kein
Inline-Script ohne Nonce, und JavaScript haengt ueber Inline-Script ohne Nonce, und JavaScript hängt über
`data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil `data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil
einzelne berechnete Breiten (Fortschrittsbalken) als Style-Attribut gesetzt einzelne berechnete Breiten (Fortschrittsbalken) als Style-Attribut gesetzt
werden; Style-Attribute fuehren keinen Code aus. werden; Style-Attribute führen keinen Code aus.
* `X-Content-Type-Options: nosniff` * `X-Content-Type-Options: nosniff`
* `X-Frame-Options: DENY`, `frame-ancestors 'none'` * `X-Frame-Options: DENY`, `frame-ancestors 'none'`
* `Referrer-Policy: same-origin` * `Referrer-Policy: same-origin`
* `Permissions-Policy` -- Kamera, Mikrofon, Ort, Zahlung, USB abgeschaltet * `Permissions-Policy` -- Kamera, Mikrofon, Ort, Zahlung, USB abgeschaltet
* `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy` * `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
* `Strict-Transport-Security` nur, wenn TLS tatsaechlich terminiert wird * `Strict-Transport-Security` nur, wenn TLS tatsächlich terminiert wird
> HSTS gehoert **nicht** zu einem selbstsignierten Zertifikat. Der Browser > HSTS gehört **nicht** zu einem selbstsignierten Zertifikat. Der Browser
> merkt sich die Vorgabe monatelang; die Warnung laesst sich danach nicht mehr > 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. > wegklicken. `install.sh` setzt HSTS deshalb nur zusammen mit Let's Encrypt.
--- ---
## 5. Geheimnisse im Ruhezustand ## 5. Geheimnisse im Ruhezustand
Ein Schluesselspeicher mit AES-256-GCM (`data/data.keys`, Modus 0600): Ein Schlüsselspeicher mit AES-256-GCM (`data/data.keys`, Modus 0600):
* Versionierte Schluessel, rotierbar. Chiffrate tragen ihre Schluesselkennung * Versionierte Schlüssel, rotierbar. Chiffrate tragen ihre Schlüsselkennung
(`v1.<id>.<nonce>.<ct>`) und bleiben nach einer Rotation lesbar. (`v1.<id>.<nonce>.<ct>`) und bleiben nach einer Rotation lesbar.
* Der Verwendungszweck geht als AAD in die Verschluesselung ein. Ein Chiffrat * Der Verwendungszweck geht als AAD in die Verschlüsselung ein. Ein Chiffrat
aus einem Kontext laesst sich in einem anderen nicht entschluesseln. aus einem Kontext lässt sich in einem anderen nicht entschlüsseln.
* Beim Start wird geprueft, ob die Rechte auf Schluessel- und Lizenzdateien * Beim Start wird geprüft, ob die Rechte auf Schlüssel- und Lizenzdateien
noch stimmen; Abweichungen erscheinen unter *Diagnose*. noch stimmen; Abweichungen erscheinen unter *Diagnose*.
Passwoerter fuer Mounts gehen ueber `stdin` an den privilegierten Helfer, der Passwörter für Mounts gehen über `stdin` an den privilegierten Helfer, der
sie in eine 0600-Datei schreibt -- nie als Kommandozeilenargument (Prozessliste) sie in eine 0600-Datei schreibt -- nie als Kommandozeilenargument (Prozessliste)
und nie ueber die Umgebung (`/proc/<pid>/environ`). und nie über die Umgebung (`/proc/<pid>/environ`).
--- ---
## 6. Rechtetrennung auf dem Host ## 6. Rechtetrennung auf dem Host
Der Webprozess laeuft als `tesm` bzw. `tesm-license`. Root-Aktionen gehen durch 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 genau einen allowlisted Helfer (`/usr/local/lib/tesm/tesm-helper`), der per
sudoers ausschliesslich fuer diesen Pfad freigegeben ist. sudoers ausschliesslich für diesen Pfad freigegeben ist.
Regeln, die im Repository durch Tests erzwungen werden: Regeln, die im Repository durch Tests erzwungen werden:
* Jedes Verb, das die Anwendung kennt, existiert im Helfer. * Jedes Verb, das die Anwendung kennt, existiert im Helfer.
* Kein `eval`, kein `bash -c`, kein `sh -c`. * Kein `eval`, kein `bash -c`, kein `sh -c`.
* Jede Pfadpruefung weist `..` ab -- sonst laesst sich eine Positivliste ueber * Jede Pfadprüfung weist `..` ab -- sonst lässt sich eine Positivliste über
den Basisnamen unterlaufen (`/etc/nginx/sites-available/../../../root/x` hat den Basisnamen unterlaufen (`/etc/nginx/sites-available/../../../root/x` hat
den zulaessigen Basisnamen `x`). den zulässigen Basisnamen `x`).
* Der Instanzname ist auf `tesm[-<instanz>]` begrenzt; er landet in Pfaden. * Der Instanzname ist auf `tesm[-<instanz>]` begrenzt; er landet in Pfaden.
* Keine sudoers-Regel ausser der einen. `NOPASSWD: ALL` gibt es nicht. * Keine sudoers-Regel ausser der einen. `NOPASSWD: ALL` gibt es nicht.
Die systemd-Units haben `NoNewPrivileges=yes`, `ProtectSystem=strict`, Die systemd-Units haben `NoNewPrivileges=yes`, `ProtectSystem=strict`,
`PrivateTmp=yes` und eine enge `RestrictAddressFamilies`-Liste. `PrivateTmp=yes` und eine enge `RestrictAddressFamilies`-Liste.
Der Vorgaenger liess die komplette Flask-Anwendung als root laufen. Eine Der Vorgänger liess die komplette Flask-Anwendung als root laufen. Eine
einzige Luecke in irgendeinem Pfad haette den Host bedeutet. einzige Lücke in irgendeinem Pfad hätte den Host bedeutet.
--- ---
## 7. Aenderungsprotokoll ## 7. Änderungsprotokoll
Verkettet gehasht: jeder Eintrag enthaelt den Hash seines Vorgaengers. Ein Verkettet gehasht: jeder Eintrag enthält den Hash seines Vorgängers. Ein
nachtraegliches Aendern oder Loeschen faellt auf und wird unter *Diagnose* nachträgliches Ändern oder Löschen fällt auf und wird unter *Diagnose*
angezeigt, mitsamt dem Eintrag, ab dem die Kette bricht. angezeigt, mitsamt dem Eintrag, ab dem die Kette bricht.
Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdruecklich Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdrücklich
auch Lesezugriffe auf Geheimnisse (`credential.secret_revealed`). auch Lesezugriffe auf Geheimnisse (`credential.secret_revealed`).
Aeltere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen. Ältere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen.
--- ---
@@ -145,20 +146,20 @@ Aeltere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen.
Siehe [ARCHITEKTUR.md](ARCHITEKTUR.md), Abschnitt Lizenzierung. Kurz: Siehe [ARCHITEKTUR.md](ARCHITEKTUR.md), Abschnitt Lizenzierung. Kurz:
getrennte Signaturkontexte, kanonisches JSON, Nonce gegen Wiedereinspielung, getrennte Signaturkontexte, kanonisches JSON, Nonce gegen Wiedereinspielung,
Frischefenster, `seq` gegen Rueckstufung, Antwort an die Anfrage gebunden, Frischefenster, `seq` gegen Rückstufung, Antwort an die Anfrage gebunden,
Aussteller-Pinning. Aussteller-Pinning.
Das Auslieferungsbundle enthaelt den privaten Clientschluessel und ist damit Das Auslieferungsbundle enthält den privaten Clientschlüssel und ist damit
selbst ein Geheimnis: eigenes Recht (`licenses.export`), frische selbst ein Geheimnis: eigenes Recht (`licenses.export`), frische
Passwortbestaetigung, `Cache-Control: no-store`, Eintrag im Zustell- und im Passwortbestätigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
Aenderungsprotokoll. Änderungsprotokoll.
--- ---
## 9. Ein- und Ausfuhr ## 9. Ein- und Ausfuhr
Verschluesselte Umschlaege (Argon2id-Ableitung + AES-256-GCM) mit einem selbst Verschlüsselte Umschläge (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
gewaehlten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben gewählten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb
braucht der Dienst kein `--workers 1` mehr. braucht der Dienst kein `--workers 1` mehr.
@@ -166,11 +167,15 @@ braucht der Dienst kein `--workers 1` mehr.
## 10. Bewusst nicht getan ## 10. Bewusst nicht getan
* **Kein Schalter, der die LDAP-Zertifikatspruefung abschaltet.** Wer ihn * **Kein Schalter, der die LDAP-Zertifikatsprüfung abschaltet.** Wer ihn
einmal setzt, setzt ihn dauerhaft. einmal setzt, setzt ihn dauerhaft.
* **Kein CSRF-Freibrief fuer cookie-autorisierte Routen.** * **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
* **Kein `unsafe-inline` fuer Skripte**, auch nicht "voruebergehend". * **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
* **Keine Rechteausweitung**, auch nicht fuer Administratoren untereinander. * **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 * **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
nicht brechen. nicht brechen.
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt. * **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
@@ -179,5 +184,5 @@ braucht der Dienst kein `--workers 1` mehr.
## 11. Wenn Sie eine Schwachstelle finden ## 11. Wenn Sie eine Schwachstelle finden
Bitte nicht oeffentlich melden. Wenden Sie sich an die im Anbieterprofil des Bitte nicht öffentlich melden. Wenden Sie sich an die im Anbieterprofil des
Lizenzservers hinterlegte Adresse. Lizenzservers hinterlegte Adresse.
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env bash
# =============================================================================
# Installation -- der kurze Weg.
#
# Diese Datei liegt oben im Paket und ruft den Installer unter deploy/ auf:
#
# tar xzf tesm-vX.Y.Z.tar.gz && cd tesm
# sudo bash install.sh --app tesm --https
#
# 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
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# --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
# 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"
}
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 ------------------------------------------------------
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
# 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[@]}"}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.0.0" version = "2.8.7"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+14 -1
View File
@@ -8,7 +8,20 @@ Design-System.
from __future__ import annotations from __future__ import annotations
__version__ = "2.0.0" #: 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 .app_factory import create_base_app
from .config import CoreConfig, SecurityConfig, load_core_config from .config import CoreConfig, SecurityConfig, load_core_config
+120 -11
View File
@@ -13,10 +13,12 @@ import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Sequence from typing import Any, Sequence
from flask import Blueprint, Flask, g, render_template, request from urllib.parse import urlencode
from flask import Blueprint, Flask, g, redirect, render_template, request
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from . import audit, logging_setup, ui from . import audit, legacy_urls, logging_setup, ui
from .config import CoreConfig from .config import CoreConfig
from .core_schema import CORE_MIGRATIONS from .core_schema import CORE_MIGRATIONS
from .db import Database from .db import Database
@@ -31,6 +33,7 @@ from .security import csrf
from .security import sessions as session_store from .security import sessions as session_store
from .security.sessions import SqliteSessionInterface from .security.sessions import SqliteSessionInterface
from .settings_store import SettingSpec, SettingsStore from .settings_store import SettingSpec, SettingsStore
from . import uploads
from .sysops import SysOps from .sysops import SysOps
from .auth import directory from .auth import directory
from .auth import routes as auth_routes from .auth import routes as auth_routes
@@ -43,6 +46,11 @@ from .views import hostsettings as hostsettings_views
from .views import logs as logs_views from .views import logs as logs_views
from .views import trash as trash_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, ...] = ( CORE_SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"), SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"),
SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"), SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"),
@@ -51,11 +59,34 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
30, 30,
"int", "int",
"Aufbewahrung im Papierkorb (Tage)", "Aufbewahrung im Papierkorb (Tage)",
hint="Nach dieser Frist werden Eintraege beim naechsten Aufruf endgueltig entfernt.", hint="Nach dieser Frist werden Einträge beim nächsten Aufruf endgültig entfernt.",
section="system", section="system",
minimum=1, minimum=1,
maximum=3650, 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("timezone", "", "str", "Zeitzone des Hosts", section="system"),
SettingSpec("hostname", "", "str", "Hostname", section="system"), SettingSpec("hostname", "", "str", "Hostname", section="system"),
SettingSpec( SettingSpec(
@@ -71,6 +102,14 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
), ),
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- # # -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung. # 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( SettingSpec(
"license_server_ca", "license_server_ca",
"", "",
@@ -83,8 +122,8 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", 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_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", section="web"),
SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"), SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"),
SettingSpec("web_key_path", "", "str", "Privater Schluessel (Pfad)", section="web"), SettingSpec("web_key_path", "", "str", "Privater Schlüssel (Pfad)", section="web"),
SettingSpec("web_acme_email", "", "str", "E-Mail fuer Let's Encrypt", 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"), SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"),
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion -- # Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
# im Regelbetrieb bleibt es bei 80 und 443. # im Regelbetrieb bleibt es bei 80 und 443.
@@ -95,7 +134,7 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535 "web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
), ),
SettingSpec( SettingSpec(
"web_max_body_mb", 128, "int", "Maximale Anfragegroesse (MB)", section="web", "web_max_body_mb", 128, "int", "Maximale Anfragegröße (MB)", section="web",
minimum=1, maximum=8192, minimum=1, maximum=8192,
), ),
*directory.SETTINGS, *directory.SETTINGS,
@@ -113,11 +152,15 @@ def create_base_app(
import_name: str = "tesm_core", import_name: str = "tesm_core",
app_short: str = "TESM", app_short: str = "TESM",
app_edition: str = "", app_edition: str = "",
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = kein #: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = nur die
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die #: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
#: Funktionsliste von TESM waere dort schlicht falsch. #: Anwendungen.
auth_tagline: str = "", auth_tagline: str = "",
app_version: str = "2.0.0", #: 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, ...] = (), built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True, enable_license: bool = True,
template_folder: str | None = None, template_folder: str | None = None,
@@ -129,6 +172,10 @@ def create_base_app(
static_folder=static_folder or "static", static_folder=static_folder or "static",
static_url_path="/assets", 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 # Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede # werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
@@ -146,6 +193,10 @@ def create_base_app(
config.data_dir.mkdir(parents=True, exist_ok=True) config.data_dir.mkdir(parents=True, exist_ok=True)
config.log_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( app.config.update(
SECRET_KEY=load_or_create_secret_key(config.secret_key_file), SECRET_KEY=load_or_create_secret_key(config.secret_key_file),
# site_key statt app_key: Cookies unterscheiden keine Ports. Zwei # site_key statt app_key: Cookies unterscheiden keine Ports. Zwei
@@ -155,6 +206,9 @@ def create_base_app(
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SECURE=config.security.cookie_secure, SESSION_COOKIE_SECURE=config.security.cookie_secure,
SESSION_COOKIE_SAMESITE=config.security.cookie_samesite, 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, MAX_CONTENT_LENGTH=config.security.max_content_length,
JSON_SORT_KEYS=False, JSON_SORT_KEYS=False,
TRAP_HTTP_EXCEPTIONS=False, TRAP_HTTP_EXCEPTIONS=False,
@@ -192,11 +246,34 @@ def create_base_app(
) )
app.extensions["tesm"] = extension 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( app.session_interface = SqliteSessionInterface(
database, database,
cookie_name=app.config["SESSION_COOKIE_NAME"], cookie_name=app.config["SESSION_COOKIE_NAME"],
idle_minutes=config.security.session_idle_minutes, idle_minutes=config.security.session_idle_minutes,
absolute_hours=config.security.session_absolute_hours, absolute_hours=config.security.session_absolute_hours,
idle_lookup=_leerlauf,
absolute_lookup=_gesamtdauer,
) )
# -- Schema und Startzustand ------------------------------------------ # # -- Schema und Startzustand ------------------------------------------ #
@@ -228,6 +305,17 @@ def create_base_app(
@app.after_request @app.after_request
def _finish_request(response): # type: ignore[no-untyped-def] 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 = "" hsts = ""
if config.security.hsts_enabled: if config.security.hsts_enabled:
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains" hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
@@ -264,6 +352,7 @@ def create_base_app(
"auth_tagline": auth_tagline, "auth_tagline": auth_tagline,
"app_version": app_version, "app_version": app_version,
"current_year": datetime.now(timezone.utc).year, "current_year": datetime.now(timezone.utc).year,
"copyright_notice": COPYRIGHT_NOTICE,
"current_user": current_user, "current_user": current_user,
"nav_items": nav_items, "nav_items": nav_items,
"palette_entries": palette, "palette_entries": palette,
@@ -285,6 +374,26 @@ def create_base_app(
# -- Fehlerseiten ------------------------------------------------------ # # -- Fehlerseiten ------------------------------------------------------ #
@app.errorhandler(HTTPException) @app.errorhandler(HTTPException)
def _http_error(error: HTTPException): # type: ignore[no-untyped-def] 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(): if _wants_json():
return { return {
"error": error.name, "error": error.name,
@@ -338,7 +447,7 @@ def create_base_app(
with app.app_context(): with app.app_context():
trash_views.register_core_providers() trash_views.register_core_providers()
app.add_url_rule("/gesundheit", "health", _health, methods=["GET"]) app.add_url_rule("/health", "health", _health, methods=["GET"])
return app return app
+46 -3
View File
@@ -28,7 +28,7 @@ import sqlite3
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Collection, Iterable, Mapping, Sequence
from .db import Database from .db import Database
@@ -116,6 +116,49 @@ def log(
return int(cursor.lastrowid or 0) 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]: def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``.""" """Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
sql = "SELECT * FROM audit_log ORDER BY id" sql = "SELECT * FROM audit_log ORDER BY id"
@@ -133,14 +176,14 @@ def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[
"ok": False, "ok": False,
"checked": checked, "checked": checked,
"broken_at": data["id"], "broken_at": data["id"],
"reason": "Vorgaenger-Hash passt nicht -- eine Zeile wurde entfernt oder eingefuegt.", "reason": "Vorgänger-Hash passt nicht – eine Zeile wurde entfernt oder eingefügt.",
} }
if compute_hash(str(data["prev_hash"]), data) != data["hash"]: if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
return { return {
"ok": False, "ok": False,
"checked": checked, "checked": checked,
"broken_at": data["id"], "broken_at": data["id"],
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash -- Eintrag geaendert.", "reason": "Zeileninhalt passt nicht zum gespeicherten Hash | Eintrag geändert.",
} }
expected_prev = str(data["hash"]) expected_prev = str(data["hash"])
checked += 1 checked += 1
@@ -26,6 +26,7 @@ import re
import socket import socket
import sqlite3 import sqlite3
import ssl import ssl
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Literal from typing import Any, Literal
@@ -42,7 +43,7 @@ AAD_LDAP_BIND = "service_accounts.ldap_bind"
#: userAccountControl-Bit fuer "Konto deaktiviert". #: userAccountControl-Bit fuer "Konto deaktiviert".
ACCOUNTDISABLE = 0x0002 ACCOUNTDISABLE = 0x0002
#: OID fuer rekursive Gruppenauflösung in AD. #: OID fuer rekursive Gruppenaufloesung in AD.
MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941" MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
Status = Literal["ok", "invalid", "unavailable", "disabled"] Status = Literal["ok", "invalid", "unavailable", "disabled"]
@@ -59,20 +60,20 @@ SETTINGS: tuple[SettingSpec, ...] = (
section="ldap", section="ldap",
choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")), choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
), ),
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat pruefen", section="ldap"), SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat prüfen", section="ldap"),
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"), SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
SettingSpec( SettingSpec(
"ldap_allow_insecure", "ldap_allow_insecure",
False, False,
"bool", "bool",
"Unverschluesselte Verbindung ausdruecklich erlauben", "Unverschlüsselte Verbindung ausdrücklich erlauben",
hint="Nur fuer Testumgebungen. Das Passwort geht dabei im Klartext ueber das Netz.", hint="Nur für Testumgebungen. Das Passwort geht dabei im Klartext über das Netz.",
section="ldap", section="ldap",
), ),
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"), SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"), SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"), SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"),
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis uebernehmen", section="ldap"), SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis übernehmen", section="ldap"),
SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60), SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60),
) )
@@ -123,7 +124,7 @@ def bind_credentials(conn: sqlite3.Connection) -> tuple[str, str]:
try: try:
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND) secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschluesselt werden") logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschlüsselt werden")
return str(row.get("username") or ""), "" return str(row.get("username") or ""), ""
return str(row.get("username") or ""), secret return str(row.get("username") or ""), secret
@@ -162,19 +163,82 @@ def _tls_object(config: dict[str, Any]):
def _server(config: dict[str, Any]): 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 from ldap3 import Server
security = str(config["ldap_security"]) security = str(config["ldap_security"])
braucht_basis = not str(config.get("ldap_base_dn") or "").strip()
return Server( return Server(
str(config["ldap_host"]), str(config["ldap_host"]),
port=int(config["ldap_port"]), port=int(config["ldap_port"]),
use_ssl=security == "ldaps", use_ssl=security == "ldaps",
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None, tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
get_info="ALL", get_info="DSA" if braucht_basis else "NO_INFO",
connect_timeout=int(config["ldap_timeout"]), 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): def _connect(config: dict[str, Any], user: str, password: str):
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen.""" """Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
from ldap3 import Connection from ldap3 import Connection
@@ -182,7 +246,7 @@ def _connect(config: dict[str, Any], user: str, password: str):
security = str(config["ldap_security"]) security = str(config["ldap_security"])
if security == "none" and not config["ldap_allow_insecure"]: if security == "none" and not config["ldap_allow_insecure"]:
raise PermissionError( raise PermissionError(
"Unverschluesselte Verzeichnisverbindungen sind deaktiviert. " "Unverschlüsselte Verzeichnisverbindungen sind deaktiviert. "
"Nutzen Sie LDAPS oder StartTLS." "Nutzen Sie LDAPS oder StartTLS."
) )
connection = Connection( connection = Connection(
@@ -192,6 +256,12 @@ def _connect(config: dict[str, Any], user: str, password: str):
auto_bind=False, auto_bind=False,
raise_exceptions=False, raise_exceptions=False,
receive_timeout=int(config["ldap_timeout"]), 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 security == "starttls":
if not connection.open(): if not connection.open():
@@ -249,12 +319,23 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
bind_user, bind_password = bind_credentials(conn) bind_user, bind_password = bind_credentials(conn)
warnings: list[str] = [] warnings: list[str] = []
if str(config["ldap_security"]) == "none": if str(config["ldap_security"]) == "none":
warnings.append("Unverschluesselte Verzeichnisverbindung in Benutzung") warnings.append("Unverschlüsselte Verzeichnisverbindung in Benutzung")
if not config["ldap_verify_cert"]: if not config["ldap_verify_cert"]:
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprueft") 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: try:
service_conn, bound = _connect(config, bind_user, bind_password) service_conn, bound = mit_wiederholung(
lambda: _connect(config, bind_user, bind_password)
)
except (PermissionError, ConnectionError) as exc: except (PermissionError, ConnectionError) as exc:
logger.error("Verzeichnisdienst: %s", exc) logger.error("Verzeichnisdienst: %s", exc)
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings)) return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
@@ -271,8 +352,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
warnings=tuple(warnings), warnings=tuple(warnings),
) )
marke = gemessen("Dienstbindung", begonnen)
try: try:
base = _base_dn(service_conn, config) base = _base_dn(service_conn, config)
marke = gemessen("Basis-DN", marke)
if not base: if not base:
return DirectoryResult( return DirectoryResult(
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings) "unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
@@ -288,8 +371,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
attributes=list(dict.fromkeys(wanted)), attributes=list(dict.fromkeys(wanted)),
size_limit=2, size_limit=2,
) )
marke = gemessen("Benutzersuche", marke)
entries = list(service_conn.entries) entries = list(service_conn.entries)
if len(entries) != 1: if len(entries) != 1:
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
return DirectoryResult("invalid", warnings=tuple(warnings)) return DirectoryResult("invalid", warnings=tuple(warnings))
entry = entries[0] entry = entries[0]
@@ -300,9 +385,18 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche # Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden. # Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
user_conn, user_bound = _connect(config, dn, password) #
# 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: try:
if not user_bound: if not user_bound:
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
return DirectoryResult("invalid", warnings=tuple(warnings)) return DirectoryResult("invalid", warnings=tuple(warnings))
finally: finally:
try: try:
@@ -313,6 +407,7 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
group_dns: tuple[str, ...] = () group_dns: tuple[str, ...] = ()
if config["ldap_group_sync"]: if config["ldap_group_sync"]:
group_dns = _resolve_groups(service_conn, base, dn) group_dns = _resolve_groups(service_conn, base, dn)
marke = gemessen("Gruppen", marke)
profile = DirectoryProfile( profile = DirectoryProfile(
username=_attr(entry, attribute) or username, username=_attr(entry, attribute) or username,
@@ -322,6 +417,15 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
email=_attr(entry, email_attribute), email=_attr(entry, email_attribute),
group_dns=group_dns, 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)) return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
finally: finally:
try: try:
@@ -349,7 +453,7 @@ def _attr_int(entry: Any, name: str) -> int | None:
def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]: def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]:
"""Rekursive Gruppenauflösung ueber LDAP_MATCHING_RULE_IN_CHAIN.""" """Rekursive Gruppenaufloesung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
try: try:
connection.search( connection.search(
search_base=base, search_base=base,
@@ -423,18 +527,24 @@ def _apply_group_mappings(
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg. haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
""" """
if not group_dns:
return
mappings = Database.all( mappings = Database.all(
conn, "SELECT ad_group_dn, app_group_id FROM ldap_group_mappings" conn, "SELECT ad_group_dn, app_group_id, grants_admin FROM ldap_group_mappings"
) )
if not mappings: if not mappings:
return return
mapped_group_ids = {int(row["app_group_id"]) for row in mappings}
lowered = {dn.lower() for dn in group_dns} 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 = { wanted = {
int(row["app_group_id"]) int(row["app_group_id"])
for row in mappings for row in gruppenzuordnungen
if str(row["ad_group_dn"]).lower() in lowered if str(row["ad_group_dn"]).lower() in lowered
} }
@@ -452,6 +562,71 @@ def _apply_group_mappings(
) )
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]]: 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,)) return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,))
+80 -12
View File
@@ -10,6 +10,7 @@ from flask import abort, flash, g, redirect, request, session, url_for
from werkzeug.local import LocalProxy from werkzeug.local import LocalProxy
from ..rbac.model import Action, PermissionTree from ..rbac.model import Action, PermissionTree
from ..security.sessions import is_automatic
AUTH_SOURCES = ("local", "ldap") AUTH_SOURCES = ("local", "ldap")
@@ -76,25 +77,40 @@ class User:
@property @property
def effective_permissions(self) -> frozenset[str]: def effective_permissions(self) -> frozenset[str]:
"""Rechte nach Anwendung der Bereichs-Kill-Switches.""" """Rechte nach Anwendung der Bereichs-Kill-Switches.
Ein echter Administrator hat den Verwaltungsbereich immer dabei -- auch
ohne Gruppe und ohne dass jemand ihn angehakt haette. Sonst koennte
sich das letzte Administratorkonto aus der eigenen Anlage aussperren,
indem es seine Gruppe verliert.
"""
if self._effective is None: if self._effective is None:
tree = self._tree tree = self._tree
self._effective = ( if tree is None:
frozenset(tree.effective(self.granted)) if tree is not None else frozenset() self._effective = frozenset()
) else:
eigene = set(tree.effective(self.granted))
if self.is_admin:
eigene |= set(tree.verwaltungsrechte())
self._effective = frozenset(eigene)
return self._effective return self._effective
def has(self, *permissions: str) -> bool: def has(self, *permissions: str) -> bool:
"""True, wenn **alle** angegebenen Rechte greifen. Admins duerfen immer alles.""" """True, wenn **alle** angegebenen Rechte greifen.
if self.is_admin:
return True Das Administratorkennzeichen gibt seit 2.8.0 **den Verwaltungsbereich**
frei und nicht mehr jede Pruefung: Benutzer, Gruppen, Einstellungen,
Sicherung, Papierkorb, Diagnose. Funktionsrechte -- Geraete, Neustarts,
DHCP, Freigaben -- kommen ueber Gruppen, und die darf sich ein
Administrator selbst zuweisen. Der Unterschied ist nicht, *ob* er sie
bekommen kann, sondern dass es dann eine Zeile im
Aenderungsprotokoll gibt und in der Matrix zu sehen ist.
"""
if not permissions: if not permissions:
return True return True
return all(permission in self.effective_permissions for permission in permissions) return all(permission in self.effective_permissions for permission in permissions)
def has_any(self, permissions: Iterable[str]) -> bool: def has_any(self, permissions: Iterable[str]) -> bool:
if self.is_admin:
return True
effective = self.effective_permissions effective = self.effective_permissions
return any(permission in effective for permission in permissions) return any(permission in effective for permission in permissions)
@@ -189,9 +205,22 @@ def _deny(message: str) -> Any:
if not current_user.is_authenticated: if not current_user.is_authenticated:
if request.method == "GET" and not request.headers.get("X-Requested-With"): if request.method == "GET" and not request.headers.get("X-Requested-With"):
session["_after_login"] = request.full_path if request.query_string else request.path session["_after_login"] = request.full_path if request.query_string else request.path
# Eine automatische Aktualisierung fragt einen Ausschnitt ab und
# erwartet HTML. Eine Umleitung auf die Anmeldeseite wuerde sie
# dankbar entgegennehmen und eine ganze Seite in eine Kachel setzen --
# der Tab bliebe stumm stehen, obwohl die Sitzung abgelaufen ist. 401
# sagt es deutlich; der Browser laedt daraufhin die Seite neu und der
# Mensch sieht die Anmeldemaske.
if is_automatic(request):
abort(401, description="Sitzung abgelaufen.")
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html: if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
abort(401, description="Anmeldung erforderlich.") abort(401, description="Anmeldung erforderlich.")
flash("Bitte melden Sie sich an.", "info") # "Bitte melden Sie sich an." stand auf einer Seite, die aus einem
# Anmeldefeld, einem Passwortfeld und einem Knopf "Anmelden" besteht --
# eine Meldung, die nichts hinzufuegt. Wer dagegen gerade noch
# angemeldet war, soll erfahren, warum er es nicht mehr ist.
if getattr(g, "session_expired", False):
flash("Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", "info")
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
abort(403, description=message) abort(403, description=message)
@@ -206,6 +235,37 @@ def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
return wrapper return wrapper
def _verlangt_wiederanmeldung(permission: str) -> bool:
"""Fragt das Rechtemodell -- ausserhalb einer Anwendung nie."""
try:
from ..extension import core
return bool(core().permissions.verlangt_wiederanmeldung(permission))
except Exception: # noqa: BLE001 - ohne Modell keine zusaetzliche Huerde
return False
def _wiederanmeldung_frisch() -> bool:
from .service import reauth_is_fresh # zyklischen Import vermeiden
return reauth_is_fresh()
def _bitte_bestaetigen(permission: str) -> Any:
"""Zur Passwortbestaetigung schicken -- oder es einem Skript sagen.
Ein Aufruf per fetch bekaeme sonst die Anmeldeseite als Antwort und wuesste
nichts damit anzufangen.
"""
if is_automatic(request) or (
request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html
):
abort(403, description="Bitte bestätigen Sie zur Sicherheit Ihr Passwort.")
session["_after_reauth"] = request.full_path if request.query_string else request.path
flash("Bitte bestätigen Sie zur Sicherheit Ihr Passwort.", "info")
return redirect(url_for("auth.reauth"))
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]: def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
"""Erzwingt Rechte serverseitig. """Erzwingt Rechte serverseitig.
@@ -223,7 +283,15 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A
else current_user.has(*permissions) else current_user.has(*permissions)
) )
if not ok: if not ok:
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.") return _deny("Für diese Seite fehlt Ihnen die Berechtigung.")
# Geheimnisse und Exporte verlangen eine frische Bestaetigung --
# das steht im Rechtemodell und wird deshalb hier abgefragt, nicht
# in jeder Route einzeln. Vorher hing es daran, dass jemand an den
# Dekorator dachte: dieselbe Lizenz herunterzuladen fragte nach,
# sie per Mail zu verschicken nicht.
heikel = [p for p in permissions if _verlangt_wiederanmeldung(p)]
if heikel and not _wiederanmeldung_frisch():
return _bitte_bestaetigen(heikel[0])
return view(*args, **kwargs) return view(*args, **kwargs)
wrapper._required_permissions = permissions # type: ignore[attr-defined] wrapper._required_permissions = permissions # type: ignore[attr-defined]
@@ -265,7 +333,7 @@ def reauth_required(view: Callable[..., Any]) -> Callable[..., Any]:
return _deny("Anmeldung erforderlich.") return _deny("Anmeldung erforderlich.")
if not reauth_is_fresh(): if not reauth_is_fresh():
session["_after_reauth"] = request.full_path if request.query_string else request.path session["_after_reauth"] = request.full_path if request.query_string else request.path
flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info") flash("Bitte bestätigen Sie zur Sicherheit Ihr Passwort.", "info")
return redirect(url_for("auth.reauth")) return redirect(url_for("auth.reauth"))
return view(*args, **kwargs) return view(*args, **kwargs)
@@ -66,7 +66,7 @@ def login():
) )
@bp.route("/login/zweiter-faktor", methods=["GET", "POST"]) @bp.route("/login/second-factor", methods=["GET", "POST"])
def second_factor(): def second_factor():
conn = db() conn = db()
user_row = service.pending_totp_user(conn) user_row = service.pending_totp_user(conn)
@@ -116,7 +116,7 @@ def logout_get():
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
@bp.route("/bestaetigen", methods=["GET", "POST"]) @bp.route("/confirm", methods=["GET", "POST"])
@login_required @login_required
def reauth(): def reauth():
target = _safe_next(session.get("_after_reauth")) target = _safe_next(session.get("_after_reauth"))
@@ -151,7 +151,7 @@ def authenticate(
if ip_verdict.blocked: if ip_verdict.blocked:
return AuthOutcome( return AuthOutcome(
"rate_limited", "rate_limited",
"Zu viele Anmeldeversuche von dieser Adresse. Bitte spaeter erneut versuchen.", "Zu viele Anmeldeversuche von dieser Adresse. Bitte später erneut versuchen.",
retry_after=ip_verdict.retry_after_seconds, retry_after=ip_verdict.retry_after_seconds,
) )
@@ -165,7 +165,7 @@ def authenticate(
if user_verdict.blocked: if user_verdict.blocked:
return AuthOutcome( return AuthOutcome(
"rate_limited", "rate_limited",
"Dieses Konto ist nach mehreren Fehlversuchen voruebergehend gesperrt.", "Dieses Konto ist nach mehreren Fehlversuchen vorübergehend gesperrt.",
retry_after=user_verdict.retry_after_seconds, retry_after=user_verdict.retry_after_seconds,
) )
@@ -248,7 +248,7 @@ def _verify_credentials(
return AuthOutcome( return AuthOutcome(
"directory_unavailable", "directory_unavailable",
"Der Verzeichnisdienst ist derzeit nicht erreichbar. " "Der Verzeichnisdienst ist derzeit nicht erreichbar. "
"Bitte versuchen Sie es spaeter erneut.", "Bitte versuchen Sie es später erneut.",
) )
if result.status != "ok" or result.profile is None: if result.status != "ok" or result.profile is None:
return AuthOutcome("invalid", GENERIC_FAILURE) return AuthOutcome("invalid", GENERIC_FAILURE)
@@ -257,6 +257,24 @@ def _verify_credentials(
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns) return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
def _ist_erstanmeldung(user_row: dict[str, Any]) -> bool:
"""Meldet sich dieses Konto zum ersten Mal ueberhaupt an?
Das Aenderungsprotokoll soll Aenderungen zeigen. Eine taegliche Anmeldung
ist keine -- sie erzeugte aber jedes Mal eine Zeile und schob damit echte
Eintraege aus dem Blickfeld. Interessant ist genau die erste: mit ihr
entsteht das Konto aus dem Verzeichnisdienst und betritt jemand die
Anwendung, der vorher nicht darin war.
Nicht betroffen: fehlgeschlagene Anmeldungen und Sperren. Die stehen
weiterhin **jedes Mal** im Protokoll -- dort liegt der sicherheitsrelevante
Teil, und Haeufung ist dort das Signal. Wer wann zuletzt angemeldet war,
steht ausserdem unveraendert am Konto und in der Sitzungsuebersicht.
"""
return not str(user_row.get("last_login_at") or "").strip()
def complete_login( def complete_login(
conn: sqlite3.Connection, conn: sqlite3.Connection,
user_row: dict[str, Any], user_row: dict[str, Any],
@@ -275,21 +293,26 @@ def complete_login(
""" """
from ..security import csrf from ..security import csrf
# Vor dem UPDATE lesen -- danach steht ueberall ein Zeitstempel.
erstanmeldung = _ist_erstanmeldung(user_row)
with core().database.transaction(conn): with core().database.transaction(conn):
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type] session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
conn.execute( conn.execute(
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"]) "UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
) )
audit.log( if erstanmeldung:
conn, audit.log(
actor=str(user_row["username"]), conn,
actor_id=int(user_row["id"]), actor=str(user_row["username"]),
action="auth.login", actor_id=int(user_row["id"]),
target=str(user_row["username"]), action="auth.first_login",
detail=f"Quelle: {user_row.get('auth_source') or 'local'}", target=str(user_row["username"]),
ip=ip, detail=f"Erste Anmeldung | Quelle: {user_row.get('auth_source') or 'local'}",
request_id=getattr(g, "request_id", ""), ip=ip,
) request_id=getattr(g, "request_id", ""),
severity="notice",
)
session.clear() session.clear()
session[SESSION_USER_KEY] = int(user_row["id"]) session[SESSION_USER_KEY] = int(user_row["id"])
@@ -336,15 +359,11 @@ def logout(conn: sqlite3.Connection) -> None:
current_app.logger.exception( current_app.logger.exception(
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook) "Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
) )
with core().database.transaction(conn): # Kein Protokolleintrag fuer das Abmelden: eine Abmeldung aendert
audit.log( # nichts. Sie stand als "Info" in jeder zweiten Zeile und verdraengte
conn, # die Eintraege, um die es geht. Wer wissen will, wer wann da war,
actor=user.username, # liest die Sitzungsuebersicht; fehlgeschlagene Anmeldungen und
actor_id=user.id, # Sperren stehen weiterhin jedes Mal im Protokoll.
action="auth.logout",
target=user.username,
ip=client_ip(),
)
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined] session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
g.user = ANONYMOUS g.user = ANONYMOUS
+12 -12
View File
@@ -83,7 +83,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
port = getattr(args, flag, None) port = getattr(args, flag, None)
if port: if port:
if not 1 <= int(port) <= 65535: if not 1 <= int(port) <= 65535:
print(f"Ungueltiger Port: {port}", file=sys.stderr) print(f"Ungültiger Port: {port}", file=sys.stderr)
return 2 return 2
values[key] = int(port) values[key] = int(port)
@@ -93,11 +93,11 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
# -- Zertifikat --------------------------------------------------- # # -- Zertifikat --------------------------------------------------- #
config = _config_from_settings(extension, conn) config = _config_from_settings(extension, conn)
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unveraendert."} cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unverändert."}
if args.acme: if args.acme:
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr) print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
return 2 return 2
cert = webserver.request_certificate( cert = webserver.request_certificate(
sysops, sysops,
@@ -118,7 +118,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
if args.self_signed and not args.acme: if args.self_signed and not args.acme:
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr) print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
return 2 return 2
suggested = webserver.local_names() suggested = webserver.local_names()
cert = webserver.create_self_signed( cert = webserver.create_self_signed(
@@ -144,7 +144,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
if args.https: if args.https:
if not (config.cert_path and config.key_path): if not (config.cert_path and config.key_path):
print( print(
"HTTPS verlangt Zertifikat und Schluessel -- bitte --self-signed " "HTTPS verlangt Zertifikat und Schlüssel – bitte --self-signed "
"oder --acme angeben.", "oder --acme angeben.",
file=sys.stderr, file=sys.stderr,
) )
@@ -172,7 +172,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
return 0 return 0
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr) print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
return 2 return 2
result = webserver.apply_config(sysops, rendered) result = webserver.apply_config(sysops, rendered)
print(result["message"]) print(result["message"])
@@ -194,7 +194,7 @@ def add_web_setup(sub: Any, runner: Any) -> None:
help="nginx und TLS einrichten (Port 80/443)", help="nginx und TLS einrichten (Port 80/443)",
description=( description=(
"Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. " "Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. "
"Die Datei wird immer vollstaendig neu geschrieben -- eine von Hand " "Die Datei wird immer vollständig neu geschrieben – eine von Hand "
"bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht." "bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht."
), ),
) )
@@ -207,20 +207,20 @@ def add_web_setup(sub: Any, runner: Any) -> None:
parser.add_argument( parser.add_argument(
"--acme", "--acme",
action="store_true", action="store_true",
help="Zertifikat per Let's Encrypt anfordern (Domain muss oeffentlich zeigen)", help="Zertifikat per Let's Encrypt anfordern (Domain muss öffentlich zeigen)",
) )
parser.add_argument("--domain", help="Oeffentlicher Name dieser Installation") parser.add_argument("--domain", help="Öffentlicher Name dieser Installation")
parser.add_argument("--acme-email", help="Kontaktadresse fuer Let's Encrypt") parser.add_argument("--acme-email", help="Kontaktadresse für Let's Encrypt")
parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen") parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen")
parser.add_argument( parser.add_argument(
"--no-redirect", "--no-redirect",
action="store_true", action="store_true",
help="Port 80 nicht auf HTTPS umleiten (Sonderfall)", help="Port 80 nicht auf HTTPS umleiten (Sonderfall)",
) )
parser.add_argument("--max-body-mb", type=int, help="Obergrenze fuer Uploads") parser.add_argument("--max-body-mb", type=int, help="Obergrenze für Uploads")
parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)") parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)")
parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)") parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)")
parser.add_argument("--apply", action="store_true", help="nginx schreiben, pruefen, neu laden") parser.add_argument("--apply", action="store_true", help="nginx schreiben, prüfen, neu laden")
parser.add_argument( parser.add_argument(
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben" "--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
) )
+5 -5
View File
@@ -45,7 +45,7 @@ class SecurityConfig:
cookie_secure: bool = True cookie_secure: bool = True
cookie_samesite: str = "Lax" cookie_samesite: str = "Lax"
#: Leerlauf-Zeitfenster einer Sitzung. #: Leerlauf-Zeitfenster einer Sitzung.
session_idle_minutes: int = 60 session_idle_minutes: int = 30
#: Harte Obergrenze unabhaengig von Aktivitaet. #: Harte Obergrenze unabhaengig von Aktivitaet.
session_absolute_hours: int = 12 session_absolute_hours: int = 12
#: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt. #: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt.
@@ -76,7 +76,7 @@ class CoreConfig:
"""Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units.""" """Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units."""
site_key: str site_key: str
"""Name *dieser Installation* auf dem Host, z. B. ``tesm-opus``. """Name *dieser Installation* auf dem Host, z. B. ``tesm-test``.
Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite
Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit
@@ -147,7 +147,7 @@ def load_core_config(
security = SecurityConfig( security = SecurityConfig(
cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True), cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True),
cookie_samesite=_env(env_prefix, "COOKIE_SAMESITE") or "Lax", cookie_samesite=_env(env_prefix, "COOKIE_SAMESITE") or "Lax",
session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 60), session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 30),
session_absolute_hours=_env_int(env_prefix, "SESSION_ABSOLUTE_HOURS", 12), session_absolute_hours=_env_int(env_prefix, "SESSION_ABSOLUTE_HOURS", 12),
reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15), reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15),
login_max_attempts=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS", 8), login_max_attempts=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS", 8),
@@ -175,11 +175,11 @@ def load_core_config(
bind = (_env(env_prefix, "BIND") or default_bind).strip() bind = (_env(env_prefix, "BIND") or default_bind).strip()
if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind): if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind):
raise ValueError(f"Ungueltige Bind-Adresse: {bind!r}") raise ValueError(f"Ungültige Bind-Adresse: {bind!r}")
site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower() site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower()
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key): if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key):
raise ValueError(f"Ungueltiger SITE_KEY: {site_key!r}") raise ValueError(f"Ungültiger SITE_KEY: {site_key!r}")
return CoreConfig( return CoreConfig(
app_key=app_key, app_key=app_key,
+144 -2
View File
@@ -141,11 +141,16 @@ CREATE TABLE IF NOT EXISTS service_accounts (
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- Eine Verzeichnisgruppe zeigt entweder auf eine lokale Gruppe **oder** auf
-- das Administratorkennzeichen (grants_admin). Darum ist app_group_id
-- nullbar: "Domänen-Admins" soll echte Administratoren erzeugen koennen und
-- nicht nur Mitglieder der Gruppe "Administration".
CREATE TABLE IF NOT EXISTS ldap_group_mappings ( CREATE TABLE IF NOT EXISTS ldap_group_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL UNIQUE, ad_group_dn TEXT NOT NULL UNIQUE,
ad_group_name TEXT NOT NULL, ad_group_name TEXT NOT NULL,
app_group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, app_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
grants_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
""" """
@@ -186,8 +191,145 @@ CREATE INDEX IF NOT EXISTS idx_jobs_kind_state ON jobs(kind, state);
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at); CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
""" """
def benenne_rechte_um(conn) -> None: # type: ignore[no-untyped-def]
"""Schreibt bestehende Gruppenrechte auf das neue Vokabular um.
``devices.view`` wird ``devices.read``, ``devices.edit`` wird
``devices.write``. Ohne diesen Schritt stehen in den Gruppen nur noch
Schluessel, die der Rechtebaum nicht kennt -- und
:meth:`PermissionTree.sanitize` wirft Unbekanntes **still** weg. Nach dem
Update waeren alle Gruppen leer und niemand kaeme mehr an seine Seiten:
kein Fehler, den man sieht, sondern einer, den man erst merkt, wenn jemand
anruft.
Im Kern und nicht in den Anwendungen, weil die Tabelle beiden gehoert.
Idempotent: was schon neu heisst, wird nicht angefasst.
"""
from .rbac.legacy_permissions import UMBENANNTE_AKTIONEN
for alt, neu in UMBENANNTE_AKTIONEN.items():
# Erst die Dubletten weg, die beim Umschreiben entstehen wuerden: haette
# eine Gruppe (aus welchem Grund auch immer) beide Schluessel, brach der
# eindeutige Index. Lieber vorher aufraeumen als in der Migration
# scheitern.
conn.execute(
"DELETE FROM group_permissions WHERE permission LIKE ? AND EXISTS ("
" SELECT 1 FROM group_permissions AS andere"
" WHERE andere.group_id = group_permissions.group_id"
" AND andere.permission = substr(group_permissions.permission, 1,"
" length(group_permissions.permission) - ?) || ?)",
(f"%.{alt}", len(alt), neu),
)
conn.execute(
"UPDATE group_permissions"
" SET permission = substr(permission, 1, length(permission) - ?) || ?"
" WHERE permission LIKE ?",
(len(alt), neu, f"%.{alt}"),
)
def zuordnung_auf_kennzeichen(conn) -> None: # type: ignore[no-untyped-def]
"""Erlaubt einer Verzeichnisgruppe, das Administratorkennzeichen zu setzen.
Bisher konnte eine Zuordnung nur auf eine lokale Gruppe zeigen. "Domänen-
Admins" landete deshalb in der Gruppe *Administration* -- und wurde damit
gerade **kein** echter Administrator: die Gruppe pflegt Benutzer und
Rechte, hat aber kein Kennzeichen und kann sich keines geben.
Zwei Aenderungen an der Tabelle, und SQLite kann Spalten nicht
umdefinieren: also neu anlegen und umkopieren. ``app_group_id`` wird
nullbar (eine Zuordnung auf das Kennzeichen zeigt auf keine Gruppe), und
``grants_admin`` sagt, dass genau das gemeint ist.
"""
spalten = {
str(zeile[1]) for zeile in conn.execute("PRAGMA table_info(ldap_group_mappings)")
}
if "grants_admin" in spalten:
return
conn.execute(
"""
CREATE TABLE ldap_group_mappings_neu (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL UNIQUE,
ad_group_name TEXT NOT NULL,
app_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
grants_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"INSERT INTO ldap_group_mappings_neu "
"(id, ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
"SELECT id, ad_group_dn, ad_group_name, app_group_id, 0, created_at "
" FROM ldap_group_mappings"
)
conn.execute("DROP TABLE ldap_group_mappings")
conn.execute("ALTER TABLE ldap_group_mappings_neu RENAME TO ldap_group_mappings")
def teile_rechte_auf(conn) -> None: # type: ignore[no-untyped-def]
"""Verteilt ein altes Sammelrecht auf die Zeilen, die daraus wurden.
Additiv: wer ``dhcp.edit`` hatte, bekommt alle fuenf DHCP-Zeilen, und das
alte Recht verschwindet. Ohne diesen Schritt stuende in der Gruppe ein
Schluessel, den der Baum nicht kennt -- und weg waere er still, denn genau
das tut :meth:`PermissionTree.sanitize` mit Unbekanntem.
Laeuft nach ``benenne_rechte_um``: die Tabelle nennt beide Schreibweisen,
wo es darauf ankommt, und ein zweiter Lauf findet nichts mehr zu tun.
"""
from .rbac.legacy_permissions import (
ERWEITERTE_RECHTE,
GETEILTE_RECHTE,
neue_schluessel,
)
def gruppen_mit(recht: str) -> list[int]:
return [
int(zeile[0])
for zeile in conn.execute(
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?",
(recht,),
).fetchall()
]
# Geteilt: das alte Recht gibt es nicht mehr, also ersetzen.
for altes, neue in GETEILTE_RECHTE.items():
gruppen = gruppen_mit(altes)
if not gruppen:
continue
conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(gid, recht) for gid in gruppen for recht in neue],
)
conn.execute("DELETE FROM group_permissions WHERE permission=?", (altes,))
# Erweitert: das Recht bleibt und bekommt dazu, was bisher an ihm hing.
# Loeschen waere hier falsch -- es ist keines von gestern.
for recht, dazu in ERWEITERTE_RECHTE.items():
gruppen = gruppen_mit(recht)
if not gruppen:
continue
conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(gid, weiteres) for gid in gruppen for weiteres in dazu],
)
assert neue_schluessel # in derselben Tabelle nachgeschlagen wie beim Import
CORE_MIGRATIONS: tuple[Migration, ...] = ( CORE_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1_BASELINE), Migration(
version=1,
name="baseline",
sql=V1_BASELINE,
geaendert_weil=(
"Das Grundschema legt ldap_group_mappings jetzt mit grants_admin und nullbarer app_group_id an -- eine neue Installation soll nicht erst core/6 brauchen, um die Tabelle wieder umzubauen."
),
),
Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS), Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
Migration(version=3, name="jobs", sql=V3_JOBS), Migration(version=3, name="jobs", sql=V3_JOBS),
Migration(version=4, name="rechte-read-write", fn=benenne_rechte_um),
Migration(version=5, name="rechte-je-funktion", fn=teile_rechte_auf),
Migration(version=6, name="ldap-kennzeichen", fn=zuordnung_auf_kennzeichen),
) )
+25 -2
View File
@@ -80,8 +80,8 @@ class Database:
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft.""" """Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
if self._depth() > 0: if self._depth() > 0:
raise NestedConnectionError( raise NestedConnectionError(
"Es laeuft bereits eine Schreibtransaktion auf dieser Datenbank. " "Es läuft bereits eine Schreibtransaktion auf dieser Datenbank. "
"Reiche die bestehende Verbindung durch, statt eine neue zu oeffnen " "Reiche die bestehende Verbindung durch, statt eine neue zu öffnen "
"(sonst blockiert SQLite sich selbst)." "(sonst blockiert SQLite sich selbst)."
) )
return self._raw_connect() return self._raw_connect()
@@ -119,6 +119,29 @@ class Database:
finally: finally:
conn.close() conn.close()
@contextmanager
def dauerhaft(self, conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
"""Fuer Schreibvorgaenge, die einen Stromausfall ueberstehen muessen.
Im Alltag laeuft die Datenbank mit ``synchronous=NORMAL``: schnell, und
bei einem Stromausfall gehen hoechstens die letzten Sekunden verloren --
die Datei bleibt heil. Fuer einen Messwert ist das genau richtig.
Fuer eine Unterschrift nicht. Dort liegt die Datei anschliessend auf der
Freigabe, und fehlte der Eintrag dazu, waere sie "unbekannt" statt
nachweisbar. Ein fsync kostet ein paar Millisekunden -- bei ein paar
Unterschriften am Tag ist das keine Diskussion wert.
Umschliesst die Transaktion, nicht umgekehrt: ``PRAGMA synchronous``
wirkt auf den naechsten Commit, und gesetzt werden kann es nur
ausserhalb einer laufenden Transaktion.
"""
conn.execute("PRAGMA synchronous=FULL")
try:
yield conn
finally:
conn.execute("PRAGMA synchronous=NORMAL")
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Transaktionen # Transaktionen
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -79,7 +79,7 @@ def core(app: Flask | None = None) -> TesmCore:
try: try:
return target.extensions["tesm"] # type: ignore[return-value] return target.extensions["tesm"] # type: ignore[return-value]
except KeyError as exc: # pragma: no cover - Programmierfehler except KeyError as exc: # pragma: no cover - Programmierfehler
raise RuntimeError("tesm_core ist fuer diese Anwendung nicht initialisiert") from exc raise RuntimeError("tesm_core ist für diese Anwendung nicht initialisiert") from exc
def db() -> sqlite3.Connection: def db() -> sqlite3.Connection:
+6 -6
View File
@@ -78,7 +78,7 @@ def check_private_file(path: Path) -> list[str]:
except OSError: except OSError:
return problems return problems
if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH): if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH):
problems.append(f"{path} ist fuer Gruppe/Andere lesbar oder schreibbar (erwartet 0600)") problems.append(f"{path} ist für Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
return problems return problems
@@ -110,11 +110,11 @@ class Keystore:
created_at=str(entry.get("created_at", "")), created_at=str(entry.get("created_at", "")),
) )
if len(key.material) != _KEY_BYTES: if len(key.material) != _KEY_BYTES:
raise ValueError(f"Schluessel {key.key_id} hat die falsche Laenge") raise ValueError(f"Schlüssel {key.key_id} hat die falsche Länge")
self._keys[key.key_id] = key self._keys[key.key_id] = key
self._active = str(raw.get("active") or "") self._active = str(raw.get("active") or "")
if self._active not in self._keys: if self._active not in self._keys:
raise ValueError("Aktiver Schluessel fehlt im Schluesselbund") raise ValueError("Aktiver Schlüssel fehlt im Schlüsselbund")
return return
self._active = "1" self._active = "1"
self._keys = { self._keys = {
@@ -160,7 +160,7 @@ class Keystore:
def drop_key(self, key_id: str) -> None: def drop_key(self, key_id: str) -> None:
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung.""" """Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
if key_id == self._active: if key_id == self._active:
raise ValueError("Der aktive Schluessel kann nicht entfernt werden") raise ValueError("Der aktive Schlüssel kann nicht entfernt werden")
self._keys.pop(key_id, None) self._keys.pop(key_id, None)
self._persist() self._persist()
@@ -183,14 +183,14 @@ class Keystore:
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}") raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
key = self._keys.get(key_id) key = self._keys.get(key_id)
if key is None: if key is None:
raise DecryptionError(f"Schluessel {key_id} ist nicht im Schluesselbund") raise DecryptionError(f"Schlüssel {key_id} ist nicht im Schlüsselbund")
try: try:
plain = AESGCM(key.material).decrypt( plain = AESGCM(key.material).decrypt(
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8") _b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
) )
except (InvalidTag, ValueError) as exc: except (InvalidTag, ValueError) as exc:
raise DecryptionError( raise DecryptionError(
"Chiffrat konnte nicht entschluesselt werden (falscher Schluessel, " "Chiffrat konnte nicht entschlüsselt werden (falscher Schlüssel, "
"manipulierte Daten oder falscher Verwendungszweck)" "manipulierte Daten oder falscher Verwendungszweck)"
) from exc ) from exc
return plain.decode("utf-8") return plain.decode("utf-8")
@@ -0,0 +1,242 @@
"""Alte, deutsche Adressen auf die neuen englischen umlenken.
Mit 2.5.0 folgen die Adressen der Navigation und sind englisch:
``/monitoring/host-keys`` statt ``/hostschluessel``. Das ist eine gute
Aenderung und eine unangenehme zugleich -- Lesezeichen, offene Browsertabs und
ein Link in einer Mail zeigen weiter auf die alte Adresse. Ein offener Tab ist
dabei der unangenehmste Fall: die Statusuebersicht fragt alle 20 Sekunden ihre
Kacheln ab und bekam nach dem Update jedes Mal einen 404.
Deshalb diese Tabelle. Sie greift **nur, wenn sonst ein 404 herauskaeme** --
also nie vor einer bestehenden Route. Das ist keine Feinheit: auf dem
Lizenzserver ist ``/lizenz/<ticket>`` eine echte, weiterhin gueltige Adresse
(sie steht in verschickten Mails), waehrend dieselbe Wurzel in TESM zur
Lizenzseite fuehrte. Wer zuerst das Routing fragt, muss sich nicht zwischen
beidem entscheiden.
Die Tabelle ist bewusst vollstaendig und nicht nur auf Wurzeln gekuerzt: eine
Umleitung von ``/dateifreigaben/x/vorschau`` auf eine Uebersichtsseite waere
zwar kein 404 mehr, aber auch nicht das, was jemand angeklickt hat.
"""
from __future__ import annotations
from typing import Mapping
#: Alte Wurzel -> (neue Wurzel, Uebersetzung der Segmente darunter).
WURZELN: dict[str, tuple[str, Mapping[str, str]]] = {
"/dateifreigaben": (
"/fileshares",
{
"herunterladen": "download",
"vorschau": "preview",
"hochladen": "upload",
"formular": "form",
"ordner": "folder",
"umbenennen": "rename",
"loeschen": "delete",
"freigeben": "release",
"zuordnungen": "mappings",
},
),
"/hostschluessel": (
"/monitoring/host-keys",
{"pruefen": "probe", "freigeben": "trust", "verwerfen": "forget", "verwaist": "orphaned"},
),
"/zugangsdaten": (
"/monitoring/credentials",
{"neu": "new", "loeschen": "delete", "anzeigen": "reveal"},
),
"/switche": (
"/monitoring/switches",
{
"neu": "new",
"loeschen": "delete",
"hostkey": "host-key",
"pruefen": "probe",
"freigeben": "trust",
"verwerfen": "forget",
"kommando": "command",
"konsole": "console",
},
),
"/clients": (
"/monitoring/clients",
{
"neu": "new",
"aktiv": "active",
"loeschen": "delete",
"neustart": "restart",
"hostkey": "host-key",
"pruefen": "probe",
"freigeben": "trust",
"verwerfen": "forget",
"konsole": "console",
},
),
"/wartung": (
"/maintenance",
{
"paket-nachinstallieren": "install-package",
"auftrag": "job",
"ausgabe": "output",
"aktualisieren": "update",
"neustarten": "restart",
},
),
"/protokolle": (
"/logs",
{
"kea": "dhcp",
"neustarts": "restarts",
"laufend": "live",
"inhalt": "content",
"verlauf": "history",
"datei": "file",
"archiv": "archive",
"archiv-export": "archive-export",
"archiv-loeschen": "archive-delete",
"aenderungen": "audit",
},
),
"/verwaltung": (
"/administration",
{
"benutzer": "users",
"neu": "new",
"loeschen": "delete",
"sitzungen-beenden": "end-sessions",
"verzeichnis-suche": "directory-search",
"verzeichnis-uebernehmen": "directory-import",
"gruppen": "groups",
"vorlage-anwenden": "apply-preset",
"sitzungen": "sessions",
"beenden": "end",
},
),
"/einstellungen": (
"/administration",
{
"zeitzone": "timezone",
"pruefung-jetzt": "check-now",
"netzwerk": "network",
"bestaetigen": "confirm",
"webserver": "web",
"uebernehmen": "apply",
"zertifikat": "certificate",
"selbstsigniert": "self-signed",
"verzeichnisdienst": "directory",
"gruppensuche": "group-search",
"zuordnung": "mapping",
"loeschen": "delete",
"anbieter": "vendor",
"logo-entfernen": "remove-logo",
},
),
"/sicherung": (
"/administration/backup",
{"vorschau": "preview", "uebernehmen": "apply", "verwerfen": "discard"},
),
"/papierkorb": (
"/administration/trash",
{"wiederherstellen": "restore", "endgueltig-loeschen": "purge", "leeren": "empty"},
),
"/diagnose": (
"/administration/diagnostics",
{"aufraeumen": "cleanup", "schluessel-rotieren": "rotate-keys"},
),
"/eigene-lizenz": (
"/own-license",
{
"einspielen": "install",
"aktivieren": "activate",
"deaktivieren": "deactivate",
"offline-antwort": "offline-response",
"signaturschluessel": "signing-key",
},
),
"/lizenzen": (
"/licenses",
{
"aus-ticket": "from-ticket",
"datei": "file",
"neu-signieren": "resign",
"widerrufen": "revoke",
"loeschen": "delete",
"versenden": "send",
"sub-lizenzserver": "sub-servers",
},
),
"/lizenz": (
"/administration/license",
{
"einspielen": "install",
"aktivieren": "activate",
"deaktivieren": "deactivate",
"offline-antwort": "offline-response",
"serverzertifikat": "server-certificate",
"aussteller": "issuer",
"entfernen": "remove",
},
),
"/kunden": ("/customers", {"neu": "new", "loeschen": "delete"}),
"/konto": (
"/account",
{
"stammdaten": "profile",
"profilbild": "avatar",
"sicherheit": "security",
"passwort": "password",
"zwei-faktor": "two-factor",
"starten": "start",
"bestaetigen": "confirm",
"deaktivieren": "disable",
"codes-erneuern": "recovery-codes",
"sitzungen": "sessions",
"beenden": "end",
"alle-beenden": "end-all",
},
),
"/uebersicht": ("/overview", {"kacheln": "tiles"}),
"/gesundheit": ("/health", {}),
"/bestaetigen": ("/confirm", {}),
}
#: Abfrageparameter, die mit umbenannt wurden.
PARAMETER = {
"freigabe": "share",
"pfad": "path",
"aktion": "action",
"benutzer": "actor",
"stufe": "severity",
"seite": "page",
"methode": "method",
"kunde": "customer",
"freischalten": "unlocked",
}
#: Wurzeln, unter denen eine unbekannte Unterseite **nicht** umgelenkt wird.
#: ``/lizenz/<ticket>`` ist auf dem Lizenzserver eine echte Adresse; ein
#: abgelaufenes oder falsches Ticket gehoert dort auf die Fehlerseite und nicht
#: in die Verwaltung.
STRENG = {"/lizenz"}
def neue_adresse(pfad: str) -> str | None:
"""Uebersetzt eine alte Adresse -- oder ``None``, wenn es keine ist."""
for alt, (neu, segmente) in WURZELN.items():
if pfad != alt and not pfad.startswith(alt + "/"):
continue
rest = pfad[len(alt) :]
teile = rest.split("/")
if alt in STRENG:
erstes = next((teil for teil in teile if teil), "")
if erstes and erstes not in segmente:
return None
return neu + "/".join(segmente.get(teil, teil) for teil in teile)
return None
def neue_parameter(args: Mapping[str, str]) -> dict[str, str]:
return {PARAMETER.get(name, name): wert for name, wert in args.items()}
@@ -34,7 +34,7 @@ import urllib.request
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable, Iterable
import tesm_licensing as lic import tesm_licensing as lic
@@ -48,8 +48,27 @@ SETTING_STATUS = "license_activation_status"
SETTING_PENDING = "license_pending_request" SETTING_PENDING = "license_pending_request"
SETTING_LAST_HEARTBEAT = "license_last_heartbeat_at" SETTING_LAST_HEARTBEAT = "license_last_heartbeat_at"
SETTING_HEARTBEAT_ERROR = "license_heartbeat_error" SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
#: Ab wann die Restlaufzeit als *Hinweis* erscheint (gruen). Unterhalb von
#: ``EXPIRY_WARNING_DAYS`` uebernimmt die Warnung (orange), nach Ablauf Rot.
EXPIRY_HINT_DAYS = 90
SETTING_EXPORT_GRACE = "license_export_grace" SETTING_EXPORT_GRACE = "license_export_grace"
#: Module, die der Betreiber vor Ort abgeschaltet hat (Liste von Namen).
#:
#: Die Lizenz sagt, *was erlaubt ist*; sie sagt nicht, was ein Haus auch
#: benutzen will. Wer kein DHCP ueber TESM fahren will, weil ein anderer Server
#: das tut, soll den Punkt nicht wegdruecken muessen -- und vor allem soll
#: niemand versehentlich eine fremde Kea-Konfiguration ueberschreiben koennen.
#: Abschalten ist deshalb mehr als Aufraeumen der Navigation: die Sperre sitzt
#: an derselben Stelle wie die Lizenzpruefung, also vor jeder Route.
#: Bewusst **ohne** das Praefix ``license_``: Sicherungen lassen alles mit
#: diesem Praefix draussen, weil Lizenz- und Aktivierungszustand zur Maschine
#: gehoeren und nicht in ein Backup. Der Schalter ist aber keine Lizenzangabe,
#: sondern eine Entscheidung des Hauses -- die soll eine Wiederherstellung
#: mitbringen.
SETTING_MODULES_OFF = "modules_disabled"
STATUS_INACTIVE = "inactive" STATUS_INACTIVE = "inactive"
STATUS_PENDING = "pending" STATUS_PENDING = "pending"
STATUS_ACTIVE = "active" STATUS_ACTIVE = "active"
@@ -64,10 +83,10 @@ API_PATHS = {
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie. #: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
LICENSED_FEATURES: dict[str, str] = { LICENSED_FEATURES: dict[str, str] = {
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren", "directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
"nginx": "Webserver-Einstellungen aendern", "nginx": "Webserver-Einstellungen ändern",
"export": "Daten exportieren", "export": "Daten exportieren",
"trash_restore": "Papierkorb wiederherstellen und endgueltig loeschen", "trash_restore": "Papierkorb wiederherstellen und endgültig löschen",
"monitor_interval": "Pruefintervall der Ueberwachung aendern", "monitor_interval": "Prüfintervall der Überwachung ändern",
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall", "auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
} }
@@ -81,7 +100,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
""" """
text = str(exc) text = str(exc)
if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text: if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text:
detail = "Das Zertifikat konnte nicht geprueft werden" detail = "Das Zertifikat konnte nicht geprüft werden"
if "self-signed" in text or "self signed" in text: if "self-signed" in text or "self signed" in text:
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat" detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
return ( return (
@@ -90,7 +109,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
"einer anerkannten Stelle." "einer anerkannten Stelle."
) )
if "Name or service not known" in text or "nodename nor servname" in text: if "Name or service not known" in text or "nodename nor servname" in text:
return f"Der Name im Endpunkt ({endpoint}) laesst sich nicht aufloesen." return f"Der Name im Endpunkt ({endpoint}) lässt sich nicht auflösen."
if "Connection refused" in text: if "Connection refused" in text:
return f"{endpoint} nimmt keine Verbindung an." return f"{endpoint} nimmt keine Verbindung an."
if "timed out" in text or "timeout" in text.lower(): if "timed out" in text or "timeout" in text.lower():
@@ -117,6 +136,8 @@ class LicenseState:
last_heartbeat_at: str = "" last_heartbeat_at: str = ""
heartbeat_error: str = "" heartbeat_error: str = ""
pending_action: str = "" pending_action: str = ""
#: Vor Ort abgeschaltet -- unabhaengig davon, was die Lizenz erlaubt.
modules_off: frozenset[str] = frozenset()
@property @property
def role(self) -> str: def role(self) -> str:
@@ -124,8 +145,14 @@ class LicenseState:
@property @property
def modules(self) -> frozenset[str]: def modules(self) -> frozenset[str]:
"""Was die Lizenz freischaltet -- ohne Ruecksicht auf den Schalter."""
return self.status.modules if self.status else frozenset() return self.status.modules if self.status else frozenset()
@property
def modules_in_use(self) -> frozenset[str]:
"""Was tatsaechlich laeuft: lizenziert **und** nicht abgeschaltet."""
return self.modules - self.modules_off
@property @property
def customer_name(self) -> str: def customer_name(self) -> str:
return str((self.status.customer if self.status else {}).get("name") or "") return str((self.status.customer if self.status else {}).get("name") or "")
@@ -175,6 +202,7 @@ class LicenseManager:
"""Liest Lizenzdatei und Status neu ein.""" """Liest Lizenzdatei und Status neu ein."""
with self._lock: with self._lock:
state = LicenseState() state = LicenseState()
state.modules_off = self.abgeschaltete_module(conn)
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "") state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "") state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
pending = self._pending(conn) pending = self._pending(conn)
@@ -235,9 +263,51 @@ class LicenseManager:
) )
def module_active(self, name: str) -> bool: def module_active(self, name: str) -> bool:
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?""" """Ist ein Vollmodul (dhcp/fileshare/maintenance) in Betrieb?
Zwei Bedingungen, und beide muessen gelten: die Lizenz muss es
freischalten, und der Betreiber darf es nicht abgeschaltet haben. Diese
eine Funktion fragen die Navigation, jede Route und die Hintergrund-
aufgaben -- darum sitzt der Schalter hier und nicht in einer Vorlage.
"""
return (
self.active()
and name in self._state.modules
and name not in self._state.modules_off
)
def module_licensed(self, name: str) -> bool:
"""Schaltet die Lizenz das Modul frei? (Ohne den Schalter vor Ort.)"""
return self.active() and name in self._state.modules return self.active() and name in self._state.modules
def abgeschaltete_module(self, conn: sqlite3.Connection) -> frozenset[str]:
"""Liest die Liste aus den Einstellungen."""
gespeichert = self.settings.get(conn, SETTING_MODULES_OFF) or []
if isinstance(gespeichert, str): # Altbestand: ein einzelner Name
gespeichert = [gespeichert]
return frozenset(str(name) for name in gespeichert)
def module_abschalten(
self, conn: sqlite3.Connection, namen: Iterable[str], *, actor: str = ""
) -> tuple[frozenset[str], frozenset[str]]:
"""Setzt die Liste der abgeschalteten Module neu.
Gibt ``(eingeschaltet, abgeschaltet)`` zurueck -- also nur das, was sich
wirklich geaendert hat; der Aufrufer schreibt daraus seinen
Protokolleintrag.
Der Zustand wird hier nicht neu eingelesen, sondern nur an dieser einen
Stelle nachgezogen: ein voller Neuaufbau wuerde die Lizenzdatei erneut
pruefen, obwohl sich an ihr nichts geaendert hat -- und der Schalter ist
das Einzige, was jetzt anders ist.
"""
vorher = self.abgeschaltete_module(conn)
nachher = frozenset(str(name) for name in namen)
self.settings.set(conn, SETTING_MODULES_OFF, sorted(nachher), actor=actor)
with self._lock:
self._state.modules_off = nachher
return vorher - nachher, nachher - vorher
def feature_allowed(self, feature: str) -> bool: def feature_allowed(self, feature: str) -> bool:
"""Lizenzpflichtige Einzelfunktion. """Lizenzpflichtige Einzelfunktion.
@@ -253,8 +323,8 @@ class LicenseManager:
def require(self, feature: str) -> None: def require(self, feature: str) -> None:
if not self.feature_allowed(feature): if not self.feature_allowed(feature):
raise LicenseError( raise LicenseError(
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gueltige, " f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gültige, "
"aktivierte Lizenz nicht moeglich." "aktivierte Lizenz nicht möglich."
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -280,7 +350,7 @@ class LicenseManager:
try: try:
payload = json.loads(text) payload = json.loads(text)
except ValueError as exc: except ValueError as exc:
raise LicenseError("Die Datei ist kein gueltiges JSON.") from exc raise LicenseError("Die Datei ist kein gültiges JSON.") from exc
try: try:
document, client_key = lic.split_bundle(payload) document, client_key = lic.split_bundle(payload)
@@ -290,20 +360,20 @@ class LicenseManager:
try: try:
lic.verify_license(document) # innere Konsistenz lic.verify_license(document) # innere Konsistenz
except lic.LicensingError as exc: except lic.LicensingError as exc:
raise LicenseError(f"Die Lizenzdatei ist ungueltig: {exc}") from exc raise LicenseError(f"Die Lizenzdatei ist ungültig: {exc}") from exc
master_pubkey = str(document.get("master_pubkey") or "") master_pubkey = str(document.get("master_pubkey") or "")
known = self.trusted_keys(conn) known = self.trusted_keys(conn)
if known and master_pubkey not in set(known) and not allow_new_issuer: if known and master_pubkey not in set(known) and not allow_new_issuer:
raise LicenseError( raise LicenseError(
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt " "Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
f"(Schluessel {lic.key_id(master_pubkey)}). Ein Administrator muss den " f"(Schlüssel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
"Wechsel des Ausstellers ausdruecklich bestaetigen." "Wechsel des Ausstellers ausdrücklich bestätigen."
) )
if not client_key and not self.key_path.is_file(): if not client_key and not self.key_path.is_file():
raise LicenseError( raise LicenseError(
"Der Datei fehlt der Client-Schluessel. Bitte das vollstaendige " "Der Datei fehlt der Client-Schlüssel. Bitte das vollständige "
"Lizenz-Bundle des Lizenzservers verwenden." "Lizenz-Bundle des Lizenzservers verwenden."
) )
@@ -328,7 +398,7 @@ class LicenseManager:
actor=actor, actor=actor,
action="license.issuer_pinned", action="license.issuer_pinned",
target=lic.key_id(master_pubkey), target=lic.key_id(master_pubkey),
detail="Trust on first use" if not known else "Aussteller-Wechsel bestaetigt", detail="Trust on first use" if not known else "Aussteller-Wechsel bestätigt",
severity="notice", severity="notice",
) )
@@ -393,11 +463,11 @@ class LicenseManager:
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
if not self._state.valid: if not self._state.valid:
raise LicenseError("Es ist keine gueltige Lizenz installiert.") raise LicenseError("Es ist keine gültige Lizenz installiert.")
key = self.client_key() key = self.client_key()
if not key: if not key:
raise LicenseError( raise LicenseError(
"Der Client-Schluessel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren." "Der Client-Schlüssel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren."
) )
return lic.build_client_request( return lic.build_client_request(
action, self._state.document, key, hostname=hostname, report=report action, self._state.document, key, hostname=hostname, report=report
@@ -438,8 +508,8 @@ class LicenseManager:
) )
else: else:
reason = ( reason = (
"In dieser Lizenz ist kein Lizenzserver hinterlegt -- sie kann sich nur " "In dieser Lizenz ist kein Lizenzserver hinterlegt – sie kann sich nur "
"ueber den Offline-Weg aktivieren." "über den Offline-Weg aktivieren."
) )
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und # Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
@@ -471,7 +541,7 @@ class LicenseManager:
"code": code, "code": code,
"message": ( "message": (
(reason + " " if reason else "") (reason + " " if reason else "")
+ "Der Offline-Weg steht offen: uebermitteln Sie den angezeigten Code an " + "Der Offline-Weg steht offen: übermitteln Sie den angezeigten Code an "
"Ihren Ansprechpartner und tragen Sie die Antwort hier ein." "Ihren Ansprechpartner und tragen Sie die Antwort hier ein."
), ),
} }
@@ -523,7 +593,7 @@ class LicenseManager:
current_seq=current_seq, current_seq=current_seq,
) )
except lic.LicensingError as exc: except lic.LicensingError as exc:
raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gueltig: {exc}") from exc raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gültig: {exc}") from exc
status = str(response.get("status") or "") status = str(response.get("status") or "")
detail = str(response.get("detail") or "") detail = str(response.get("detail") or "")
@@ -593,17 +663,25 @@ class LicenseManager:
actor=actor, actor=actor,
) )
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor) self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor)
message = "Verbindung zum Lizenzserver bestaetigt." message = "Verbindung zum Lizenzserver bestätigt."
self.settings.set(conn, SETTING_PENDING, "", actor=actor) self.settings.set(conn, SETTING_PENDING, "", actor=actor)
audit.log( # Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
conn, # Ereignis, sondern der Normalzustand. Im Auditlog stand dadurch
actor=actor, # praktisch nur noch er, und die Eintraege, um die es dort geht --
action=f"license.{action}", # Aktivierung, Deaktivierung, Widerruf --, gingen darin unter. Der
target=str(request_payload["license_id"]), # Zeitpunkt des letzten Heartbeats steht ohnehin in den Einstellungen
detail=f"Transport: {transport}", # und auf der Lizenzseite. Ein *fehlgeschlagener* Heartbeat wird
severity="notice", # weiterhin protokolliert (siehe die Fehlerpfade oben).
) if action != "heartbeat":
audit.log(
conn,
actor=actor,
action=f"license.{action}",
target=str(request_payload["license_id"]),
detail=f"Transport: {transport}",
severity="notice",
)
self.reload(conn) self.reload(conn)
return {"ok": True, "transport": transport, "code": "", "message": message} return {"ok": True, "transport": transport, "code": "", "message": message}
@@ -636,7 +714,7 @@ class LicenseManager:
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = "" url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
) -> dict[str, Any]: ) -> dict[str, Any]:
if not url.startswith(("https://", "http://")): if not url.startswith(("https://", "http://")):
raise ValueError("Ungueltiger Endpunkt") raise ValueError("Ungültiger Endpunkt")
data = json.dumps(payload).encode("utf-8") data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
url, url,
@@ -704,6 +782,19 @@ class LicenseManager:
except LicenseError as exc: except LicenseError as exc:
with self.database.transaction(conn): with self.database.transaction(conn):
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, str(exc), actor="system") self.settings.set(conn, SETTING_HEARTBEAT_ERROR, str(exc), actor="system")
# Der *erfolgreiche* Heartbeat wird nicht protokolliert -- er
# ist der Normalzustand und hat das Auditlog zugeschuettet.
# Ein Fehlschlag dagegen ist genau das, was dort stehen soll:
# er kann bedeuten, dass die Lizenz demnaechst in die
# Kulanzzeit laeuft.
audit.log(
conn,
actor="system",
action="license.heartbeat_failed",
target=self._state.license_id or "",
detail=str(exc)[:500],
severity="warning",
)
return {"ok": False, "message": str(exc)} return {"ok": False, "message": str(exc)}
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -723,7 +814,7 @@ class LicenseManager:
} }
if not state.valid: if not state.valid:
return { return {
"label": "Lizenz ungueltig", "label": "Lizenz ungültig",
"css_class": "status-pill--danger", "css_class": "status-pill--danger",
"dot": "1", "dot": "1",
"dot_class": "status-pill__dot--pulse", "dot_class": "status-pill__dot--pulse",
@@ -748,18 +839,29 @@ class LicenseManager:
"dot": "1", "dot": "1",
"dot_class": "status-pill__dot--pulse", "dot_class": "status-pill__dot--pulse",
"title": ( "title": (
"Die Kulanzfrist laeuft noch, Funktionen bleiben vorerst verfuegbar." "Die Kulanzfrist läuft noch, Funktionen bleiben vorerst verfügbar."
if status.grace_active if status.grace_active
else "Lizenzpflichtige Funktionen sind abgeschaltet." else "Lizenzpflichtige Funktionen sind abgeschaltet."
), ),
} }
if status.expiring_soon: if status.expiring_soon:
return { return {
"label": f"Lizenz laeuft in {status.days_left} Tagen ab", "label": f"Lizenz läuft in {status.days_left} Tagen ab",
"css_class": "status-pill--warning", "css_class": "status-pill--warning",
"dot": "1", "dot": "1",
"dot_class": "status-pill__dot--pulse", "dot_class": "status-pill__dot--pulse",
"title": "Bitte rechtzeitig verlaengern.", "title": "Bitte rechtzeitig verlängern.",
}
# Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer
# eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es
# nach einem Problem aussieht.
if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS:
return {
"label": f"Lizenz läuft in {status.days_left} Tagen ab",
"css_class": "",
"dot": "1",
"dot_class": "",
"title": "Nur ein Hinweis – es ist nichts eingeschränkt.",
} }
if state.last_heartbeat_at: if state.last_heartbeat_at:
try: try:
@@ -773,6 +875,6 @@ class LicenseManager:
"css_class": "status-pill--warning", "css_class": "status-pill--warning",
"dot": "1", "dot": "1",
"dot_class": "", "dot_class": "",
"title": "Rein informativ -- es wird nichts abgeschaltet.", "title": "Rein informativ – es wird nichts abgeschaltet.",
} }
return None return None
@@ -94,7 +94,7 @@ def init_app(app: Flask, *, log_dir: Path, debug: bool, json_logs: bool = False)
except OSError: except OSError:
pass pass
except OSError as exc: # pragma: no cover - z. B. fehlende Rechte except OSError as exc: # pragma: no cover - z. B. fehlende Rechte
root.warning("Konnte app.log nicht oeffnen: %s", exc) root.warning("Konnte app.log nicht öffnen: %s", exc)
# Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces. # Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces.
access = logging.getLogger(ACCESS_LOGGER) access = logging.getLogger(ACCESS_LOGGER)
+34 -12
View File
@@ -50,6 +50,12 @@ class Migration:
sql: str = "" sql: str = ""
fn: MigrationFn | None = None fn: MigrationFn | None = None
allow_integrity_error: bool = False allow_integrity_error: bool = False
#: Begruendung, falls der Text einer bereits angewendeten Migration
#: absichtlich geaendert wurde -- etwa weil eine frische Installation
#: mit der neuen Schreibweise starten muss. Ohne Begruendung warnt der
#: Lauf, und das soll er auch: ein stiller Umbau einer angewendeten
#: Migration ist fast immer ein Versehen.
geaendert_weil: str = ""
tags: tuple[str, ...] = field(default=()) tags: tuple[str, ...] = field(default=())
@property @property
@@ -175,17 +181,33 @@ class MigrationRunner:
record = known.get(migration.version) record = known.get(migration.version)
if record is not None: if record is not None:
if record["checksum"] != migration.checksum: if record["checksum"] != migration.checksum:
logger.warning( if migration.geaendert_weil:
"Migration %s/%s (%s) wurde nachtraeglich geaendert " # Mit Begruendung ist es eine Notiz, keine Warnung:
"(Pruefsumme %s != %s). Bereits angewendete Migrationen werden " # manchmal muss der Text einer laengst angewendeten
"nicht erneut ausgefuehrt -- neue Aenderungen brauchen eine neue " # Migration mit, damit eine **frische** Installation
"Versionsnummer.", # denselben Stand erreicht. Wer das Protokoll liest,
component, # soll den Unterschied zu einem Versehen sehen.
migration.version, logger.info(
migration.name, "Migration %s/%s (%s) wurde absichtlich geändert: %s "
record["checksum"], "Auf dieser Installation ist sie längst angewendet und "
migration.checksum, "wird nicht erneut ausgeführt.",
) component,
migration.version,
migration.name,
migration.geaendert_weil,
)
else:
logger.warning(
"Migration %s/%s (%s) wurde nachträglich geändert "
"(Prüfsumme %s != %s). Bereits angewendete Migrationen werden "
"nicht erneut ausgeführt – neue Änderungen brauchen eine neue "
"Versionsnummer.",
component,
migration.version,
migration.name,
record["checksum"],
migration.checksum,
)
continue continue
try: try:
@@ -210,7 +232,7 @@ class MigrationRunner:
raise raise
logger.error( logger.error(
"Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht " "Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht "
"angewendet werden. Der Start laeuft weiter; die Daten muessen " "angewendet werden. Der Start läuft weiter; die Daten müssen "
"manuell bereinigt werden.", "manuell bereinigt werden.",
component, component,
migration.version, migration.version,
+56 -32
View File
@@ -16,10 +16,10 @@ A = Action
USERS = Resource( USERS = Resource(
key="users", key="users",
label="Benutzer", label="Benutzer",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Konten anlegen, sperren, Gruppen zuordnen", hint="Konten anlegen, sperren, Gruppen zuordnen",
action_hints={ action_hints={
A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung aendern.", A.WRITE: "Stammdaten, Sperre und Gruppenzuordnung ändern.",
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).", A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
}, },
) )
@@ -27,35 +27,46 @@ USERS = Resource(
GROUPS = Resource( GROUPS = Resource(
key="groups", key="groups",
label="Gruppen und Rechte", label="Gruppen und Rechte",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Rechtematrix pflegen", hint="Rechtematrix pflegen",
action_hints={ action_hints={
A.EDIT: "Rechte vergeben -- nur Rechte, die man selbst besitzt.", A.WRITE: "Rechte vergeben – nur Rechte, die man selbst besitzt.",
}, },
) )
SETTINGS_SYSTEM = Resource( SETTINGS_SYSTEM = Resource(
key="settings_system", key="settings_system",
label="Systemeinstellungen", label="Systemeinstellungen",
actions=(A.VIEW, A.EDIT, A.EXECUTE), actions=(A.READ, A.WRITE, A.EXECUTE),
hint="Hostname, Zeitzone, Protokollrotation, Navigation", hint="Hostname, Zeitzone, Protokollrotation, Sitzungsdauer",
action_hints={A.EXECUTE: "Systemaktionen ausloesen (Dienste, Netzwerk uebernehmen)."}, action_hints={A.EXECUTE: "Systemaktionen auslösen (Dienste, Netzwerk übernehmen)."},
)
#: Die Reihenfolge des Menues lief unter "Systemaktionen auslösen" mit -- also
#: unter demselben Recht wie das Aendern des Hostnamens. Es ist aber das
#: harmloseste, was man in der Verwaltung tun kann, und das haeufigste.
SETTINGS_NAV = Resource(
key="settings_nav",
parent="settings_system",
label="Navigation",
actions=(A.READ, A.WRITE),
hint="Reihenfolge der Menüpunkte und ihrer Unterpunkte",
) )
SETTINGS_LICENSE = Resource( SETTINGS_LICENSE = Resource(
key="settings_license", key="settings_license",
label="Lizenz", label="Lizenz",
actions=(A.VIEW, A.EDIT), actions=(A.READ, A.WRITE),
hint="Lizenzdatei einspielen, aktivieren, deaktivieren", hint="Lizenzdatei einspielen, aktivieren, deaktivieren",
) )
SETTINGS_DIRECTORY = Resource( SETTINGS_DIRECTORY = Resource(
key="settings_directory", key="settings_directory",
label="Verzeichnisdienst (LDAP/AD)", label="Verzeichnisdienst (LDAP/AD)",
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS), actions=(A.READ, A.WRITE, A.EXECUTE, A.SECRETS),
hint="Anbindung und Gruppen-Zuordnung", hint="Anbindung und Gruppen-Zuordnung",
action_hints={ action_hints={
A.EXECUTE: "Verbindungstest ausfuehren.", A.EXECUTE: "Verbindungstest ausführen.",
A.SECRETS: "Bind-Passwort des Dienstkontos setzen.", A.SECRETS: "Bind-Passwort des Dienstkontos setzen.",
}, },
) )
@@ -63,67 +74,78 @@ SETTINGS_DIRECTORY = Resource(
SETTINGS_WEB = Resource( SETTINGS_WEB = Resource(
key="settings_web", key="settings_web",
label="Webserver und TLS", label="Webserver und TLS",
actions=(A.VIEW, A.EDIT, A.EXECUTE), actions=(A.READ, A.WRITE, A.EXECUTE),
hint="Domain, Zertifikat, HSTS", hint="Domain, Zertifikat, HSTS",
action_hints={A.EXECUTE: "Konfiguration uebernehmen, Zertifikat anfordern."}, action_hints={A.EXECUTE: "Konfiguration übernehmen, Zertifikat anfordern."},
) )
SETTINGS_NETWORK = Resource( SETTINGS_NETWORK = Resource(
key="settings_network", key="settings_network",
label="Netzwerkeinstellungen", label="Netzwerkeinstellungen",
actions=(A.VIEW, A.EDIT), actions=(A.READ, A.WRITE),
hint="IP-Adresse, Gateway, DNS des Hosts", hint="IP-Adresse, Gateway, DNS des Hosts",
) )
BACKUP = Resource( #: Sichern und Einspielen sind zwei Zeilen, weil es zwei Vorgaenge mit sehr
key="backup", #: verschiedenen Folgen sind: eine Sicherung zu erzeugen aendert nichts, eine
label="Sicherung und Uebertragung", #: einzuspielen aendert alles. Sie liefen unter einem Recht, und wer eine
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS), #: Sicherung ziehen durfte, durfte damit auch eine fremde einspielen.
hint="Verschluesselter Export und Import", BACKUP_EXPORT = Resource(
key="backup_export",
label="Sicherung erstellen",
actions=(A.READ, A.EXPORT, A.SECRETS),
hint="Verschlüsselter Export des Datenstands",
action_hints={ action_hints={
A.EXECUTE: "Eine Sicherung einspielen.",
A.EXPORT: "Sicherung erzeugen und herunterladen.", A.EXPORT: "Sicherung erzeugen und herunterladen.",
A.SECRETS: "Zugangsdaten in die Sicherung einschliessen.", A.SECRETS: "Zugangsdaten in die Sicherung einschließen.",
}, },
) )
BACKUP_IMPORT = Resource(
key="backup_import",
label="Sicherung einspielen",
actions=(A.READ, A.EXECUTE),
hint="Prüfen, übernehmen, verwerfen",
action_hints={A.EXECUTE: "Eine geprüfte Sicherung anwenden."},
)
TRASH = Resource( TRASH = Resource(
key="trash", key="trash",
label="Papierkorb", label="Papierkorb",
actions=(A.VIEW, A.EDIT, A.DELETE), actions=(A.READ, A.WRITE, A.DELETE),
hint="Geloeschtes wiederherstellen oder endgueltig entfernen", hint="Gelöschtes wiederherstellen oder endgültig entfernen",
action_hints={ action_hints={
A.EDIT: "Eintraege wiederherstellen.", A.WRITE: "Einträge wiederherstellen.",
A.DELETE: "Eintraege endgueltig entfernen.", A.DELETE: "Einträge endgültig entfernen.",
}, },
) )
DIAGNOSTICS = Resource( DIAGNOSTICS = Resource(
key="diagnostics", key="diagnostics",
label="Diagnose", label="Diagnose",
actions=(A.VIEW,), actions=(A.READ,),
hint="Schemastand, Protokollintegritaet, Schluesselrechte", hint="Schemastand, Protokollintegrität, Schlüsselrechte",
) )
LOGS_LIVE = Resource( LOGS_LIVE = Resource(
key="logs_live", key="logs_live",
label="Laufendes Protokoll", label="Laufendes Protokoll",
actions=(A.VIEW,), actions=(A.READ,),
) )
LOGS_HISTORY = Resource( LOGS_HISTORY = Resource(
key="logs_history", key="logs_history",
label="Protokollverlauf", label="Protokollverlauf",
actions=(A.VIEW, A.DELETE, A.EXPORT), actions=(A.READ, A.DELETE, A.EXPORT),
hint="Rotierte und archivierte Protokolle", hint="Rotierte und archivierte Protokolle",
action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."}, action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."},
) )
LOGS_AUDIT = Resource( LOGS_AUDIT = Resource(
key="logs_audit", key="logs_audit",
label="Aenderungsprotokoll", label="Änderungsprotokoll",
actions=(A.VIEW, A.EXPORT), actions=(A.READ, A.EXPORT),
hint="Wer hat wann was geaendert", hint="Wer hat wann was geändert",
) )
@@ -139,11 +161,13 @@ def administration_area(*extra: Resource) -> Area:
USERS, USERS,
GROUPS, GROUPS,
SETTINGS_SYSTEM, SETTINGS_SYSTEM,
SETTINGS_NAV,
SETTINGS_LICENSE, SETTINGS_LICENSE,
SETTINGS_DIRECTORY, SETTINGS_DIRECTORY,
SETTINGS_WEB, SETTINGS_WEB,
SETTINGS_NETWORK, SETTINGS_NETWORK,
BACKUP, BACKUP_EXPORT,
BACKUP_IMPORT,
TRASH, TRASH,
DIAGNOSTICS, DIAGNOSTICS,
*extra, *extra,
@@ -0,0 +1,171 @@
"""Alte Rechteschluessel auf neue -- an einer Stelle, nicht an dreien.
Mit 2.8.0 heisst das Aktionsvokabular durchgehend wie das, was es tut:
``view`` wurde ``read``, ``edit`` wurde ``write``. Danach steht in der Matrix
"Write" und im Aenderungsprotokoll ``write``, nicht zweierlei.
Drei Stellen brauchen die Zuordnung, und darum steht sie hier und nicht dort:
* die **Migration**, die bestehende Gruppenzeilen umschreibt,
* der **Import**, dem eine Sicherung aus 2.7.x untergeschoben wird -- ohne die
Zuordnung landen dort lauter unbekannte Schluessel, und
:meth:`PermissionTree.sanitize` wirft Unbekanntes absichtlich **still** weg.
Aus einer Sicherung wuerden Gruppen ohne Rechte.
* der **Test**, der prueft, dass kein alter Schluessel mehr im Code steht.
Warum nicht dreimal dieselbe Ersetzung tippen: weil die dritte Kopie beim
naechsten Umbau vergessen wird. Genau so entstand das Muster bei den Adressen
(:mod:`tesm_core.legacy_urls`).
"""
from __future__ import annotations
#: Aktion alt -> neu. Die Ressource bleibt, wie sie heisst.
UMBENANNTE_AKTIONEN: dict[str, str] = {
"view": "read",
"edit": "write",
}
#: Ein altes Recht, das in mehrere neue zerfaellt -- ein Recht je Funktion.
#:
#: Die Aufteilung ist **additiv**: wer das alte Recht hatte, bekommt alle
#: neuen. Niemand verliert etwas; ab jetzt laesst sich jedes Stueck einzeln
#: vergeben. Der umgekehrte Weg waere auch denkbar gewesen -- nur das
#: Harmloseste uebernehmen --, waere aber ein Update, das Leuten Rechte
#: wegnimmt, ohne dass jemand es entschieden hat.
#:
#: Die Schluessel stehen fuer beide Anwendungen in einer Tabelle. Auf einer
#: TESM-Datenbank tut die Zeile fuer ``licenses.write`` nichts, weil sie dort
#: in keiner Gruppe steht -- und umgekehrt. Zwei getrennte Tabellen waeren
#: zwei Stellen, die man pflegen muss.
GETEILTE_RECHTE: dict[str, tuple[str, ...]] = {
# Drei Neustartwege mit drei verschiedenen Folgen.
"devices.execute": (
"device_restart_poe.read",
"device_restart_poe.execute",
"device_restart_ssh.read",
"device_restart_ssh.execute",
"device_restart_rpc.read",
"device_restart_rpc.execute",
),
# Die Switch-Konsole war "Switch ausfuehren".
"switches.execute": ("switch_console.read", "switch_console.execute"),
# Vier Funktionen: aktualisieren, neu starten, Werkzeug installieren --
# und die Client-Konsole, die jetzt bei den Clients steht.
"maintenance.read": (
"servicing_update.read",
"servicing_reboot.read",
"servicing_tool.read",
"device_console.read",
),
"maintenance.execute": (
"servicing_update.read",
"servicing_update.execute",
"servicing_reboot.read",
"servicing_reboot.execute",
"servicing_tool.read",
"servicing_tool.execute",
"device_console.read",
"device_console.execute",
),
# Elf Funktionen an einem Recht.
"dhcp.read": (
"dhcp_subnets.read",
"dhcp_options.read",
"dhcp_reservations.read",
"dhcp_config.read",
"dhcp_service.read",
),
"dhcp.write": (
"dhcp_subnets.read",
"dhcp_subnets.create",
"dhcp_subnets.write",
"dhcp_subnets.delete",
"dhcp_options.read",
"dhcp_options.write",
"dhcp_reservations.read",
"dhcp_reservations.create",
"dhcp_reservations.write",
"dhcp_reservations.delete",
"dhcp_config.read",
"dhcp_config.execute",
),
"dhcp.execute": ("dhcp_service.read", "dhcp_service.execute"),
# Sichern und Einspielen.
"backup.read": ("backup_export.read", "backup_import.read"),
"backup.export": ("backup_export.read", "backup_export.export"),
"backup.secrets": ("backup_export.read", "backup_export.secrets"),
"backup.execute": ("backup_import.read", "backup_import.execute"),
# Lizenzserver: "aendern" hiess widerrufen **oder** erneuern -- zwei
# Eingriffe mit entgegengesetzter Wirkung unter einem Haken.
"licenses.create": ("license_issue.read", "license_issue.execute"),
"licenses.write": (
"license_renew.read",
"license_renew.execute",
"license_revoke.read",
"license_revoke.execute",
),
"licenses.execute": ("license_activation.read", "license_activation.execute"),
}
#: Ein Recht, das **bleibt** und bisher noch etwas anderes mitgab.
#:
#: Der Unterschied zu :data:`GETEILTE_RECHTE` ist keine Formsache: dort ist der
#: alte Schluessel verschwunden und muss ersetzt werden, hier gilt er weiter
#: und bekommt nur Gesellschaft. Beides in eine Tabelle zu werfen hiesse, in
#: der Migration ein Recht zu loeschen, das es noch gibt -- und im Test nicht
#: mehr pruefen zu koennen, welcher Fall gemeint war.
ERWEITERTE_RECHTE: dict[str, tuple[str, ...]] = {
# Die Navigationsreihenfolge lief unter "Systemaktionen ausloesen" mit.
"settings_system.execute": ("settings_nav.read", "settings_nav.write"),
# Einen Host-Schluessel freizugeben hing an den Stammdaten.
"devices.write": ("hostkeys.read", "hostkeys.execute"),
"switches.write": ("hostkeys.read", "hostkeys.execute"),
# Umbenennen war auch "Freigaben zuordnen".
"fileshare.write": (
"fileshare_mappings.read",
"fileshare_mappings.create",
"fileshare_mappings.write",
"fileshare_mappings.delete",
),
# Ausgefuellt ablegen war auch "unterschreiben".
"fileshare_pdf.create": ("fileshare_signature.read", "fileshare_signature.execute"),
}
def neue_schluessel(alt: str) -> tuple[str, ...]:
"""Was aus einem alten Rechteschluessel wird -- eins oder mehrere.
Unbekanntes kommt unveraendert zurueck: ob ein Schluessel gilt, entscheidet
der Rechtebaum, nicht diese Tabelle. Sie benennt nur um.
"""
if alt in GETEILTE_RECHTE:
return GETEILTE_RECHTE[alt]
ressource, punkt, aktion = alt.rpartition(".")
if not punkt:
return (alt,)
neu = UMBENANNTE_AKTIONEN.get(aktion)
selbst = f"{ressource}.{neu}" if neu else alt
# Ein Recht, das bleibt, nimmt mit, was es bisher mitgab -- sonst verliert
# jemand beim Update genau die Funktion, die vorher an diesem Haken hing.
return (selbst, *ERWEITERTE_RECHTE.get(selbst, ()))
def umgeschrieben(rechte: object) -> list[str]:
"""Eine ganze Liste umschreiben, ohne Dubletten und in fester Reihenfolge."""
if not isinstance(rechte, (list, tuple, set, frozenset)):
return []
ergebnis: set[str] = set()
for recht in rechte:
ergebnis.update(neue_schluessel(str(recht)))
return sorted(ergebnis)
__all__ = [
"UMBENANNTE_AKTIONEN",
"GETEILTE_RECHTE",
"ERWEITERTE_RECHTE",
"neue_schluessel",
"umgeschrieben",
]
+147 -32
View File
@@ -1,6 +1,14 @@
"""Rechtemodell: Bereiche, Ressourcen, Aktionen. """Rechtemodell: Bereiche, Ressourcen, Aktionen.
Das Vorgaengermodell kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern") Das Vokabular heisst seit 2.8.0 durchgehend wie das, was es tut: **Read,
Create, Write, Delete, Execute, Export, Secrets**. Bis dahin hiessen zwei
Aktionen anders (``view``, ``edit``), waehrend die Matrix "Bearbeiten" zeigte
und die Kurzfassung "U" -- dieselbe Aktion in drei Schreibweisen. Der
Kill-Switch eines Bereichs heisst entsprechend ``<bereich>.read``.
:mod:`tesm_core.rbac.legacy_permissions` schreibt alte Schluessel um; die
Migration ``core/4`` tut das fuer bestehende Gruppen.
Das Modell vor 2.0 kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes
bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber: bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber:
@@ -15,7 +23,7 @@ Aktionen eine Ressource ueberhaupt kennt, entscheidet die Ressource; die Matrix
rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen. rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen.
Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein
``<bereich>.view``-Recht. Ohne dieses greift kein einziges Recht innerhalb des ``<bereich>.read``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer
Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein
eingestellten Einzelrechte zu verlieren. eingestellten Einzelrechte zu verlieren.
@@ -29,38 +37,68 @@ from typing import Iterable, Mapping, Sequence
class Action(str, Enum): class Action(str, Enum):
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen.""" """Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen.
VIEW = "view" Die Namen sind die englischen, weil sie in jedem Rechtesystem dieselben
sind und keiner Uebersetzung bedarf: wer ``write`` liest, weiss es. Zwei
hiessen bis 2.7.x anders (``view``, ``edit``) -- in der Matrix stand
"Bearbeiten" und im Protokoll ``edit``, also zweierlei fuer dasselbe.
:mod:`tesm_core.rbac.legacy_permissions` schreibt die alten Schluessel um.
"""
READ = "read"
CREATE = "create" CREATE = "create"
EDIT = "edit" WRITE = "write"
DELETE = "delete" DELETE = "delete"
EXECUTE = "execute" EXECUTE = "execute"
EXPORT = "export" EXPORT = "export"
SECRETS = "secrets" SECRETS = "secrets"
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix. #: Kuerzel, englischer Name, deutsche Beschriftung, Erklaerung.
ACTION_META: dict[Action, tuple[str, str, str]] = { #:
Action.VIEW: ("R", "Ansehen", "Datensaetze und Seiten dieses Bereichs lesen."), #: Beides hat seinen Ort: die Matrix fuehrt die Spalte unter ihrem englischen
Action.CREATE: ("C", "Anlegen", "Neue Datensaetze erstellen."), #: Namen (das ist das Vokabular, und es steht so auch im Schluessel), die
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensaetze aendern -- ohne Loeschen."), #: Kleinschrift darunter und jeder Satz in der Oberflaeche bleiben deutsch.
Action.DELETE: ("D", "Loeschen", "Datensaetze loeschen bzw. endgueltig entfernen."), ACTION_META: dict[Action, tuple[str, str, str, str]] = {
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen ausloesen (Neustart, Dienste, Jobs, Import)."), Action.READ: ("R", "Read", "ansehen", "Datensätze und Seiten dieses Bereichs ansehen."),
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."), Action.CREATE: ("C", "Create", "anlegen", "Neue Datensätze anlegen."),
Action.WRITE: (
"W",
"Write",
"ändern",
"Bestehende Datensätze ändern – ohne Anlegen, ohne Löschen.",
),
Action.DELETE: ("D", "Delete", "löschen", "Datensätze löschen bzw. endgültig entfernen."),
Action.EXECUTE: (
"X",
"Execute",
"auslösen",
"Aktionen auslösen (Neustart, Dienste, Aufträge, Import).",
),
Action.EXPORT: (
"E",
"Export",
"mitnehmen",
"Daten aus dem System heraustragen (Datei-Download).",
),
Action.SECRETS: ( Action.SECRETS: (
"S", "S",
"Geheimnisse", "Secrets",
"Passwoerter und Schluessel im Klartext sehen oder mitnehmen.", "Klartext",
"Passwörter und Schlüssel im Klartext sehen oder mitnehmen.",
), ),
} }
#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus). #: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus).
SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT}) SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT})
#: ``view`` ist Voraussetzung fuer alles andere in derselben Zeile. #: ``read`` ist Voraussetzung fuer alles andere in derselben Zeile. Umgekehrt
#: gilt nichts: wer anlegen darf, darf damit nicht aendern, und wer aendern
#: darf, nicht anlegen. Das ist die Trennung, die man in einer
#: Verwaltungsanwendung am haeufigsten braucht.
IMPLIED_BY: dict[Action, tuple[Action, ...]] = { IMPLIED_BY: dict[Action, tuple[Action, ...]] = {
action: (Action.VIEW,) for action in Action if action is not Action.VIEW action: (Action.READ,) for action in Action if action is not Action.READ
} }
@@ -74,6 +112,32 @@ class Resource:
hint: str = "" hint: str = ""
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint). #: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
action_hints: Mapping[Action, str] = field(default_factory=dict) action_hints: Mapping[Action, str] = field(default_factory=dict)
#: Zu welcher Zeile diese gehoert -- rein fuer die Darstellung.
#:
#: Aus einer Zeile "Clients" sind sechs geworden (Stammdaten, drei
#: Neustartwege, Konsole, Host-Schluessel). Untereinander und alle gleich
#: eingerueckt liest man sie als sechs unabhaengige Dinge; eingerueckt
#: unter ihrer Hauptzeile sieht man, dass sie zusammengehoeren. Auf die
#: Rechte hat das keine Wirkung: jede Zeile bleibt einzeln vergebbar, und
#: die Hauptzeile ist **nicht** Voraussetzung fuer ihre Unterzeilen.
parent: str = ""
#: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen,
#: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als
#: stilles Weglassen in der Route: eine Ausnahme von einer
#: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss.
ohne_wiederanmeldung: tuple[Action, ...] = ()
@property
def short_label(self) -> str:
"""Beschriftung ohne den Namen der Hauptzeile.
Unter "Clients" steht "Neustart über PoE" und nicht "Clients: Neustart
über PoE" -- das Wort stand sonst siebenmal untereinander. Ausserhalb
der Matrix (Protokoll, Meldungen) bleibt der vollstaendige Name.
"""
if self.parent and ": " in self.label:
return self.label.split(": ", 1)[1]
return self.label
def permission(self, action: Action) -> str: def permission(self, action: Action) -> str:
return f"{self.key}.{action.value}" return f"{self.key}.{action.value}"
@@ -96,8 +160,9 @@ class Area:
hint: str = "" hint: str = ""
@property @property
def view_key(self) -> str: def read_key(self) -> str:
return f"{self.key}.view" """Der Kill-Switch des Bereichs. Ohne ihn greift keine Zeile darunter."""
return f"{self.key}.read"
def columns(self) -> tuple[Action, ...]: def columns(self) -> tuple[Action, ...]:
"""Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen.""" """Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen."""
@@ -123,45 +188,92 @@ class PermissionTree:
self._parent: dict[str, str] = {} self._parent: dict[str, str] = {}
self._all: list[str] = [] self._all: list[str] = []
self._resources: dict[str, tuple[Area, Resource]] = {} self._resources: dict[str, tuple[Area, Resource]] = {}
self._verwaltung: frozenset[str] | None = None
seen_keys: set[str] = set() seen_keys: set[str] = set()
for area in self.areas: for area in self.areas:
if area.key in seen_keys: if area.key in seen_keys:
raise ValueError(f"Doppelter Bereichsschluessel: {area.key}") raise ValueError(f"Doppelter Bereichsschlüssel: {area.key}")
seen_keys.add(area.key) seen_keys.add(area.key)
self._by_key[area.key] = area self._by_key[area.key] = area
self._all.append(area.view_key) self._all.append(area.read_key)
for resource in area.resources: for resource in area.resources:
if resource.key in seen_keys: if resource.key in seen_keys:
raise ValueError(f"Doppelter Ressourcenschluessel: {resource.key}") raise ValueError(f"Doppelter Ressourcenschlüssel: {resource.key}")
seen_keys.add(resource.key) seen_keys.add(resource.key)
if Action.VIEW not in resource.actions: if Action.READ not in resource.actions:
raise ValueError( raise ValueError(
f"Ressource {resource.key} muss die Aktion 'view' anbieten -- " f"Ressource {resource.key} muss die Aktion 'read' anbieten – "
"sie ist Voraussetzung fuer jede andere Aktion." "sie ist Voraussetzung für jede andere Aktion."
) )
self._resources[resource.key] = (area, resource) self._resources[resource.key] = (area, resource)
for permission in resource.permissions(): for permission in resource.permissions():
self._all.append(permission) self._all.append(permission)
self._parent[permission] = area.view_key self._parent[permission] = area.read_key
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def all_keys(self) -> tuple[str, ...]: def all_keys(self) -> tuple[str, ...]:
return tuple(self._all) return tuple(self._all)
#: Der Bereich, den ein echter Administrator immer ganz hat.
#:
#: Nicht "alles": das Kennzeichen ``is_admin`` gab bisher jede Pruefung
#: frei, auch Geraete, DHCP und Dateifreigaben. Ein Administrator ist aber
#: jemand, der die **Anlage** verwaltet -- Benutzer, Rechte,
#: Einstellungen --, und nicht automatisch jemand, der Maschinen neu
#: startet oder in Freigaben liest. Diese Rechte holt er sich ueber eine
#: Gruppe, und dann steht es im Aenderungsprotokoll.
VERWALTUNGSBEREICH = "administration"
def verwaltungsrechte(self) -> frozenset[str]:
"""Alle Rechte des Verwaltungsbereichs, samt seinem Kill-Switch.
Einmal gerechnet und gemerkt: die Antwort haengt nur am Baum, und der
entsteht beim Import.
"""
if self._verwaltung is None:
bereich = self._by_key.get(self.VERWALTUNGSBEREICH)
if bereich is None:
self._verwaltung = frozenset()
else:
rechte = {bereich.read_key}
for resource in bereich.resources:
rechte.update(resource.permissions())
self._verwaltung = frozenset(rechte)
return self._verwaltung
def area(self, key: str) -> Area | None: def area(self, key: str) -> Area | None:
return self._by_key.get(key) return self._by_key.get(key)
def parent_view_key(self, permission: str) -> str | None: def parent_read_key(self, permission: str) -> str | None:
return self._parent.get(permission) return self._parent.get(permission)
def resource(self, key: str) -> tuple[Area, Resource] | None: def resource(self, key: str) -> tuple[Area, Resource] | None:
return self._resources.get(key) return self._resources.get(key)
def verlangt_wiederanmeldung(self, permission: str) -> bool:
"""Braucht dieses Recht eine frische Passwortbestaetigung?
Die Antwort steht im Modell (``SENSITIVE_ACTIONS``) und nicht in der
Route -- sonst vergisst sie die naechste, die dazukommt.
"""
resource_key, _, action_value = permission.rpartition(".")
entry = self._resources.get(resource_key)
if entry is None:
return False
_, resource = entry
try:
action = Action(action_value)
except ValueError:
return False
if action in resource.ohne_wiederanmeldung:
return False
return action in SENSITIVE_ACTIONS
def label_for(self, permission: str) -> str: def label_for(self, permission: str) -> str:
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs.""" """Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
if permission.endswith(".view") and permission[:-5] in self._by_key: if permission.endswith(".read") and permission[:-5] in self._by_key:
return f"Bereich {self._by_key[permission[:-5]].label}: Zugang" return f"Bereich {self._by_key[permission[:-5]].label}: Zugang"
resource_key, _, action_value = permission.rpartition(".") resource_key, _, action_value = permission.rpartition(".")
entry = self._resources.get(resource_key) entry = self._resources.get(resource_key)
@@ -172,7 +284,10 @@ class PermissionTree:
action = Action(action_value) action = Action(action_value)
except ValueError: except ValueError:
return permission return permission
return f"{resource.label}: {ACTION_META[action][1]}" # Die deutsche Beschriftung, nicht der englische Name: dieser Text
# landet in Saetzen ("Sie koennen kein Recht vergeben ...") und im
# Aenderungsprotokoll, und die sind deutsch.
return f"{resource.label}: {ACTION_META[action][2]}"
def sanitize(self, permissions: Iterable[str]) -> list[str]: def sanitize(self, permissions: Iterable[str]) -> list[str]:
"""Wirft unbekannte Rechte weg und ergaenzt implizierte. """Wirft unbekannte Rechte weg und ergaenzt implizierte.
@@ -180,7 +295,7 @@ class PermissionTree:
Regeln: Regeln:
* Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles * Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles
Weiterschleppen von Altrechten nach einem Refactoring). Weiterschleppen von Altrechten nach einem Refactoring).
* Jede Aktion impliziert ``view`` derselben Zeile. * Jede Aktion impliziert ``read`` derselben Zeile -- aber nur diese.
* Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des * Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des
Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der
Bearbeiter wuerde sich wundern. Bearbeiter wuerde sich wundern.
@@ -200,13 +315,13 @@ class PermissionTree:
result.add(candidate) result.add(candidate)
for area in self.areas: for area in self.areas:
if any(p in result for resource in area.resources for p in resource.permissions()): if any(p in result for resource in area.resources for p in resource.permissions()):
result.add(area.view_key) result.add(area.read_key)
return sorted(result) return sorted(result)
def effective(self, permissions: Iterable[str]) -> set[str]: def effective(self, permissions: Iterable[str]) -> set[str]:
"""Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen.""" """Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen."""
granted = set(permissions) granted = set(permissions)
active = {key for key in granted if key.endswith(".view") and key[:-5] in self._by_key} active = {key for key in granted if key.endswith(".read") and key[:-5] in self._by_key}
return { return {
key key
for key in granted for key in granted
+155 -32
View File
@@ -29,7 +29,7 @@ from typing import Any, Iterable, Sequence
from ..db import Database from ..db import Database
from ..security import passwords from ..security import passwords
from .model import Action, PermissionTree, RolePreset from .model import ACTION_META, Action, PermissionTree, RolePreset
class PermissionDenied(Exception): class PermissionDenied(Exception):
@@ -124,7 +124,20 @@ def group_permissions(conn: sqlite3.Connection, group_id: int) -> list[str]:
def guard_no_amplification( def guard_no_amplification(
actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht" actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht"
) -> None: ) -> None:
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt.""" """Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt.
Ein echter Administrator darf es -- und das ist seit 2.8.0 die bewusst
offene Tuer: sein Kennzeichen gibt ihm den Verwaltungsbereich, die
Funktionen holt er sich ueber eine Gruppe, auch fuer sich selbst. Der
Unterschied zu vorher ist nicht, *ob* er an die Funktionen kommt, sondern
dass es dann eine Zeile im Aenderungsprotokoll gibt und in der Matrix zu
sehen ist, statt unsichtbar im Kennzeichen zu stecken.
Fuer alle anderen bleibt die Regel dicht -- auch fuer die Gruppe
"Administration", die Benutzer und Gruppen pflegt: sie kann keiner Gruppe
ein Recht geben, das sie nicht selbst hat, und sich damit nicht ueber eine
neue Gruppe hochstufen.
"""
if getattr(actor, "is_admin", False): if getattr(actor, "is_admin", False):
return return
missing = sorted(p for p in added if not actor.has(p)) missing = sorted(p for p in added if not actor.has(p))
@@ -132,7 +145,7 @@ def guard_no_amplification(
labels = ", ".join(tree.label_for(p) for p in missing[:6]) labels = ", ".join(tree.label_for(p) for p in missing[:6])
more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)" more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)"
raise PermissionDenied( raise PermissionDenied(
f"Sie koennen kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}." f"Sie können kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
) )
@@ -160,7 +173,7 @@ def create_group(
if existing["deleted_at"]: if existing["deleted_at"]:
raise ValidationError( raise ValidationError(
f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. " f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. "
"Stellen Sie sie wieder her oder loeschen Sie sie endgueltig." "Stellen Sie sie wieder her oder löschen Sie sie endgültig."
) )
raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.") raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.")
@@ -205,7 +218,7 @@ def update_group(
if name is not None and name.strip() != group.name: if name is not None and name.strip() != group.name:
if group.is_system: if group.is_system:
raise PermissionDenied("Systemgruppen koennen nicht umbenannt werden.") raise PermissionDenied("Systemgruppen können nicht umbenannt werden.")
clean = name.strip() clean = name.strip()
if not clean: if not clean:
raise ValidationError("Der Gruppenname darf nicht leer sein.") raise ValidationError("Der Gruppenname darf nicht leer sein.")
@@ -224,13 +237,13 @@ def update_group(
"UPDATE groups SET description=?, updated_at=? WHERE id=?", "UPDATE groups SET description=?, updated_at=? WHERE id=?",
(description.strip(), _now(), group_id), (description.strip(), _now(), group_id),
) )
changes.append("Beschreibung geaendert") changes.append("Beschreibung geändert")
if permissions is not None: if permissions is not None:
if group.is_system and not unlock_system: if group.is_system and not unlock_system:
raise PermissionDenied( raise PermissionDenied(
"Die Rechte dieser Systemgruppe sind gesperrt. " "Die Rechte dieser Systemgruppe sind gesperrt. "
"Sie muessen sie zuerst ausdruecklich freischalten." "Sie müssen sie zuerst ausdrücklich freischalten."
) )
wanted = tree.sanitize(permissions) wanted = tree.sanitize(permissions)
added = sorted(set(wanted) - set(group.permissions)) added = sorted(set(wanted) - set(group.permissions))
@@ -240,7 +253,7 @@ def update_group(
_write_permissions(conn, group_id, wanted) _write_permissions(conn, group_id, wanted)
conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id)) conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id))
if added: if added:
changes.append("Rechte hinzugefuegt: " + ", ".join(tree.label_for(p) for p in added)) changes.append("Rechte hinzugefügt: " + ", ".join(tree.label_for(p) for p in added))
if removed: if removed:
changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed)) changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed))
@@ -252,7 +265,7 @@ def soft_delete_group(conn: sqlite3.Connection, group_id: int) -> None:
if group is None: if group is None:
raise ValidationError("Diese Gruppe existiert nicht.") raise ValidationError("Diese Gruppe existiert nicht.")
if group.is_system: if group.is_system:
raise PermissionDenied("Systemgruppen koennen nicht geloescht werden.") raise PermissionDenied("Systemgruppen können nicht gelöscht werden.")
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id)) conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id))
@@ -315,6 +328,113 @@ def ensure_presets(
return created return created
def unberuehrte_vorlagen_entfernen(
conn: sqlite3.Connection, tree: PermissionTree, presets: Sequence[RolePreset]
) -> list[str]:
"""Legt Vorlagengruppen in den Papierkorb -- aber nur unberuehrte.
Wird gebraucht, wenn eine Vorlage aus der Auslieferung verschwindet. Auf
einer frischen Installation entsteht sie dann gar nicht erst; auf einer
bestehenden steht sie in der Datenbank, und eine Migration, die sie einfach
loescht, nimmt einem Kunden womoeglich eine Gruppe weg, mit der er
arbeitet.
Darum diese fuenf Bedingungen, und alle fuenf muessen zutreffen:
* Die Gruppe stammt **aus der Auslieferung**: ihr ``preset_key`` ist der der
Vorlage. Diese Kennung setzt nur :func:`ensure_presets`; eine selbst
angelegte Gruppe hat sie nicht, auch wenn sie zufaellig denselben Namen
traegt. Ueber den Namen wird bewusst *nicht* gesucht -- sonst raeumte
dieser Schritt jemandem seine eigene Gruppe "Betrieb" weg.
* Sie heisst noch so, wie sie ausgeliefert wurde. Ein anderer Name heisst:
jemand hat sie uebernommen.
* Die Gruppe hat **keine Mitglieder**. Wer Mitglieder hat, wird benutzt.
* Ihre Rechte sind **unveraendert** die der Vorlage. Jede Abweichung heisst:
jemand hat sie angepasst, also ist sie seine, nicht unsere.
* Sie ist keine Systemgruppe.
Geloescht wird weich: die Gruppe liegt im Papierkorb und kommt mit einem
Klick zurueck. Gibt die Namen der einsortierten Gruppen zurueck.
"""
entfernt: list[str] = []
gruppen = list_groups(conn)
for preset in presets:
gruppe = next(
(
g
for g in gruppen
if g.preset_key
and g.preset_key == preset.key
and g.name.lower() == preset.name.lower()
),
None,
)
if gruppe is None or gruppe.is_system or gruppe.member_count:
continue
if set(gruppe.permissions) != set(tree.expand_preset(preset)):
continue
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), gruppe.id))
entfernt.append(gruppe.name)
return entfernt
def funktionsgruppe_anlegen(
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
) -> int:
"""Legt die Vollzugriffsgruppe an, falls sie fehlt, und gibt ihre Nummer.
Getrennt von :func:`funktionsgruppe_fuer_administratoren`, weil das zwei
verschiedene Dinge sind: hier entsteht nur die Gruppe, dort bekommt sie
**jeder** Administrator. Wer ein einzelnes Konto aufnehmen will -- etwa
das, das an der Kommandozeile entsteht --, braucht das erste ohne das
zweite; sonst faengt sich ein Administrator, der die Gruppe bewusst
abgelegt hat, sie beim naechsten Aufruf wieder ein.
"""
zeile = Database.one(
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
)
if zeile is not None:
return int(zeile["id"])
cursor = conn.execute(
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
"created_at, updated_at) VALUES (?,?,0,0,?,?,?)",
(preset.name, preset.description, preset.key, _now(), _now()),
)
gruppe = int(cursor.lastrowid or 0)
_write_permissions(conn, gruppe, tree.expand_preset(preset))
return gruppe
def funktionsgruppe_fuer_administratoren(
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
) -> int:
"""Legt die Vollzugriffsgruppe an und gibt sie allen Administratoren.
Der Uebergang zu 2.8.0: bis dahin gab das Kennzeichen ``is_admin`` jede
Pruefung frei. Danach gibt es den Verwaltungsbereich, und alles andere
kommt ueber Gruppen. Ohne diesen Schritt fehlt einem bestehenden
Administrator nach dem Update die halbe Anwendung -- und er haelt das fuer
einen Fehler, nicht fuer eine Absicht.
Auf einer frischen Installation gibt es beim Migrieren noch kein Konto;
dann entsteht nur die Gruppe, und wer sie will, weist sie sich zu. Genau so
ist die neue Trennung gedacht.
Gibt die Nummer der Gruppe zurueck.
"""
gruppe = funktionsgruppe_anlegen(conn, tree, preset)
conn.executemany(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
[
(int(row["id"]), gruppe)
for row in Database.all(
conn, "SELECT id FROM users WHERE is_admin=1 AND deleted_at IS NULL"
)
],
)
return gruppe
def default_group_id(conn: sqlite3.Connection) -> int | None: def default_group_id(conn: sqlite3.Connection) -> int | None:
value = Database.value( value = Database.value(
conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1" conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1"
@@ -410,7 +530,7 @@ def guard_target_account(actor: Any, target: dict[str, Any]) -> None:
"""Ein Administratorkonto darf nur ein echter Administrator anfassen.""" """Ein Administratorkonto darf nur ein echter Administrator anfassen."""
if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False): if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False):
raise PermissionDenied( raise PermissionDenied(
"Administratorkonten koennen nur von Administratoren bearbeitet werden." "Administratorkonten können nur von Administratoren bearbeitet werden."
) )
@@ -420,8 +540,8 @@ def guard_last_admin(conn: sqlite3.Connection, user_id: int, *, action: str) ->
return return
if count_active_admins(conn, exclude_id=user_id) == 0: if count_active_admins(conn, exclude_id=user_id) == 0:
raise PermissionDenied( raise PermissionDenied(
f"{action} nicht moeglich: es muss mindestens ein entsperrtes " f"{action} nicht möglich: es muss mindestens ein entsperrtes "
"Administratorkonto uebrig bleiben." "Administratorkonto übrig bleiben."
) )
@@ -482,12 +602,12 @@ def create_user(
if existing["deleted_at"]: if existing["deleted_at"]:
raise ValidationError( raise ValidationError(
f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto " f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto "
"wieder her oder loeschen Sie es endgueltig." "wieder her oder löschen Sie es endgültig."
) )
raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.") raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.")
if is_admin and not getattr(actor, "is_admin", False): if is_admin and not getattr(actor, "is_admin", False):
raise PermissionDenied("Nur Administratoren koennen weitere Administratoren anlegen.") raise PermissionDenied("Nur Administratoren können weitere Administratoren anlegen.")
if auth_source == "local": if auth_source == "local":
problems = policy.check(password, username=clean) problems = policy.check(password, username=clean)
@@ -552,24 +672,30 @@ def update_user(
# ueberschrieben -- die Bearbeitung waere eine Illusion. # ueberschrieben -- die Bearbeitung waere eine Illusion.
raise ValidationError( raise ValidationError(
"Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung " "Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung "
"aus dem Verzeichnis uebernommen und koennen hier nicht geaendert werden." "aus dem Verzeichnis übernommen und können hier nicht geändert werden."
) )
fields: dict[str, Any] = {} fields: dict[str, Any] = {}
# Mit Werten, nicht nur "geaendert": aus dem Protokoll soll man den
# vorherigen Stand ablesen koennen, ohne eine Sicherung auszupacken.
beschriftungen = {"first_name": "Vorname", "last_name": "Nachname", "email": "E-Mail"}
for column, value in ( for column, value in (
("first_name", first_name), ("first_name", first_name),
("last_name", last_name), ("last_name", last_name),
("email", email), ("email", email),
): ):
if value is not None and value.strip() != str(target.get(column) or ""): alt = str(target.get(column) or "")
if value is not None and value.strip() != alt:
fields[column] = value.strip() fields[column] = value.strip()
changes.append(f"{column} geaendert") changes.append(
f"{beschriftungen[column]}: {alt or '(leer)'} -> {value.strip() or '(leer)'}"
)
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]): if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
if not getattr(actor, "is_admin", False): if not getattr(actor, "is_admin", False):
raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator aendern.") raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator ändern.")
if int(target["id"]) == int(getattr(actor, "id", 0)): if int(target["id"]) == int(getattr(actor, "id", 0)):
raise PermissionDenied("Sie koennen Ihr eigenes Admin-Kennzeichen nicht aendern.") raise PermissionDenied("Sie können Ihr eigenes Admin-Kennzeichen nicht ändern.")
if not is_admin: if not is_admin:
guard_last_admin(conn, user_id, action="Herabstufen") guard_last_admin(conn, user_id, action="Herabstufen")
fields["is_admin"] = int(bool(is_admin)) fields["is_admin"] = int(bool(is_admin))
@@ -577,7 +703,7 @@ def update_user(
if is_locked is not None and bool(is_locked) != bool(target["is_locked"]): if is_locked is not None and bool(is_locked) != bool(target["is_locked"]):
if int(target["id"]) == int(getattr(actor, "id", 0)): if int(target["id"]) == int(getattr(actor, "id", 0)):
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht sperren.") raise PermissionDenied("Sie können Ihr eigenes Konto nicht sperren.")
if is_locked: if is_locked:
guard_last_admin(conn, user_id, action="Sperren") guard_last_admin(conn, user_id, action="Sperren")
fields["is_locked"] = int(bool(is_locked)) fields["is_locked"] = int(bool(is_locked))
@@ -595,7 +721,7 @@ def update_user(
after = {int(gid) for gid in group_ids} after = {int(gid) for gid in group_ids}
if before != after: if before != after:
set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree) set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree)
changes.append("Gruppenzuordnung geaendert") changes.append("Gruppenzuordnung geändert")
return changes return changes
@@ -613,7 +739,7 @@ def set_password(
raise ValidationError("Dieses Konto existiert nicht.") raise ValidationError("Dieses Konto existiert nicht.")
if str(row.get("auth_source")) == "ldap": if str(row.get("auth_source")) == "ldap":
raise ValidationError( raise ValidationError(
"Verzeichniskonten haben hier kein Passwort -- es wird im Verzeichnisdienst gepflegt." "Verzeichniskonten haben hier kein Passwort – es wird im Verzeichnisdienst gepflegt."
) )
problems = policy.check(new_password, username=str(row["username"])) problems = policy.check(new_password, username=str(row["username"]))
if problems: if problems:
@@ -631,7 +757,7 @@ def soft_delete_user(conn: sqlite3.Connection, user_id: int, *, actor: Any) -> s
raise ValidationError("Dieses Konto existiert nicht (mehr).") raise ValidationError("Dieses Konto existiert nicht (mehr).")
guard_target_account(actor, target) guard_target_account(actor, target)
if int(target["id"]) == int(getattr(actor, "id", 0)): if int(target["id"]) == int(getattr(actor, "id", 0)):
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht loeschen.") raise PermissionDenied("Sie können Ihr eigenes Konto nicht löschen.")
guard_last_admin(conn, user_id, action="Loeschen") guard_last_admin(conn, user_id, action="Loeschen")
if str(target.get("auth_source")) == "ldap": if str(target.get("auth_source")) == "ldap":
@@ -677,7 +803,7 @@ def matrix_state(
granted = set(permissions) granted = set(permissions)
state: dict[str, dict[str, bool]] = {} state: dict[str, dict[str, bool]] = {}
for area in tree.areas: for area in tree.areas:
state[area.key] = {"view": area.view_key in granted} state[area.key] = {"read": area.read_key in granted}
for resource in area.resources: for resource in area.resources:
state[resource.key] = { state[resource.key] = {
action.value: resource.permission(action) in granted for action in resource.actions action.value: resource.permission(action) in granted for action in resource.actions
@@ -690,7 +816,7 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
granted = set(permissions) granted = set(permissions)
lines: list[str] = [] lines: list[str] = []
for area in tree.areas: for area in tree.areas:
if area.view_key not in granted: if area.read_key not in granted:
continue continue
parts: list[str] = [] parts: list[str] = []
for resource in area.resources: for resource in area.resources:
@@ -706,12 +832,9 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
return lines return lines
#: Abgeleitet und nicht abgeschrieben: die Buchstaben stehen in ACTION_META.
#: Die zweite Liste hier hatte "U" fuer Bearbeiten, waehrend die Matrix "W"
#: zeigte -- dieselbe Aktion, zwei Kuerzel, je nachdem wo man hinsah.
ACTION_LETTERS: dict[Action, str] = { ACTION_LETTERS: dict[Action, str] = {
Action.VIEW: "R", action: meta[0] for action, meta in ACTION_META.items()
Action.CREATE: "C",
Action.EDIT: "U",
Action.DELETE: "D",
Action.EXECUTE: "X",
Action.EXPORT: "E",
Action.SECRETS: "S",
} }
@@ -112,7 +112,7 @@ def protect() -> None:
if not expected or not provided: if not expected or not provided:
abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.") abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
if not hmac.compare_digest(str(expected), str(provided)): if not hmac.compare_digest(str(expected), str(provided)):
abort(403, description="Das Sicherheitstoken des Formulars ist ungueltig. Bitte Seite neu laden.") abort(403, description="Das Sicherheitstoken des Formulars ist ungültig. Bitte Seite neu laden.")
def exempt(view): def exempt(view):
Loaded 100 of 261 files, more files were not shown because too many files have changed in this diff. Show more