Compare commits

..
9 Commits
Author SHA1 Message Date
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
79 changed files with 1790 additions and 780 deletions
+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
+34 -34
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,7 +16,7 @@ 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.
@@ -31,7 +31,7 @@ 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 |
--- ---
@@ -43,9 +43,9 @@ sudo -u tesm tesm-admin create-admin
``` ```
Danach `https://<host>/`. Ohne `--domain` entsteht ein selbstsigniertes Danach `https://<host>/`. Ohne `--domain` entsteht ein selbstsigniertes
Zertifikat; der Browser warnt einmal, die Verbindung ist verschluesselt. Zertifikat; der Browser warnt einmal, die Verbindung ist verschlüsselt.
Mit oeffentlichem Namen und Let's Encrypt stattdessen: 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 +54,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 +72,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 +83,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 +100,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 +135,20 @@ 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.
+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.0.2"
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 = [
+70 -3
View File
@@ -23,7 +23,7 @@ from .permissions import 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__ = "2.0.2"
APP_KEY = "tesm" APP_KEY = "tesm"
APP_NAME = "TESM" APP_NAME = "TESM"
@@ -181,7 +181,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.view", "monitoring.view")):
intervall = int(extension.settings.get(verbindung, "monitor_interval_seconds") or 300)
faellig = monitor.next_check_at(verbindung, intervall)
if faellig is not None:
eintrag = {
"label": "Naechste Pruefung in",
"countdown_to": faellig.isoformat(),
"countdown_done": "jetzt",
"css_class": "",
"dot": "1",
"dot_class": "",
"title": f"Der Ueberwachungsdienst prueft alle {intervall} Sekunden.",
}
if user.has("settings_system.execute"):
from flask import url_for
eintrag["action"] = url_for("settings.run_check_now")
eintrag["action_title"] = "Alle Clients sofort pruefen"
pills.append(eintrag)
counts = monitor.dashboard_counts(verbindung)
if counts["offline"]: if counts["offline"]:
pills.append( pills.append(
{ {
@@ -275,18 +299,61 @@ def _startup_tasks(app: Flask) -> None:
) )
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 Wartungsauftraege als unklar markiert", orphaned)
if vergessen:
logger.info(
"%s Freigabe-Einbindungen eines vorherigen Prozesses vergessen "
"-- sie bestehen mit dessen Namensraum nicht mehr",
vergessen,
)
if extension.license is not None: 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
from .services import fileshare
def schleife() -> None:
while True:
time.sleep(FILESHARE_SWEEP_SECONDS)
try:
with app.app_context():
geloest = fileshare.sweep_expired(core(app).database)
if geloest:
logger.info("%s abgelaufene Freigabe-Einbindungen geloest", geloest)
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen")
threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start()
# Fuer ``gunicorn tesm:app`` # 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
+10
View File
@@ -298,6 +298,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,6 +313,10 @@ 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} fuer {device['name']} gestartet. Der Verlauf steht unter Wartung.",
@@ -119,6 +119,21 @@ 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",
}, },
) )
@@ -255,6 +270,26 @@ def mapping_add():
return redirect(url_for("fileshare.mappings")) return redirect(url_for("fileshare.mappings"))
@bp.post("/zuordnungen/<int:mapping_id>")
@permission_required("fileshare.edit")
def mapping_update(mapping_id: int):
try:
with transaction() as conn:
service.update_mapping(
conn,
mapping_id,
ad_group_dn=request.form.get("ad_group_dn", ""),
ad_group_name=request.form.get("ad_group_name", ""),
label=request.form.get("label", ""),
unc=request.form.get("unc", ""),
)
_audit("fileshare.mapping_updated", request.form.get("label", ""), severity="notice")
flash("Zuordnung geaendert.", "success")
except service.FileshareError as exc:
flash(str(exc), "danger")
return redirect(url_for("fileshare.mappings"))
@bp.post("/zuordnungen/<int:mapping_id>/loeschen") @bp.post("/zuordnungen/<int:mapping_id>/loeschen")
@permission_required("fileshare.edit") @permission_required("fileshare.edit")
def mapping_delete(mapping_id: int): def mapping_delete(mapping_id: int):
+13 -1
View File
@@ -22,7 +22,7 @@ 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 ..services import inventory, maintenance as service, ssh, windowsops, monitor
bp = Blueprint("maintenance", __name__, url_prefix="/wartung") bp = Blueprint("maintenance", __name__, url_prefix="/wartung")
@@ -246,6 +246,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,6 +258,15 @@ 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
@@ -54,7 +54,6 @@ def overview():
"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_enabled")), terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")),
) )
+1 -1
View File
@@ -140,7 +140,7 @@ NAVIGATION = NavTree(
), ),
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.view", "logs_audit.export"),
+159 -5
View File
@@ -27,6 +27,7 @@ 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
@@ -114,6 +115,73 @@ 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} ueber {kandidat} aufgeloest"
return unc, (
f"Der Name {host} ist auf diesem Server nicht aufloesbar. Bitte den "
"vollqualifizierten Namen oder die IP-Adresse eintragen -- oder unter "
"Netzwerk eine Suchdomaene setzen."
)
def list_mappings(conn: sqlite3.Connection) -> list[dict[str, Any]]: 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"
@@ -139,6 +207,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 ausgewaehlt.")
vorhanden = Database.one(
conn, "SELECT id FROM fileshare_mappings WHERE id=?", (mapping_id,)
)
if vorhanden is None:
raise FileshareError("Diese Zuordnung existiert nicht mehr.")
# Die Eindeutigkeit (ad_group_dn, unc) gilt weiterhin -- nur nicht gegen
# sich selbst.
kollision = Database.one(
conn,
"SELECT id FROM fileshare_mappings WHERE ad_group_dn=? AND unc=? AND id<>?",
(clean_dn, clean_unc, mapping_id),
)
if kollision is not None:
raise FileshareError("Diese Zuordnung existiert bereits.")
conn.execute(
"UPDATE fileshare_mappings SET ad_group_dn=?, ad_group_name=?, label=?, unc=? "
"WHERE id=?",
(clean_dn, (ad_group_name or "").strip(), clean_label, clean_unc, mapping_id),
)
def delete_mapping(conn: sqlite3.Connection, mapping_id: int) -> None: 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,))
@@ -259,12 +373,18 @@ def mount_for_login(
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
@@ -298,6 +418,24 @@ def release_mount(conn: sqlite3.Connection, mount_id: int) -> None:
) )
def forget_all_mounts(conn: sqlite3.Connection) -> int:
"""Vergisst alle Einbindungen -- beim Start des Webprozesses.
Eingebunden wird im Mount-Namensraum dieses Dienstes. Mit dem Dienst
verschwindet der Namensraum und mit ihm jede Einbindung; die Zeilen in der
Datenbank ueberleben aber. Ohne dieses Aufraeumen zeigte die Oberflaeche
nach jedem Neustart Freigaben an, die es nicht mehr gibt -- und ein Klick
darauf fuehrte in ein leeres Verzeichnis.
Bewusst **kein** ``umount``: es gibt nichts zu loesen. Der Aufruf ginge in
einen Namensraum, der nicht mehr existiert.
"""
cursor = conn.execute(
"UPDATE fileshare_mounts SET released_at=? WHERE released_at IS NULL", (_iso(_now()),)
)
return int(cursor.rowcount or 0)
def release_all_for_user(conn: sqlite3.Connection, user_id: int) -> int: def release_all_for_user(conn: sqlite3.Connection, user_id: int) -> int:
count = 0 count = 0
for mount in active_mounts(conn, user_id): for mount in active_mounts(conn, user_id):
@@ -315,10 +453,26 @@ 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. 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 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
+15 -2
View File
@@ -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,6 +575,7 @@ def run_reboot(
actor=actor, actor=actor,
duration_ms=elapsed(), duration_ms=elapsed(),
detail=str(exc), detail=str(exc),
method=method,
) )
return return
@@ -607,6 +617,7 @@ def run_reboot(
actor=actor, actor=actor,
duration_ms=elapsed(), duration_ms=elapsed(),
detail=problem, detail=problem,
method=method,
) )
return return
@@ -646,6 +657,7 @@ def run_reboot(
actor=actor, actor=actor,
duration_ms=elapsed(), duration_ms=elapsed(),
detail="Neustart ueber SSH, Geraet ist zurueckgekehrt.", detail="Neustart ueber SSH, Geraet ist zurueckgekehrt.",
method=method,
) )
return return
time.sleep(REBOOT_POLL_SECONDS) time.sleep(REBOOT_POLL_SECONDS)
@@ -669,7 +681,8 @@ def run_reboot(
f"Neustart ausgeloest, aber {host}:{ssh_port} hat innerhalb von " f"Neustart ausgeloest, 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(
+68 -5
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,7 +106,13 @@ 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 ueberschritten")
@@ -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,6 +397,13 @@ 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 moeglich."
@@ -573,6 +614,28 @@ 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 next_check_at(conn: sqlite3.Connection, interval_seconds: int) -> datetime | None:
"""Wann der naechste Ueberwachungsdurchlauf faellig ist.
Anker ist die letzte tatsaechliche Pruefung, nicht der Prozessstart: nach
einem Neustart des Dienstes soll der Zaehler dort weiterlaufen, wo der
Durchlauf steht, und nicht bei voller Laenge beginnen.
"""
zeile = Database.one(
conn, "SELECT MAX(last_check_at) AS letzte FROM device_status", ()
)
letzte = (zeile or {}).get("letzte")
if not letzte:
return None
try:
stamp = datetime.fromisoformat(str(letzte))
except ValueError:
return None
if stamp.tzinfo is None:
stamp = stamp.replace(tzinfo=timezone.utc)
return stamp + timedelta(seconds=max(1, int(interval_seconds)))
def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]: def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
row = Database.one( row = Database.one(
conn, conn,
+20 -1
View File
@@ -45,7 +45,18 @@ class Method:
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 Geraet, 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",
@@ -82,9 +93,17 @@ 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":
return [DEFINITIONS[POWER_ON]] if hat_poe else []
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:
+148 -37
View File
@@ -52,7 +52,8 @@ 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-Geraet abgewiesen. Bei einem "
"Domaenenkonto die Form DOMAENE\\\\benutzer verwenden.", "Domaenenkonto die Form DOMAENE\\benutzer verwenden -- mit *einem* "
"Backslash.",
), ),
( (
"NT_STATUS_ACCESS_DENIED", "NT_STATUS_ACCESS_DENIED",
@@ -73,6 +74,24 @@ HINTS: tuple[tuple[str, str], ...] = (
"NT_STATUS_HOST_UNREACHABLE", "NT_STATUS_HOST_UNREACHABLE",
"Das Geraet ist nicht erreichbar.", "Das Geraet ist nicht erreichbar.",
), ),
# Diese Meldung liest sich wie ein technisches Detail, hat aber fast immer
# eine einzige Ursache: das Konto ist auf dem Zielgeraet kein Administrator.
# Nachgemessen an einem echten Fall -- die Anmeldung gelang, aber schon
# "net rpc info" und der Dienstmanager antworteten mit ACCESS_DENIED, also
# bekommt ein Standardkonto die winreg-Pipe gar nicht zu sehen.
(
"pipe winreg",
"Das Konto hat auf diesem Geraet keine Administratorrechte. Ein Neustart ueber "
"RPC braucht sie: die Pipe, ueber die er laeuft, ist fuer Standardkonten nicht "
"sichtbar. Ein Konto der lokalen Administratoren hinterlegen -- pruefen laesst "
"sich das mit 'net rpc service status RemoteRegistry'.",
),
(
"NT_STATUS_OBJECT_NAME_NOT_FOUND",
"Der benoetigte RPC-Dienst antwortet auf dem Geraet nicht. Meist fehlen dem "
"Konto die Administratorrechte; sonst ist der Dienst 'Remoteregistrierung' "
"gestoppt.",
),
) )
@@ -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' laesst sich "
"ein Windows-Geraet von hier nicht neu starten.", "ein Windows-Geraet 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 ueberschritten.")
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:
@@ -166,3 +195,85 @@ def reboot(
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 Geraet keine "
"Administratorrechte. Fuer einen Neustart ueber RPC braucht es sie.",
)
return RemoteResult(False, output, hint_for(output) or "Pruefung fehlgeschlagen.")
def reboot(
host: str,
username: str,
password: str,
*,
domain: str = "",
comment: str = "Neustart durch TESM-Wartung",
delay: int = 5,
timeout: int = DEFAULT_TIMEOUT,
) -> RemoteResult:
"""Loest einen Neustart auf einem Windows-Geraet aus."""
ergebnis = _net_with_credentials(
[
"rpc",
"shutdown",
"-r", # neu starten statt herunterfahren
"-f", # laufende Anwendungen nicht blockieren lassen
"-t",
str(max(0, int(delay))),
"-C",
comment,
"-I",
host,
],
username=username,
password=password,
domain=domain,
timeout=timeout,
)
if isinstance(ergebnis, RemoteResult):
return ergebnis
code, output = ergebnis
if code == 0:
return RemoteResult(True, output or "Neustart ausgeloest.")
return RemoteResult(False, output, hint_for(output))
@@ -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>
@@ -92,7 +92,7 @@
<input type="hidden" name="method" value="{{ method.key }}"> <input type="hidden" name="method" value="{{ method.key }}">
<button class="btn btn--sm" type="submit" title="{{ method.hint }}" <button class="btn btn--sm" type="submit" title="{{ method.hint }}"
data-busy-label="Startet ..." data-busy-label="Startet ..."
data-confirm="{{ tile.name }} ueber {{ method.label }} neu starten? {{ method.hint }}" data-confirm="{{ tile.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=14) }} {{ method.label }} {{ icon('power', size=14) }} {{ method.label }}
@@ -118,8 +118,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>
+23 -23
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>
@@ -150,8 +150,8 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
<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>
@@ -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">
@@ -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 %}
@@ -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 %}
@@ -24,7 +24,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 +36,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 +58,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,29 +88,33 @@
<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 }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -118,7 +122,7 @@
</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,12 +130,12 @@
<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.edit') %}
@@ -151,11 +155,11 @@
<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 +167,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>
@@ -184,15 +188,15 @@
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>
@@ -223,7 +227,7 @@
<dt>Zugangsdaten</dt><dd>{{ device.credential_name or 'keine' }}</dd> <dt>Zugangsdaten</dt><dd>{{ device.credential_name or 'keine' }}</dd>
<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 %}
@@ -234,7 +238,7 @@
<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>
+26 -25
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 %}
@@ -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>
+26 -26
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 %}
@@ -27,13 +27,13 @@
<div class="stat"> <div class="stat">
<span class="stat__label">Reservierungen</span> <span class="stat__label">Reservierungen</span>
<span class="stat__value">{{ reservations | length }}</span> <span class="stat__value">{{ reservations | length }}</span>
<span class="stat__hint">automatisch + manuell</span> <span class="stat__hint">automatisch + manüll</span>
</div> </div>
<div class="stat"> <div class="stat">
<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>
@@ -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 %}
@@ -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 ...') }}
@@ -202,8 +202,8 @@
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 %}
@@ -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 == 'manüll' and current_user.has('dhcp.edit') %}
<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() }}">
@@ -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,7 +429,7 @@
<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>
@@ -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', freigabe=token,
pfad=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}"> pfad=(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,7 +79,7 @@
{% 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="pfade" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
{% endif %} {% endif %}
<td> <td>
{{ icon('folder', size=15) }} {{ icon('folder', size=15) }}
@@ -93,16 +93,44 @@
{% 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="pfade" 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, pfad=entry.relative) }}"
data-fill-Name="{{ entry.name }}"
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
{{ icon('file-text', size=15) }} {{ entry.name }}
</button>
{% else %}
<a class="link-icon"
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
{{ icon('file-text', size=15) }} {{ entry.name }}
</a>
{% endif %}
</td>
<td class="numeric fs-sm">{{ entry.size | bytes }}</td> <td class="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, pfad=entry.relative) }}"
data-fill-Name="{{ entry.name }}"
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
{% endif %}
{% if current_user.has('fileshare.edit') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="file-rename"
data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}"
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
{% endif %} {% endif %}
<a class="btn btn--sm btn--ghost" <a class="btn btn--sm btn--ghost"
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}" href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
@@ -119,9 +147,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 %}
@@ -142,7 +170,7 @@
<input type="hidden" name="pfad" value="{{ listing.relative }}"> <input type="hidden" name="pfad" 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>
@@ -165,4 +193,44 @@
</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>
<div class="modal__body modal__body--flush">
<iframe class="file-preview" title="Vorschau" data-fill-target="Src" data-fill-as="src"
src="about:blank" referrerpolicy="same-origin"></iframe>
</div>
<div class="modal__footer">
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
{{ icon('download', size=15) }} Herunterladen
</a>
<button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button>
</div>
{% endcall %}
{% endif %}
{% if listing and current_user.has('fileshare.edit') %}
{% call modal('file-rename', 'Umbenennen', '', 'narrow') %}
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="parent" value="{{ listing.relative }}">
<input type="hidden" name="pfad" data-fill-target="Pfad" value="">
<div class="modal__body">
{{ field('name', 'Neuer Name', required=True, attrs='data-fill-target="Name"') }}
<p class="fs-xs faint">
Umbenannt wird auf der Freigabe selbst -- es gibt dort keinen Papierkorb.
</p>
</div>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--primary" type="submit">Umbenennen</button>
</div>
</form>
{% endcall %}
{% endif %}
{% endblock %} {% 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.edit') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="mapping-edit-{{ entry.id }}"
aria-label="Bearbeiten">{{ icon('pencil', size=15) }}</button>
{% endif %}
<form method="post" action="{{ url_for('fileshare.mapping_delete', mapping_id=entry.id) }}" data-no-guard> <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.edit') %}
{% for entry in mappings %}
{% call modal('mapping-edit-' ~ entry.id, entry.label, 'Zuordnung ändern') %}
<form method="post" action="{{ url_for('fileshare.mapping_update', mapping_id=entry.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">
<div class="form-grid">
{{ field('label', 'Anzeigename', value=entry.label, required=True) }}
{{ field('unc', 'UNC-Pfad', value=entry.unc, required=True,
hint_text='Schrägstriche gehen auch: //dc2022/daten') }}
{{ group_picker('ad_group_name', 'ad_group_dn', 'edit-' ~ entry.id,
name_value=entry.ad_group_name, dn_value=entry.ad_group_dn,
required=True,
label='Verzeichnisgruppe, die diese Freigabe bekommt') }}
</div>
</div>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--primary" type="submit">Speichern</button>
</div>
</form>
{% endcall %}
{% endfor %}
{% endif %}
{% endblock %}
+24 -20
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>
@@ -162,7 +166,7 @@ wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff au
<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>
+32 -28
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 %}
@@ -88,7 +88,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 +118,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 +135,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,10 +145,10 @@
<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>
@@ -161,15 +161,15 @@
<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 +178,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>
@@ -196,7 +196,7 @@
<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 +206,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 +233,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 +243,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 +281,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 +307,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>
@@ -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 manüll, ü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">
@@ -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('maintenance.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>
@@ -92,7 +92,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<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('maintenance.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,13 +108,13 @@ 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 %}
@@ -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,12 +144,12 @@ 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>
@@ -158,7 +158,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{% 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,7 +205,7 @@ 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') }}");
@@ -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;
@@ -2,12 +2,12 @@
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill %} {% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill %}
{% 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,9 +41,9 @@
</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 manülle 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) }}
</div> </div>
@@ -56,17 +56,17 @@
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('terminal', size=17) }} Switch-Konsole</div> <div class="card__title">{{ icon('terminal', size=17) }} Switch-Konsole</div>
<div class="card__sub"> <div class="card__sub">
Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestaetigung, Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestätigung,
vollstaendige Mitschrift vollständige Mitschrift
</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('terminal_enabled', 'Browser-Konsole für Switche zulassen',
checked=values.terminal_enabled) }} checked=values.terminal_enabled) }}
{{ field('terminal_idle_timeout', 'Trennen bei Untaetigkeit (Sekunden)', {{ field('terminal_idle_timeout', 'Trennen bei Untätigkeit (Sekunden)',
value=values.terminal_idle_timeout, type='number') }} 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>
@@ -87,6 +87,9 @@
specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }} specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }}
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep, {{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
type='number') }} type='number') }}
{{ field('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
value=values.session_idle_minutes, type='number',
hint_text=specs['session_idle_minutes'].hint) }}
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)', {{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
value=values.trash_retention_days, type='number', value=values.trash_retention_days, type='number',
hint_text=specs['trash_retention_days'].hint) }} hint_text=specs['trash_retention_days'].hint) }}
@@ -134,7 +137,7 @@
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div> <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 class="card__sub">Gilt für alle Benutzer; jeder sieht weiter nur, was er darf</div>
</div> </div>
</div> </div>
<form method="post" action="{{ url_for('admin.nav_order') }}"> <form method="post" action="{{ url_for('admin.nav_order') }}">
@@ -154,8 +157,8 @@
{% endfor %} {% endfor %}
</ol> </ol>
<p class="fs-xs faint mt-3"> <p class="fs-xs faint mt-3">
Die Reihenfolge folgt der Liste. Zum Umsortieren die Eintraege in der gewuenschten Die Reihenfolge folgt der Liste. Zum Umsortieren die Einträge in der gewünschten
Reihenfolge speichern (Drag-and-drop folgt in einer spaeteren Version). Reihenfolge speichern (Drag-and-drop folgt in einer späteren Version).
</p> </p>
</div> </div>
<div class="card__footer"> <div class="card__footer">
@@ -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 %}
@@ -25,11 +25,11 @@
<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.edit') %}
@@ -45,12 +45,12 @@
<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.edit') %}
@@ -60,8 +60,8 @@
<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 %}
@@ -83,14 +83,14 @@
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>
@@ -99,9 +99,9 @@
<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>
+12 -34
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>
@@ -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>
+7 -12
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
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,7 +34,6 @@ 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)
@@ -100,15 +98,12 @@ class MonitorDaemon:
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)
+16 -1
View File
@@ -112,6 +112,21 @@ MONITOR_UNIT="$NAME-monitor.service"
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_"
# Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst
# verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz
# faellt auf 8080/8443 zurueck, auch wenn sie seit der Installation auf Port 80
# lief -- und ein Reverse Proxy davor zeigt dann ins Leere. Dieselbe Regel wie
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
# nicht ausdruecklich neu gesetzt wird.
ENV_FILE="/etc/tesm/$NAME.env"
if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then
PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)"
fi
if [[ -f "$NGINX_SITE" ]]; then
[[ -z "$HTTP_PORT" ]] && HTTP_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\);.*/\1/p' "$NGINX_SITE" | head -1)"
[[ -z "$HTTPS_PORT" ]] && HTTPS_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\)[[:space:]]\+ssl.*/\1/p' "$NGINX_SITE" | head -1)"
fi
if [[ -z "$PORT" ]]; then if [[ -z "$PORT" ]]; then
PORT=5000 PORT=5000
[[ "$APP" == "tesm-license" ]] && PORT=5001 [[ "$APP" == "tesm-license" ]] && PORT=5001
@@ -134,7 +149,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
+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,
+37 -22
View File
@@ -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)
+41 -8
View File
@@ -12,17 +12,47 @@
# ============================================================================= # =============================================================================
set -euo pipefail set -euo pipefail
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel
# unbrauchbar ("bad interpreter"). Die Pruefung darf dabei nicht selbst
# von einem Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken
# Server fehlt -- endete sie in "command not found", grep bekam nichts zu
# sehen, und das Skript machte weiter, als waere alles in Ordnung.
hat_crlf() {
local cr
cr="$(printf '\r')"
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer Windows-Buildmaschine
# aber entscheidend: dort entfernt grep den Wagenruecklauf beim Lesen und
# findet deshalb nie einen.
LC_ALL=C grep -qU "$cr" "$1"
}
APP="${1:-}" 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-opus" eine
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
# nginx-Site und demselben Port.
shift $(( $# > 2 ? 2 : $# ))
EXTRA=("$@")
# Bezugsquelle. Die Vorgaben zeigen dorthin, wo die Releases tatsaechlich
# liegen -- ein Platzhalter wie "gitea.example.invalid" macht das Skript auf
# jedem Host unbrauchbar, und genau das war hier der Fall.
#
# Das Repository heisst wie die Anwendung; getrennte Repositorien, getrennte
# Releases. Der Anhang heisst "<repo>-<tag>.tar.gz", siehe unten.
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
OWNER="${TESM_RELEASE_OWNER:-alientim}"
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { [[ "$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 opus" >&2
exit 2 exit 2
} }
REPO="${TESM_RELEASE_REPO:-$APP}"
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)" WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
@@ -39,18 +69,21 @@ curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
echo "==> Entpacke ..." echo "==> Entpacke ..."
tar -xzf "$WORK/$ASSET" -C "$WORK" tar -xzf "$WORK/$ASSET" -C "$WORK"
SRC="$(find "$WORK" -maxdepth 2 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)" # Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
SRC="$(find "$WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)"
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; } [[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; }
SRC="$(cd "$SRC" && pwd)" SRC="$(cd "$SRC" && pwd)"
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..." echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
if file "$SRC/deploy/install.sh" | grep -q CRLF; then if hat_crlf "$SRC/deploy/install.sh"; then
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2 echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
echo "'git -c core.autocrlf=false archive'." >&2 echo "'git -c core.autocrlf=false archive'." >&2
exit 1 exit 1
fi fi
read -r -p "Version $TAG fuer $APP installieren? [j/N] " answer read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0 [[ "$answer" =~ ^[jJyY]$ ]] || exit 0
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"}
+103 -65
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,57 @@ 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`
Liegt kein entpacktes Release vor, holt `update.sh` es selbst -- es lädt das
Release-Asset, entpackt es und ruft dessen `install.sh` auf:
```bash
sudo bash /opt/tesm/deploy/update.sh tesm
sudo bash /opt/tesm-license/deploy/update.sh tesm-license
```
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
eine **benannte Instanz** zu aktualisieren:
```bash
sudo bash /opt/tesm-opus/deploy/update.sh tesm latest --instance opus
```
Ohne `--instance` entstünde daneben eine zweite Installation namens `tesm`, mit
eigener Datenbank und eigener nginx-Site. Die Ports einer bestehenden
Installation bleiben erhalten, auch wenn sie von den Vorgaben abweichen: eine
Aktualisierung verschiebt nichts, was nicht ausdrücklich neu gesetzt wird.
Ohne Tag wird `latest` **über die API aufgelöst**, nicht in die URL geschrieben:
Gitea liefert unter einem Tag-Namen sonst ein automatisch erzeugtes
Quell-Archiv mit HTTP 200 statt des echten Anhangs -- und das enthält kein
Release, sondern den Repositoriumsstand.
Die Bezugsquelle ist voreingestellt und lässt sich über die Umgebung umlenken,
etwa auf eine Spiegelung:
| Variable | Vorgabe |
|---|---|
| `TESM_RELEASE_BASE` | `https://gitea.int.eertmoed.net` |
| `TESM_RELEASE_OWNER` | `alientim` |
| `TESM_RELEASE_REPO` | der Anwendungsname (`tesm` bzw. `tesm-license`) |
Der erwartete Anhang heisst `<repo>-<tag>.tar.gz`.
--- ---
## 4. HTTPS ## 4. HTTPS
@@ -114,7 +152,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 +162,42 @@ 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.
--- ---
## 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 +217,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 +229,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 +252,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`
--- ---
@@ -230,17 +268,17 @@ curl -s http://127.0.0.1:5000/gesundheit
``` ```
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 +288,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 +328,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.
+56 -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,35 @@ 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 |
| 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 +45,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 +69,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 +145,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 +166,11 @@ 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.
* **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 +179,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.
+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.0.2"
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 = [
+1 -1
View File
@@ -8,7 +8,7 @@ Design-System.
from __future__ import annotations from __future__ import annotations
__version__ = "2.0.0" __version__ = "2.0.2"
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
@@ -43,6 +43,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"),
@@ -56,6 +61,18 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
minimum=1, minimum=1,
maximum=3650, maximum=3650,
), ),
SettingSpec(
"session_idle_minutes",
30,
"int",
"Automatische Abmeldung nach Leerlauf (Minuten)",
hint="Jede Anfrage setzt den Zaehler zurueck -- gezaehlt wird nur Untaetigkeit. "
"Gilt auch fuer die eingebundenen Dateifreigaben: mit der Sitzung endet die "
"Einbindung.",
section="system",
minimum=5,
maximum=1440,
),
SettingSpec("timezone", "", "str", "Zeitzone des Hosts", section="system"), SettingSpec("timezone", "", "str", "Zeitzone des Hosts", section="system"),
SettingSpec("hostname", "", "str", "Hostname", section="system"), SettingSpec("hostname", "", "str", "Hostname", section="system"),
SettingSpec( SettingSpec(
@@ -117,7 +134,7 @@ def create_base_app(
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die #: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
#: Funktionsliste von TESM waere dort schlicht falsch. #: Funktionsliste von TESM waere dort schlicht falsch.
auth_tagline: str = "", auth_tagline: str = "",
app_version: str = "2.0.0", app_version: str = "2.0.2",
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,
@@ -192,11 +209,25 @@ 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
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,
) )
# -- Schema und Startzustand ------------------------------------------ # # -- Schema und Startzustand ------------------------------------------ #
@@ -264,6 +295,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,
@@ -42,7 +42,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"]
@@ -349,7 +349,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,
@@ -359,7 +359,7 @@ def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]
) )
return tuple(str(entry.entry_dn) for entry in connection.entries) return tuple(str(entry.entry_dn) for entry in connection.entries)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc) logger.warning("Gruppenaufloesung fehlgeschlagen: %s", exc)
return () return ()
+2 -2
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.
@@ -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),
@@ -48,6 +48,10 @@ 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"
STATUS_INACTIVE = "inactive" STATUS_INACTIVE = "inactive"
@@ -596,14 +600,22 @@ class LicenseManager:
message = "Verbindung zum Lizenzserver bestaetigt." message = "Verbindung zum Lizenzserver bestaetigt."
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}
@@ -704,6 +716,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)}
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -761,6 +786,17 @@ class LicenseManager:
"dot_class": "status-pill__dot--pulse", "dot_class": "status-pill__dot--pulse",
"title": "Bitte rechtzeitig verlaengern.", "title": "Bitte rechtzeitig verlaengern.",
} }
# 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 laeuft in {status.days_left} Tagen ab",
"css_class": "",
"dot": "1",
"dot_class": "",
"title": "Nur ein Hinweis -- es ist nichts eingeschraenkt.",
}
if state.last_heartbeat_at: if state.last_heartbeat_at:
try: try:
last = lic.parse_iso(state.last_heartbeat_at) last = lic.parse_iso(state.last_heartbeat_at)
@@ -88,11 +88,24 @@ class SqliteSessionInterface(SessionInterface):
cookie_name: str, cookie_name: str,
idle_minutes: int, idle_minutes: int,
absolute_hours: int, absolute_hours: int,
idle_lookup: Any = None,
) -> None: ) -> None:
self.database = database self.database = database
self.cookie_name = cookie_name self.cookie_name = cookie_name
self.idle_minutes = idle_minutes self.idle_minutes = idle_minutes
self.absolute_hours = absolute_hours self.absolute_hours = absolute_hours
#: Optionale Nachschlagefunktion fuer die Leerlaufgrenze. Damit ist die
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
self._idle_lookup = idle_lookup
def _idle(self, conn: Any) -> int:
if self._idle_lookup is None:
return self.idle_minutes
try:
return int(self._idle_lookup(conn)) or self.idle_minutes
except Exception: # pragma: no cover - Einstellung unlesbar
return self.idle_minutes
# -- Flask-Schnittstelle ------------------------------------------- # # -- Flask-Schnittstelle ------------------------------------------- #
@@ -146,7 +159,7 @@ class SqliteSessionInterface(SessionInterface):
with self.database.transaction(conn): with self.database.transaction(conn):
conn.execute( conn.execute(
"UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?", "UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?",
(_iso(now), _iso(now + timedelta(minutes=self.idle_minutes)), sid), (_iso(now), _iso(now + timedelta(minutes=self._idle(conn))), sid),
) )
return session return session
@@ -190,7 +203,7 @@ class SqliteSessionInterface(SessionInterface):
session.get("user_id"), session.get("user_id"),
_iso(now), _iso(now),
_iso(now), _iso(now),
_iso(now + timedelta(minutes=self.idle_minutes)), _iso(now + timedelta(minutes=self._idle(conn))),
_iso(now + timedelta(hours=self.absolute_hours)), _iso(now + timedelta(hours=self.absolute_hours)),
session.get("_ip", ""), session.get("_ip", ""),
session.get("_ua", ""), session.get("_ua", ""),
@@ -161,6 +161,40 @@
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-ring); box-shadow: 0 0 0 3px var(--accent-ring);
} }
/* Der Knopf im Dateifeld kommt vom Browser und ignoriert jede Aussenfarbe:
im dunklen Thema ein hellgraues Kaestchen mit dunkler Schrift, das weder
zum Feld noch zu den uebrigen Knoepfen passt und kaum zu lesen war.
Deshalb explizit wie ein Sekundaerknopf gestaltet -- und weil vier Felder
betroffen sind (Sicherung, Lizenz, Profilbild, Freigaben), einmal hier. */
.input[type="file"] {
padding: 5px 6px;
display: flex;
align-items: center;
gap: var(--sp-3);
cursor: pointer;
color: var(--text-muted);
}
.input[type="file"]::file-selector-button {
padding: 5px 12px;
margin-right: var(--sp-1);
border: 1px solid var(--border-strong);
border-radius: var(--r-sm);
background: var(--surface-2);
color: var(--text);
font-family: inherit;
font-size: var(--fs-sm);
font-weight: 570;
cursor: pointer;
transition: background var(--dur-fast), border-color var(--dur-fast);
}
.input[type="file"]:hover::file-selector-button {
background: var(--surface-3);
border-color: var(--text-faint);
}
.input[type="file"]:focus-visible::file-selector-button {
border-color: var(--accent);
}
.input[readonly], .input[disabled], .select[disabled], .textarea[disabled] { .input[readonly], .input[disabled], .select[disabled], .textarea[disabled] {
background: var(--surface-inset); background: var(--surface-inset);
color: var(--text-muted); color: var(--text-muted);
@@ -374,6 +408,15 @@
background: var(--danger-soft); background: var(--danger-soft);
color: var(--danger); color: var(--danger);
} }
/* Gefuellt statt getoent: "kritisch" muss sich von "Warnung" unterscheiden,
und beide sind rot. */
.pill--critical {
background: var(--danger);
color: var(--surface-0);
font-weight: 700;
}
.pill--critical::before { background: currentColor; }
.pill--warning, .pill--unknown, .pill--orange { .pill--warning, .pill--unknown, .pill--orange {
background: var(--warning-soft); background: var(--warning-soft);
color: var(--warning); color: var(--warning);
@@ -402,6 +445,22 @@
background: var(--success); background: var(--success);
flex-shrink: 0; flex-shrink: 0;
} }
/* Der Knopf sitzt *in* der Pille: kein Rahmen, keine Flaeche, nur das
Symbol in der Farbe der Pille. Ein Standardknopf wuerde sie sprengen. */
.status-pill__action { display: inline-flex; }
.status-pill__action button {
display: inline-flex;
align-items: center;
padding: 0;
margin-left: 2px;
border: 0;
background: none;
color: inherit;
opacity: 0.65;
cursor: pointer;
}
.status-pill__action button:hover { opacity: 1; }
.status-pill__dot--pulse { animation: pulse-ring 2.4s var(--ease) infinite; } .status-pill__dot--pulse { animation: pulse-ring 2.4s var(--ease) infinite; }
.status-pill--danger { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); color: var(--danger); } .status-pill--danger { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); color: var(--danger); }
.status-pill--danger .status-pill__dot { background: var(--danger); } .status-pill--danger .status-pill__dot { background: var(--danger); }
@@ -621,6 +680,37 @@
} }
.modal__title { font-size: var(--fs-md); font-weight: 640; flex: 1; } .modal__title { font-size: var(--fs-md); font-weight: 640; flex: 1; }
.modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; } .modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; }
/* Eine Datei-Vorschau soll die ganze Flaeche des Dialogs nutzen; der
uebliche Innenabstand des Dialogkoerpers wuerde sie einrahmen. */
.modal__body--flush { padding: 0; max-height: none; }
.modal__caption {
padding: 0 var(--sp-5) var(--sp-2);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--fs-xs);
word-break: break-all;
}
.file-preview {
display: block;
width: 100%;
height: min(70dvh, 760px);
border: 0;
background: var(--surface-0);
}
/* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein
Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere
unruhig. */
.file-name {
background: none;
border: 0;
padding: 0;
color: var(--accent);
font: inherit;
text-align: left;
cursor: pointer;
}
.file-name:hover { text-decoration: underline; }
.modal__body { .modal__body {
padding: var(--sp-5); padding: var(--sp-5);
overflow-y: auto; overflow-y: auto;
@@ -1001,6 +1091,16 @@
.warning-text { color: var(--warning); } .warning-text { color: var(--warning); }
.accent-text { color: var(--accent); } .accent-text { color: var(--accent); }
.mono { font-family: var(--font-mono); font-size: 0.94em; } .mono { font-family: var(--font-mono); font-size: 0.94em; }
/* Ein Inline-SVG ist im Reset display:block (base.css). In einem Knopf
stoert das nicht -- .btn ist inline-flex. In einem einfachen Link reisst
es die Beschriftung in die naechste Zeile. Fuer solche Links deshalb
ausdruecklich eine Zeile. */
.link-icon {
display: inline-flex;
align-items: center;
gap: 6px;
}
.nowrap { white-space: nowrap; } .nowrap { white-space: nowrap; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.break-all { overflow-wrap: anywhere; } .break-all { overflow-wrap: anywhere; }
@@ -28,7 +28,7 @@ function store(key, value) {
if (value === null) localStorage.removeItem(key); if (value === null) localStorage.removeItem(key);
else localStorage.setItem(key, value); else localStorage.setItem(key, value);
} catch { } catch {
/* Privatmodus o. Ä. -- Einstellungen sind dann nur fluechtig. */ /* Privatmodus o. Ae. -- Einstellungen sind dann nur fluechtig. */
} }
} }
@@ -212,13 +212,21 @@ function initDialogs() {
event.preventDefault(); event.preventDefault();
const dialog = document.getElementById(opener.dataset.dialogOpen); const dialog = document.getElementById(opener.dataset.dialogOpen);
if (dialog) { if (dialog) {
// Felder aus data-fill-* des Auslösers in das Formular uebernehmen. // Felder aus data-fill-* des Ausloesers in das Formular uebernehmen.
Object.entries(opener.dataset).forEach(([key, value]) => { Object.entries(opener.dataset).forEach(([key, value]) => {
if (!key.startsWith("fill")) return; if (!key.startsWith("fill")) return;
const name = key.slice(4); const name = key.slice(4);
const target = dialog.querySelector(`[data-fill-target="${name}"]`); const target = dialog.querySelector(`[data-fill-target="${name}"]`);
if (!target) return; if (!target) return;
if ("value" in target) target.value = value; // Wohin der Wert gehoert, haengt am Element: ein Rahmen will die
// Quelle, ein Link das Ziel, ein Feld einen Wert. Ohne diese
// Unterscheidung landete die URL als *Beschriftung* im
// Herunterladen-Knopf -- und weil das href dabei "#" blieb, lud ein
// Klick die Seite selbst als "Herunterladen.htm" herunter.
const als = target.dataset.fillAs;
if (als === "src") target.src = value;
else if (als === "href") target.href = value;
else if ("value" in target) target.value = value;
else target.textContent = value; else target.textContent = value;
}); });
openDialog(dialog); openDialog(dialog);
@@ -563,7 +571,8 @@ function initAutoRefresh() {
// von innerHTML entsteht ein neues Element -- mit scrollTop 0. Genau // von innerHTML entsteht ein neues Element -- mit scrollTop 0. Genau
// deshalb sprang ein laufendes Auftragsprotokoll bei jeder // deshalb sprang ein laufendes Auftragsprotokoll bei jeder
// Aktualisierung an den Anfang zurueck. // Aktualisierung an den Anfang zurueck.
const scrollerOf = (el) => el.querySelector(".log-view, [data-scroll]") || el; const scrollerOf = (el) =>
el.querySelector("[data-tail], .log-view, [data-scroll]") || el;
const before = scrollerOf(region); const before = scrollerOf(region);
const position = before.scrollTop; const position = before.scrollTop;
// "Am Ende" grosszuegig auslegen: wer mitliest, soll mitgezogen // "Am Ende" grosszuegig auslegen: wer mitliest, soll mitgezogen
@@ -814,6 +823,25 @@ function initTileFilters() {
/* -- Start ---------------------------------------------------------------- */ /* -- Start ---------------------------------------------------------------- */
/**
* Protokollansichten stehen am Ende, nicht am Anfang.
*
* Bei einem Protokoll ist die neueste Zeile die interessante, und die steht
* unten. Ohne diesen Anfangssprung blieb die Ansicht oben stehen -- und weil
* die Aktualisierung nur mitzieht, wenn man ohnehin schon am Ende ist, zog
* sie danach nie mit. Es sah aus, als waere der Autoscroll kaputt; in
* Wirklichkeit stand man nur nie an der Stelle, an der er greift.
*
* Ausdruecklich per ``data-tail`` markiert: eine Konfigurationsvorschau
* (nginx, Kea) und der Offline-Code sind auch <pre class="log-view">, sollen
* aber oben beginnen.
*/
function initLogTails(root = document) {
root.querySelectorAll("[data-tail]").forEach((view) => {
view.scrollTop = view.scrollHeight;
});
}
function boot() { function boot() {
initTheme(); initTheme();
initSidebar(); initSidebar();
@@ -830,6 +858,7 @@ function boot() {
initPalette(); initPalette();
initSubmitGuards(); initSubmitGuards();
initTileFilters(); initTileFilters();
initLogTails();
unsavedGuard.init(); unsavedGuard.init();
} }
@@ -166,6 +166,13 @@ class SysOps:
input=stdin, input=stdin,
capture_output=True, capture_output=True,
text=True, text=True,
# Kodierung festnageln. "text=True" allein nimmt die Locale des
# Prozesses -- unter systemd ist die haeufig C/ASCII, und dann
# bricht das Lesen ab, sobald ein Werkzeug einen Umlaut
# ausgibt. Der Fehler entsteht im Leser-Thread von subprocess,
# also weit weg von der Zeile, die ihn ausgeloest hat.
encoding="utf-8",
errors="replace",
timeout=timeout or self.timeout, timeout=timeout or self.timeout,
check=False, check=False,
) )
@@ -197,6 +204,8 @@ class SysOps:
[binary, *command[1:]], [binary, *command[1:]],
capture_output=True, capture_output=True,
text=True, text=True,
encoding="utf-8",
errors="replace",
timeout=timeout, timeout=timeout,
check=False, check=False,
) )
@@ -1,15 +1,15 @@
{# Auswahlfeld fuer eine Verzeichnisgruppe. {# Auswahlfeld für eine Verzeichnisgruppe.
Der DN einer AD-Gruppe ist nichts, was man abtippt: er ist lang, enthaelt Der DN einer AD-Gruppe ist nichts, was man abtippt: er ist lang, enthält
Kommas und Leerzeichen und ein Tippfehler faellt erst bei der naechsten Kommas und Leerzeichen und ein Tippfehler fällt erst bei der nächsten
Anmeldung auf -- als stille Nicht-Zuordnung. Deshalb gesucht statt getippt. Anmeldung auf -- als stille Nicht-Zuordnung. Deshalb gesucht statt getippt.
Das Suchergebnis kommt aus dem Verzeichnis selbst; die Felder werden dabei Das Suchergebnis kommt aus dem Verzeichnis selbst; die Felder werden dabei
beide gefuellt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer beide gefüllt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer
Umgebung ohne Dienstkonto ist die Suche nicht moeglich, und dann ist ein Umgebung ohne Dienstkonto ist die Suche nicht möglich, und dann ist ein
eingefuegter DN besser als gar keine Zuordnung. eingefügter DN besser als gar keine Zuordnung.
Erwartet: name_field, dn_field (Feldnamen), scope (eindeutiges Kuerzel), Erwartet: name_field, dn_field (Feldnamen), scope (eindeutiges Kürzel),
optional name_value / dn_value und required. optional name_value / dn_value und required.
#} #}
{% macro group_picker(name_field, dn_field, scope, name_value='', dn_value='', {% macro group_picker(name_field, dn_field, scope, name_value='', dn_value='',
@@ -1,5 +1,5 @@
{% if lines %} {% if lines %}
<pre class="log-view">{% for line in lines %}{{ line }} <pre class="log-view" data-tail>{% for line in lines %}{{ line }}
{% endfor %}</pre> {% endfor %}</pre>
{% else %} {% else %}
<div class="empty"><div class="empty__title">Keine Zeilen</div> <div class="empty"><div class="empty__title">Keine Zeilen</div>
@@ -20,7 +20,7 @@
<div class="card__body"> <div class="card__body">
{% if current_user.is_directory_account %} {% if current_user.is_directory_account %}
{{ alert('Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder {{ alert('Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder
Anmeldung uebernommen.', 'info') }} Anmeldung übernommen.', 'info') }}
{% endif %} {% endif %}
<div class="form-grid"> <div class="form-grid">
{{ field('first_name', 'Vorname', value=profile.first_name, {{ field('first_name', 'Vorname', value=profile.first_name,
@@ -48,7 +48,7 @@
</div> </div>
<div class="card__body"> <div class="card__body">
{% if current_user.is_admin %} {% if current_user.is_admin %}
{{ alert('Sie sind Administrator und duerfen unabhaengig von Gruppen alles.', 'warning') }} {{ alert('Sie sind Administrator und dürfen unabhängig von Gruppen alles.', 'warning') }}
{% elif permissions %} {% elif permissions %}
<div class="row row--tight"> <div class="row row--tight">
{% for permission in permissions %} {% for permission in permissions %}
@@ -35,26 +35,26 @@
<div class="stack"> <div class="stack">
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Passwort aendern</div></div> <div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Passwort ändern</div></div>
</div> </div>
{% if current_user.is_directory_account %} {% if current_user.is_directory_account %}
<div class="card__body"> <div class="card__body">
{{ alert('Ihr Passwort wird im Verzeichnisdienst gepflegt und dort geaendert.', 'info') }} {{ alert('Ihr Passwort wird im Verzeichnisdienst gepflegt und dort geändert.', 'info') }}
</div> </div>
{% else %} {% else %}
<form method="post" action="{{ url_for('account.change_password') }}"> <form method="post" action="{{ url_for('account.change_password') }}">
<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">
{{ field('current_password', 'Aktuelles Passwort', type='password', required=True, {{ field('current_password', 'Aktülles Passwort', type='password', required=True,
autocomplete='current-password') }} autocomplete='current-password') }}
{{ field('new_password', 'Neues Passwort', type='password', required=True, {{ field('new_password', 'Neues Passwort', type='password', required=True,
autocomplete='new-password', autocomplete='new-password',
hint_text='Mindestens ' ~ policy.min_length ~ ' Zeichen und zwei verschiedene Zeichenarten.') }} hint_text='Mindestens ' ~ policy.min_length ~ ' Zeichen und zwei verschiedene Zeichenarten.') }}
{{ field('repeat_password', 'Neues Passwort wiederholen', type='password', required=True, {{ field('repeat_password', 'Neues Passwort wiederholen', type='password', required=True,
autocomplete='new-password') }} autocomplete='new-password') }}
{{ alert('Nach der Aenderung werden alle anderen Sitzungen beendet.', 'info') }} {{ alert('Nach der Änderung werden alle anderen Sitzungen beendet.', 'info') }}
</div> </div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Passwort aendern</button></div> <div class="card__footer"><button class="btn btn--primary" type="submit">Passwort ändern</button></div>
</form> </form>
{% endif %} {% endif %}
</section> </section>
@@ -70,7 +70,7 @@
</div> </div>
<div class="card__body stack"> <div class="card__body stack">
{% if pending_secret %} {% if pending_secret %}
{{ alert('Richten Sie den Eintrag in Ihrer App ein und bestaetigen Sie mit einem aktuellen Code. Erst dann wird die Anmeldung umgestellt.', 'warning') }} {{ alert('Richten Sie den Eintrag in Ihrer App ein und bestätigen Sie mit einem aktüllen Code. Erst dann wird die Anmeldung umgestellt.', 'warning') }}
<dl class="kv fs-sm"> <dl class="kv fs-sm">
<dt>Geheimnis</dt> <dt>Geheimnis</dt>
<dd><span class="mono" id="totp-secret">{{ pending_secret_grouped }}</span> <dd><span class="mono" id="totp-secret">{{ pending_secret_grouped }}</span>
@@ -87,7 +87,7 @@
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input class="input input--mono" type="text" name="code" required maxlength="6" <input class="input input--mono" type="text" name="code" required maxlength="6"
inputmode="numeric" placeholder="123456" style="max-width:130px;text-align:center"> inputmode="numeric" placeholder="123456" style="max-width:130px;text-align:center">
<button class="btn btn--primary" type="submit">Bestaetigen</button> <button class="btn btn--primary" type="submit">Bestätigen</button>
</form> </form>
{% elif totp_enabled %} {% elif totp_enabled %}
<dl class="kv fs-sm"> <dl class="kv fs-sm">
@@ -100,7 +100,7 @@
<form method="post" action="{{ url_for('account.recovery_codes') }}" data-no-guard> <form method="post" action="{{ url_for('account.recovery_codes') }}" 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" <button class="btn btn--sm" type="submit"
data-confirm="Neue Codes erzeugen? Die bisherigen verlieren ihre Gueltigkeit." data-confirm="Neue Codes erzeugen? Die bisherigen verlieren ihre Gültigkeit."
data-confirm-title="Codes erneuern" data-confirm-ok="Erneuern" data-confirm-style="primary"> data-confirm-title="Codes erneuern" data-confirm-ok="Erneuern" data-confirm-style="primary">
Codes erneuern</button> Codes erneuern</button>
</form> </form>
@@ -114,10 +114,10 @@
</div> </div>
{% else %} {% else %}
{% if require_totp_for_admins and current_user.is_admin %} {% if require_totp_for_admins and current_user.is_admin %}
{{ alert('In dieser Installation ist ein zweiter Faktor fuer Administratoren verpflichtend. Bitte jetzt einrichten.', 'danger') }} {{ alert('In dieser Installation ist ein zweiter Faktor für Administratoren verpflichtend. Bitte jetzt einrichten.', 'danger') }}
{% endif %} {% endif %}
<p class="muted fs-sm"> <p class="muted fs-sm">
Ein zweiter Faktor schuetzt Ihr Konto auch dann, wenn Ihr Passwort bekannt wird. Ein zweiter Faktor schützt Ihr Konto auch dann, wenn Ihr Passwort bekannt wird.
</p> </p>
<form method="post" action="{{ url_for('account.totp_start') }}" data-no-guard> <form method="post" action="{{ url_for('account.totp_start') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -1,6 +1,6 @@
{# ========================================================================== {# ==========================================================================
Grundgeruest aller angemeldeten Seiten. Grundgerüst aller angemeldeten Seiten.
Bloecke: extra_head, page_title, page_sub, breadcrumb, topbar_extra, Blöcke: extra_head, page_title, page_sub, breadcrumb, topbar_extra,
content, dialogs, scripts content, dialogs, scripts
========================================================================== #} ========================================================================== #}
<!doctype html> <!doctype html>
@@ -23,9 +23,9 @@
<aside class="sidebar" aria-label="Hauptnavigation"> <aside class="sidebar" aria-label="Hauptnavigation">
<div class="sidebar__brand"> <div class="sidebar__brand">
{# Die Wortmarke traegt den Namen selbst -- ein zweiter Textblock daneben {# Die Wortmarke trägt den Namen selbst -- ein zweiter Textblock daneben
waere eine Doppelung. Eine einzige Zeichnung, deren Farben aus den wäre eine Doppelung. Eine einzige Zeichnung, deren Farben aus den
Themen-Token kommen: zwei per Media Query versteckte SVG waeren bei Themen-Token kommen: zwei per Media Query versteckte SVG wären bei
"System" und beim Umschalten von Hand falsch. #} "System" und beim Umschalten von Hand falsch. #}
<span class="sidebar__logo" role="img" aria-label="{{ app_name }}"> <span class="sidebar__logo" role="img" aria-label="{{ app_name }}">
<svg viewBox="0 0 312 112" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 312 112" xmlns="http://www.w3.org/2000/svg">
@@ -126,9 +126,25 @@
<div class="topbar__right"> <div class="topbar__right">
{% block topbar_extra %}{% endblock %} {% block topbar_extra %}{% endblock %}
{% for pill in status_pills %} {% for pill in status_pills %}
{# Eine Pille kann mehr als Text sein: ein Countdown (data-countdown-to
wird laufend gezählt) und eine Aktion. Der Knopf steckt bewusst in
der Pille -- er gehört zu der Angabe, die er aktualisiert. #}
<span class="status-pill {{ pill.css_class }}" {% if pill.title %}title="{{ pill.title }}"{% endif %}> <span class="status-pill {{ pill.css_class }}" {% if pill.title %}title="{{ pill.title }}"{% endif %}>
{% if pill.dot %}<span class="status-pill__dot {{ pill.dot_class }}"></span>{% endif %} {% if pill.dot %}<span class="status-pill__dot {{ pill.dot_class }}"></span>{% endif %}
{{ pill.label }} {{ pill.label }}
{% if pill.countdown_to %}
<span class="mono" data-countdown-to="{{ pill.countdown_to }}"
data-countdown-done="{{ pill.countdown_done or 'jetzt' }}"></span>
{% endif %}
{% if pill.suffix %}{{ pill.suffix }}{% endif %}
{% if pill.action %}
<form method="post" action="{{ pill.action }}" data-no-guard class="status-pill__action">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button type="submit" title="{{ pill.action_title or 'Jetzt ausführen' }}"
aria-label="{{ pill.action_title or 'Jetzt ausführen' }}"
data-busy-label="...">{{ icon('refresh', size=13) }}</button>
</form>
{% endif %}
</span> </span>
{% endfor %} {% endfor %}
<button class="btn btn--icon btn--ghost hide-sm" type="button" data-behavior="open-palette" <button class="btn btn--icon btn--ghost hide-sm" type="button" data-behavior="open-palette"
@@ -141,7 +157,7 @@
</main> </main>
<footer class="page-footer"> <footer class="page-footer">
<span>{{ app_name }} {{ app_version }} &middot; &copy; {{ current_year }} WiS GmbH</span> <span>{{ app_name }} {{ app_version }} &middot; &copy; {{ current_year }} {{ copyright_notice }}</span>
<span class="row row--tight"> <span class="row row--tight">
{% if migration_warning %} {% if migration_warning %}
<a class="danger-text" href="{{ url_for('diagnostics.overview') }}">Schemawarnung</a> <a class="danger-text" href="{{ url_for('diagnostics.overview') }}">Schemawarnung</a>
@@ -163,17 +179,17 @@
aria-label="Suchbegriff" autocomplete="off"> aria-label="Suchbegriff" autocomplete="off">
<div class="palette__list"></div> <div class="palette__list"></div>
<div class="palette__hint"> <div class="palette__hint">
<span><kbd>&uarr;</kbd><kbd>&darr;</kbd> waehlen</span> <span><kbd>&uarr;</kbd><kbd>&darr;</kbd> wählen</span>
<span><kbd>Enter</kbd> oeffnen</span> <span><kbd>Enter</kbd> öffnen</span>
<span><kbd>Esc</kbd> schliessen</span> <span><kbd>Esc</kbd> schliessen</span>
</div> </div>
</dialog> </dialog>
<script type="application/json" id="palette-data">{{ palette_entries | tojson }}</script> <script type="application/json" id="palette-data">{{ palette_entries | tojson }}</script>
{# -- Bestaetigungsdialog (ersetzt window.confirm) ------------------------- #} {# -- Bestätigungsdialog (ersetzt window.confirm) ------------------------- #}
<dialog class="modal modal--narrow" id="confirm-dialog" aria-labelledby="confirm-title"> <dialog class="modal modal--narrow" id="confirm-dialog" aria-labelledby="confirm-title">
<div class="modal__header"> <div class="modal__header">
<div class="modal__title" id="confirm-title" data-confirm-title>Bitte bestaetigen</div> <div class="modal__title" id="confirm-title" data-confirm-title>Bitte bestätigen</div>
</div> </div>
<div class="modal__body"><p data-confirm-text></p></div> <div class="modal__body"><p data-confirm-text></p></div>
<div class="modal__footer"> <div class="modal__footer">
@@ -2,7 +2,7 @@
{% from "tesm_core/macros.html" import alert, pill, stat %} {% from "tesm_core/macros.html" import alert, pill, stat %}
{% block page_title %}Diagnose{% endblock %} {% block page_title %}Diagnose{% endblock %}
{% block page_sub %}Schemastand, Protokollintegritaet, Schluessel und Systemzugriff{% endblock %} {% block page_sub %}Schemastand, Protokollintegrität, Schlüssel und Systemzugriff{% endblock %}
{% block content %} {% block content %}
<div class="grid grid--4"> <div class="grid grid--4">
@@ -17,17 +17,17 @@
<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) }} Aenderungsprotokoll {{ icon('shield', size=17) }} Änderungsprotokoll
{% if chain.ok %}{{ pill('unversehrt', 'online') }}{% else %}{{ pill('Kette gebrochen', 'danger') }}{% endif %} {% if chain.ok %}{{ pill('unversehrt', 'online') }}{% else %}{{ pill('Kette gebrochen', 'danger') }}{% endif %}
</div> </div>
<div class="card__sub">{{ chain.checked }} Zeilen geprueft (Hash-Kette)</div> <div class="card__sub">{{ chain.checked }} Zeilen geprüft (Hash-Kette)</div>
</div> </div>
</div> </div>
<div class="card__body"> <div class="card__body">
{% if chain.ok %} {% if chain.ok %}
<p class="muted fs-sm"> <p class="muted fs-sm">
Jede Zeile bindet den Hash ihrer Vorgaengerin ein. Eine nachtraeglich entfernte oder Jede Zeile bindet den Hash ihrer Vorgängerin ein. Eine nachträglich entfernte oder
geaenderte Zeile wuerde hier auffallen. geänderte Zeile würde hier auffallen.
</p> </p>
{% else %} {% else %}
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger', {{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
@@ -40,17 +40,17 @@
<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('key', size=17) }} Schluessel und Dateirechte {{ icon('key', size=17) }} Schlüssel und Dateirechte
{% if key_problems %}{{ pill('Problem', 'danger') }}{% else %}{{ pill('in Ordnung', 'online') }}{% endif %} {% if key_problems %}{{ pill('Problem', 'danger') }}{% else %}{{ pill('in Ordnung', 'online') }}{% endif %}
</div> </div>
<div class="card__sub">Aktiver Datenschluessel: {{ keystore_active }} (vorhanden: {{ keystore_keys | join(', ') }})</div> <div class="card__sub">Aktiver Datenschlüssel: {{ keystore_active }} (vorhanden: {{ keystore_keys | join(', ') }})</div>
</div> </div>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<form method="post" action="{{ url_for('diagnostics.rotate_keys') }}" data-no-guard> <form method="post" action="{{ url_for('diagnostics.rotate_keys') }}" 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" <button class="btn btn--sm" type="submit"
data-confirm="Neuen Datenschluessel anlegen und gespeicherte Geheimnisse neu verschluesseln? Der alte Schluessel bleibt zum Lesen erhalten." data-confirm="Neuen Datenschlüssel anlegen und gespeicherte Geheimnisse neu verschlüsseln? Der alte Schlüssel bleibt zum Lesen erhalten."
data-confirm-title="Schluessel rotieren" data-confirm-ok="Rotieren" data-confirm-style="primary"> data-confirm-title="Schlüssel rotieren" data-confirm-ok="Rotieren" data-confirm-style="primary">
{{ icon('refresh', size=14) }} Rotieren {{ icon('refresh', size=14) }} Rotieren
</button> </button>
</form> </form>
@@ -63,8 +63,8 @@
</ul> </ul>
{% else %} {% else %}
<p class="muted fs-sm"> <p class="muted fs-sm">
Alle Schluesseldateien liegen mit Modus 0600. Feldverschluesselung ist AES-256-GCM mit Alle Schlüsseldateien liegen mit Modus 0600. Feldverschlüsselung ist AES-256-GCM mit
rotierbarem Schluesselbund; jedes Chiffrat ist an seinen Verwendungszweck gebunden. rotierbarem Schlüsselbund; jedes Chiffrat ist an seinen Verwendungszweck gebunden.
</p> </p>
{% endif %} {% endif %}
</div> </div>
@@ -87,7 +87,7 @@
<th class="sortable">Komponente</th> <th class="sortable">Komponente</th>
<th class="sortable numeric">Version</th> <th class="sortable numeric">Version</th>
<th class="sortable">Migration</th> <th class="sortable">Migration</th>
<th>Pruefsumme</th> <th>Prüfsumme</th>
<th class="sortable">Angewendet</th> <th class="sortable">Angewendet</th>
</tr> </tr>
</thead> </thead>
@@ -112,7 +112,7 @@
<div class="flex-1"> <div class="flex-1">
<div class="card__title"> <div class="card__title">
{{ icon('server', size=17) }} Systemzugriff {{ icon('server', size=17) }} Systemzugriff
{% if not sysops_available %}{{ pill('nicht verfuegbar', 'warning') }} {% if not sysops_available %}{{ pill('nicht verfügbar', 'warning') }}
{% elif sysops_selftest and sysops_selftest.ok %}{{ pill('Helfer antwortet', 'online') }} {% elif sysops_selftest and sysops_selftest.ok %}{{ pill('Helfer antwortet', 'online') }}
{% else %}{{ pill('Helfer antwortet nicht', 'danger') }}{% endif %} {% else %}{{ pill('Helfer antwortet nicht', 'danger') }}{% endif %}
</div> </div>
@@ -121,7 +121,7 @@
</div> </div>
<div class="card__body"> <div class="card__body">
<p class="muted fs-sm"> <p class="muted fs-sm">
Der Webprozess laeuft unprivilegiert. Alles, was Root-Rechte braucht, geht durch genau Der Webprozess läuft unprivilegiert. Alles, was Root-Rechte braucht, geht durch genau
ein Helferskript mit Positivliste &ndash; nicht durch die Anwendung selbst. ein Helferskript mit Positivliste &ndash; nicht durch die Anwendung selbst.
</p> </p>
{% if not sysops_available %} {% if not sysops_available %}
@@ -129,10 +129,10 @@
Alles andere funktioniert normal -- das ist der erwartete Zustand in einer Alles andere funktioniert normal -- das ist der erwartete Zustand in einer
Entwicklungsumgebung.', 'info') }} Entwicklungsumgebung.', 'info') }}
{% elif sysops_selftest and not sysops_selftest.ok %} {% elif sysops_selftest and not sysops_selftest.ok %}
{# Geprueft wird ein echter Aufruf, nicht die blosse Existenz der Datei. {# Geprüft wird ein echter Aufruf, nicht die blosse Existenz der Datei.
Genau daran lag es einmal: die Seite meldete "erreichbar", waehrend Genau daran lag es einmal: die Seite meldete "erreichbar", während
jeder Aufruf scheiterte. #} jeder Aufruf scheiterte. #}
{{ alert(sysops_selftest.message or 'Der Helfer laesst sich nicht aufrufen.', {{ alert(sysops_selftest.message or 'Der Helfer lässt sich nicht aufrufen.',
'danger', 'Systemaktionen funktionieren nicht') }} 'danger', 'Systemaktionen funktionieren nicht') }}
{% endif %} {% endif %}
</div> </div>
@@ -144,7 +144,7 @@
{% if current_user.is_admin %} {% if current_user.is_admin %}
<form method="post" action="{{ url_for('diagnostics.housekeeping') }}" data-no-guard> <form method="post" action="{{ url_for('diagnostics.housekeeping') }}" 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) }} Aufraeumen</button> <button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Aufräumen</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>
@@ -167,14 +167,14 @@
{{ icon('key', size=17) }} Lizenzzustand {{ icon('key', size=17) }} Lizenzzustand
{% if license_state.valid and license_state.activated %}{{ pill('aktiv', 'online') }} {% if license_state.valid and license_state.activated %}{{ pill('aktiv', 'online') }}
{% elif license_state.valid %}{{ pill('nicht aktiviert', 'warning') }} {% elif license_state.valid %}{{ pill('nicht aktiviert', 'warning') }}
{% else %}{{ pill('ungueltig', 'danger') }}{% endif %} {% else %}{{ pill('ungültig', 'danger') }}{% endif %}
</div> </div>
</div> </div>
</div> </div>
<div class="card__body"> <div class="card__body">
<dl class="kv fs-sm"> <dl class="kv fs-sm">
<dt>Datei vorhanden</dt><dd>{{ license_state.present | yes_no }}</dd> <dt>Datei vorhanden</dt><dd>{{ license_state.present | yes_no }}</dd>
<dt>Signatur gueltig</dt><dd>{{ license_state.valid | yes_no }}</dd> <dt>Signatur gültig</dt><dd>{{ license_state.valid | yes_no }}</dd>
<dt>Aussteller gepinnt</dt><dd>{{ license_state.issuer_pinned | yes_no }}</dd> <dt>Aussteller gepinnt</dt><dd>{{ license_state.issuer_pinned | yes_no }}</dd>
<dt>Rolle</dt><dd>{{ license_state.role }}</dd> <dt>Rolle</dt><dd>{{ license_state.role }}</dd>
<dt>Module</dt><dd>{{ license_state.modules | sort | join(', ') or '&ndash;' | safe }}</dd> <dt>Module</dt><dd>{{ license_state.modules | sort | join(', ') or '&ndash;' | safe }}</dd>
@@ -7,11 +7,11 @@
<div class="alert__body">{{ description }}</div> <div class="alert__body">{{ description }}</div>
</div> </div>
{% if request_id %} {% if request_id %}
<p class="fs-xs faint center">Vorgangsnummer fuer die Fehlersuche: <span class="mono">{{ request_id }}</span></p> <p class="fs-xs faint center">Vorgangsnummer für die Fehlersuche: <span class="mono">{{ request_id }}</span></p>
{% endif %} {% endif %}
<div class="row row--end"> <div class="row row--end">
<a class="btn" href="{{ url_for('auth.login') if code in (401, 403) and not current_user.is_authenticated else back_url }}"> <a class="btn" href="{{ url_for('auth.login') if code in (401, 403) and not current_user.is_authenticated else back_url }}">
{{ 'Zur Anmeldung' if code in (401, 403) and not current_user.is_authenticated else 'Zurueck' }} {{ 'Zur Anmeldung' if code in (401, 403) and not current_user.is_authenticated else 'Zurück' }}
</a> </a>
</div> </div>
{% endblock %} {% endblock %}
@@ -20,8 +20,8 @@
{{ icon('lock', size=18) }} {{ icon('lock', size=18) }}
<div class="alert__body"> <div class="alert__body">
<div class="alert__title">Systemgruppe</div> <div class="alert__title">Systemgruppe</div>
Name und Rechte sind gesperrt. Anders als im Vorgaenger werden die Rechte Name und Rechte sind gesperrt. Anders als im Vorgänger werden die Rechte
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurueckgesetzt &ndash; <strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt &ndash;
Anpassungen bleiben erhalten. Anpassungen bleiben erhalten.
{% if current_user.has('groups.edit') %} {% if current_user.has('groups.edit') %}
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, freischalten=1) }}"> <a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, freischalten=1) }}">
@@ -49,8 +49,8 @@
{% if group.preset_key and current_user.is_admin %} {% if group.preset_key and current_user.is_admin %}
<button class="btn btn--sm" type="submit" <button class="btn btn--sm" type="submit"
formaction="{{ url_for('admin.group_apply_preset', group_id=group.id) }}" formaction="{{ url_for('admin.group_apply_preset', group_id=group.id) }}"
data-confirm="Rechte dieser Gruppe auf die Vorlage zuruecksetzen? Bestehende Anpassungen gehen verloren." data-confirm="Rechte dieser Gruppe auf die Vorlage zurücksetzen? Bestehende Anpassungen gehen verloren."
data-confirm-title="Vorlage anwenden" data-confirm-ok="Zuruecksetzen" data-confirm-style="primary"> data-confirm-title="Vorlage anwenden" data-confirm-ok="Zurücksetzen" data-confirm-style="primary">
{{ icon('refresh', size=14) }} Vorlage anwenden {{ icon('refresh', size=14) }} Vorlage anwenden
</button> </button>
{% endif %} {% endif %}
@@ -58,7 +58,7 @@
<div class="card__body"> <div class="card__body">
<div class="form-grid"> <div class="form-grid">
{{ field('name', 'Name', value=group.name, required=True, disabled=group.is_system, {{ field('name', 'Name', value=group.name, required=True, disabled=group.is_system,
hint_text='Systemgruppen koennen nicht umbenannt werden.' if group.is_system else '') }} hint_text='Systemgruppen können nicht umbenannt werden.' if group.is_system else '') }}
{{ textarea_field('description', 'Beschreibung', value=group.description, rows=2, {{ textarea_field('description', 'Beschreibung', value=group.description, rows=2,
extra_class='field--full') }} extra_class='field--full') }}
</div> </div>
@@ -2,7 +2,7 @@
{% from "tesm_core/macros.html" import field, textarea_field, pill, modal, alert, permission_matrix, empty_state %} {% from "tesm_core/macros.html" import field, textarea_field, pill, modal, alert, permission_matrix, empty_state %}
{% block page_title %}Gruppen und Rechte{% endblock %} {% block page_title %}Gruppen und Rechte{% endblock %}
{% block page_sub %}{{ groups | length }} Gruppen &middot; Rechte addieren sich ueber alle Gruppen eines Benutzers{% endblock %} {% block page_sub %}{{ groups | length }} Gruppen &middot; Rechte addieren sich über alle Gruppen eines Benutzers{% endblock %}
{% block topbar_extra %} {% block topbar_extra %}
{% if current_user.has('groups.create') %} {% if current_user.has('groups.create') %}
@@ -13,9 +13,9 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{{ alert('Wer selbst ein Recht nicht besitzt, kann es auch nicht vergeben. Diese Regel gilt fuer {{ alert('Wer selbst ein Recht nicht besitzt, kann es auch nicht vergeben. Diese Regel gilt für
alle ausser echten Administratoren und verhindert, dass sich ein Gruppenverwalter ueber eine alle ausser echten Administratoren und verhindert, dass sich ein Gruppenverwalter über eine
neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }} neue Gruppe selbst höherstuft.', 'info', 'Eskalationsschutz') }}
<div class="grid grid--2"> <div class="grid grid--2">
{% for group in groups %} {% for group in groups %}
@@ -37,9 +37,9 @@ neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
<form method="post" action="{{ url_for('admin.group_delete', group_id=group.id) }}" data-no-guard> <form method="post" action="{{ url_for('admin.group_delete', group_id=group.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"
data-confirm="Gruppe {{ group.name }} loeschen? Sie landet im Papierkorb; Mitglieder verlieren die zugehoerigen Rechte." data-confirm="Gruppe {{ group.name }} löschen? Sie landet im Papierkorb; Mitglieder verlieren die zugehörigen Rechte."
data-confirm-title="Gruppe loeschen" data-confirm-ok="Loeschen" data-confirm-title="Gruppe löschen" data-confirm-ok="Löschen"
aria-label="Loeschen">{{ icon('trash', size=15) }}</button> aria-label="Löschen">{{ icon('trash', size=15) }}</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>
@@ -67,7 +67,7 @@ neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
{% block dialogs %} {% block dialogs %}
{% if current_user.has('groups.create') %} {% if current_user.has('groups.create') %}
{% call modal('group-new', 'Gruppe anlegen', 'Rechte koennen anschliessend feiner eingestellt werden', 'wide') %} {% call modal('group-new', 'Gruppe anlegen', 'Rechte können anschliessend feiner eingestellt werden', 'wide') %}
<form method="post" action="{{ url_for('admin.group_create') }}"> <form method="post" action="{{ url_for('admin.group_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"> <div class="modal__body">
@@ -24,6 +24,6 @@
</p> </p>
{% endif %} {% endif %}
{% if public_dashboard_url %} {% if public_dashboard_url %}
<p class="center fs-sm"><a href="{{ public_dashboard_url }}">Zur oeffentlichen Statusuebersicht</a></p> <p class="center fs-sm"><a href="{{ public_dashboard_url }}">Zur öffentlichen Statusübersicht</a></p>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@@ -1,6 +1,6 @@
{% extends "tesm_core/base.html" %} {% extends "tesm_core/base.html" %}
{% block page_title %}Archiv {{ day }}{% endblock %} {% block page_title %}Archiv {{ day }}{% endblock %}
{% block page_sub %}{{ rows | length }} archivierte Aenderungen{% endblock %} {% block page_sub %}{{ rows | length }} archivierte Änderungen{% endblock %}
{% block breadcrumb %} {% block breadcrumb %}
<nav class="breadcrumb"><span><a href="{{ url_for('logs.history') }}">Verlauf</a></span><span>{{ day }}</span></nav> <nav class="breadcrumb"><span><a href="{{ url_for('logs.history') }}">Verlauf</a></span><span>{{ day }}</span></nav>
{% endblock %} {% endblock %}
@@ -1,8 +1,8 @@
{% extends "tesm_core/base.html" %} {% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import pill, empty_state, alert %} {% from "tesm_core/macros.html" import pill, empty_state, alert %}
{% block page_title %}Aenderungsprotokoll{% endblock %} {% block page_title %}Änderungsprotokoll{% endblock %}
{% block page_sub %}{{ total }} Eintraege &middot; Hash-Kette {{ 'unversehrt' if chain.ok else 'gebrochen' }}{% endblock %} {% block page_sub %}{{ total }} Einträge &middot; Hash-Kette {{ 'unversehrt' if chain.ok else 'gebrochen' }}{% endblock %}
{% block topbar_extra %} {% block topbar_extra %}
{% if current_user.has('logs_audit.export') %} {% if current_user.has('logs_audit.export') %}
@@ -13,10 +13,10 @@
{% block content %} {% block content %}
{% if not chain.ok %} {% if not chain.ok %}
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger', {{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
'Das Protokoll wurde veraendert') }} 'Das Protokoll wurde verändert') }}
{% else %} {% else %}
{{ alert('Jeder Eintrag bindet den Hash seines Vorgaengers ein. Eine nachtraeglich entfernte {{ alert('Jeder Eintrag bindet den Hash seines Vorgängers ein. Eine nachträglich entfernte
oder geaenderte Zeile faellt bei der Pruefung auf -- ' ~ chain.checked ~ ' Zeilen geprueft.', oder geänderte Zeile fällt bei der Prüfung auf -- ' ~ chain.checked ~ ' Zeilen geprüft.',
'success') }} 'success') }}
{% endif %} {% endif %}
@@ -41,7 +41,7 @@
<input class="input" type="text" name="benutzer" value="{{ filters.benutzer }}" <input class="input" type="text" name="benutzer" value="{{ filters.benutzer }}"
placeholder="Benutzer" style="width:150px"> placeholder="Benutzer" style="width:150px">
<button class="btn btn--sm" type="submit">Filtern</button> <button class="btn btn--sm" type="submit">Filtern</button>
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zuruecksetzen</a> <a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
</form> </form>
<div class="table-wrap"> <div class="table-wrap">
@@ -66,10 +66,15 @@
<td class="fs-sm">{{ row.target }}</td> <td class="fs-sm">{{ row.target }}</td>
<td class="fs-sm muted break-all">{{ row.detail }}</td> <td class="fs-sm muted break-all">{{ row.detail }}</td>
<td> <td>
{% if row.severity == 'critical' %}{{ pill('kritisch', 'danger') }} {# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
{% elif row.severity == 'warning' %}{{ pill('Warnung', 'warning') }} grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'info') }} ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine
{% else %}{{ pill('Info', '') }}{% endif %} eigene, gefüllte Darstellung, sonst wäre es von "Warnung"
nicht zu unterscheiden. #}
{% if row.severity == 'critical' %}{{ pill('kritisch', 'critical') }}
{% elif row.severity == 'warning' %}{{ pill('Warnung', 'danger') }}
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'warning') }}
{% else %}{{ pill('Info', 'online') }}{% endif %}
</td> </td>
<td class="mono fs-xs faint">{{ row.ip or '&ndash;' | safe }}</td> <td class="mono fs-xs faint">{{ row.ip or '&ndash;' | safe }}</td>
</tr> </tr>
@@ -77,13 +82,13 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% if not rows %}{{ empty_state('Keine Eintraege', 'Zu diesem Filter gibt es nichts.', 'history') }}{% endif %} {% if not rows %}{{ empty_state('Keine Einträge', 'Zu diesem Filter gibt es nichts.', 'history') }}{% endif %}
<div class="card__footer" style="justify-content:space-between"> <div class="card__footer" style="justify-content:space-between">
<span class="fs-sm muted">Seite {{ page }}</span> <span class="fs-sm muted">Seite {{ page }}</span>
<span class="row row--tight"> <span class="row row--tight">
{% if page > 1 %} {% if page > 1 %}
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page-1, **filters) }}">Zurueck</a> <a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page-1, **filters) }}">Zurück</a>
{% endif %} {% endif %}
{% if rows | length == per_page %} {% if rows | length == per_page %}
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page+1, **filters) }}">Weiter</a> <a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page+1, **filters) }}">Weiter</a>
@@ -6,7 +6,7 @@
{% block content %} {% block content %}
{{ alert('Rotiert wird per logrotate mit copytruncate -- keiner der schreibenden Prozesse {{ alert('Rotiert wird per logrotate mit copytruncate -- keiner der schreibenden Prozesse
(Ueberwachung, Web, Kea) kann ein Reopen-Signal verarbeiten.', 'info') }} (Überwachung, Web, Kea) kann ein Reopen-Signal verarbeiten.', 'info') }}
<div class="grid grid--2"> <div class="grid grid--2">
<section class="card"> <section class="card">
@@ -19,7 +19,7 @@
{% if rotated %} {% if rotated %}
<div class="table-wrap"> <div class="table-wrap">
<table class="table table--compact"> <table class="table table--compact">
<thead><tr><th>Datei</th><th class="numeric">Groesse</th><th>Geaendert</th></tr></thead> <thead><tr><th>Datei</th><th class="numeric">Grösse</th><th>Geändert</th></tr></thead>
<tbody> <tbody>
{% for entry in rotated %} {% for entry in rotated %}
<tr> <tr>
@@ -39,7 +39,7 @@
<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('history', size=17) }} Archivierte Aenderungen</div> <div class="card__title">{{ icon('history', size=17) }} Archivierte Änderungen</div>
<div class="card__sub">Aus der Datenbank ausgelagerte Kalendertage</div> <div class="card__sub">Aus der Datenbank ausgelagerte Kalendertage</div>
</div> </div>
{% if archived and current_user.has('logs_history.export') %} {% if archived and current_user.has('logs_history.export') %}
@@ -52,7 +52,7 @@
{% if archived %} {% if archived %}
<div class="table-wrap"> <div class="table-wrap">
<table class="table table--compact"> <table class="table table--compact">
<thead><tr><th>Tag</th><th class="numeric">Groesse</th><th></th></tr></thead> <thead><tr><th>Tag</th><th class="numeric">Grösse</th><th></th></tr></thead>
<tbody> <tbody>
{% for entry in archived %} {% for entry in archived %}
<tr> <tr>
@@ -69,8 +69,8 @@
<form method="post" action="{{ url_for('logs.history_purge') }}" data-no-guard> <form method="post" action="{{ url_for('logs.history_purge') }}" 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="Alle {{ archived | length }} Archivdateien endgueltig vom Server loeschen? Bitte vorher als ZIP sichern." data-confirm="Alle {{ archived | length }} Archivdateien endgültig vom Server löschen? Bitte vorher als ZIP sichern."
data-confirm-title="Archiv loeschen" data-confirm-ok="Loeschen"> data-confirm-title="Archiv löschen" data-confirm-ok="Löschen">
Archiv vom Server entfernen Archiv vom Server entfernen
</button> </button>
</form> </form>
@@ -51,8 +51,22 @@
</label> </label>
{%- endmacro %} {%- endmacro %}
{# Ein nicht angehakter Haken sendet **nichts**. Wer die gesendeten Felder
auswertet, sieht ihn deshalb als "nicht angegeben" -- und liess den alten
Wert stehen: Haken raus, speichern, "keine Änderungen". Diese versteckte
Marke sagt "dieses Feld war im Formular", der Haken selbst sagt "an".
Damit sind alle drei Fälle unterscheidbar: an, aus, nicht Teil des
Formulars (Teilformulare dürfen fremde Schalter nicht löschen).
Kein ``disabled``-Fall: ein deaktivierter Haken darf auch nicht als "aus"
gespeichert werden, also fällt die Marke dann ebenfalls weg. #}
{% macro _present(name, disabled=False) -%}
{% if not disabled %}<input type="hidden" name="{{ name }}__present" value="1">{% endif %}
{%- endmacro %}
{% macro checkbox(name, label, checked=False, value='1', hint_text='', disabled=False) -%} {% macro checkbox(name, label, checked=False, value='1', hint_text='', disabled=False) -%}
<label class="check {% if disabled %}check--disabled{% endif %}"> <label class="check {% if disabled %}check--disabled{% endif %}">
{{ _present(name, disabled) }}
<input type="checkbox" name="{{ name }}" value="{{ value }}" <input type="checkbox" name="{{ name }}" value="{{ value }}"
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}> {% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
<span>{{ label }} {{ hint(hint_text) }}</span> <span>{{ label }} {{ hint(hint_text) }}</span>
@@ -61,6 +75,7 @@
{% macro switch(name, label, checked=False, value='1', disabled=False) -%} {% macro switch(name, label, checked=False, value='1', disabled=False) -%}
<label class="switch"> <label class="switch">
{{ _present(name, disabled) }}
<input type="checkbox" name="{{ name }}" value="{{ value }}" <input type="checkbox" name="{{ name }}" value="{{ value }}"
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}> {% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
<span>{{ label }}</span> <span>{{ label }}</span>
@@ -196,12 +211,12 @@
<span class="sr-only">{{ resource.label }} {{ action_meta[action][1] }}</span> <span class="sr-only">{{ resource.label }} {{ action_meta[action][1] }}</span>
</label> </label>
{% else %} {% else %}
{# Bewusst *kein* Eingabefeld: ein deaktiviertes Kaestchen hat das {# Bewusst *kein* Eingabefeld: ein deaktiviertes Kästchen hat das
JS beim Einschalten des Bereichs wieder freigegeben, es liess JS beim Einschalten des Bereichs wieder freigegeben, es liess
sich anhaken, hatte aber kein name-Attribut -- es sah erteilt sich anhaken, hatte aber kein name-Attribut -- es sah erteilt
aus und war nach dem Speichern weg. Was es nicht gibt, ist aus und war nach dem Speichern weg. Was es nicht gibt, ist
hier deshalb gar kein Bedienelement. #} hier deshalb gar kein Bedienelement. #}
<span class="matrix__na" title="Fuer diese Zeile nicht vorgesehen."></span> <span class="matrix__na" title="Für diese Zeile nicht vorgesehen."></span>
<span class="sr-only">nicht vorgesehen</span> <span class="sr-only">nicht vorgesehen</span>
{% endif %} {% endif %}
</td> </td>
@@ -216,7 +231,7 @@
{% for action, meta in action_meta.items() %} {% for action, meta in action_meta.items() %}
<span><b>{{ meta[0] }}</b> {{ meta[1] }} &ndash; {{ meta[2] }}</span> <span><b>{{ meta[0] }}</b> {{ meta[1] }} &ndash; {{ meta[2] }}</span>
{% endfor %} {% endfor %}
<span><span class="matrix__na"></span> Fuer diese Zeile nicht vorgesehen &ndash; <span><span class="matrix__na"></span> Für diese Zeile nicht vorgesehen &ndash;
die Spalte gehoert zu einer anderen Zeile desselben Bereichs.</span> die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
</div> </div>
{%- endmacro %} {%- endmacro %}
@@ -1,13 +1,13 @@
{% extends "tesm_core/standalone.html" %} {% extends "tesm_core/standalone.html" %}
{% block heading %}Bitte bestaetigen{% endblock %} {% block heading %}Bitte bestätigen{% endblock %}
{% block subheading %}Diese Aktion verlangt eine erneute Eingabe Ihres Passworts{% endblock %} {% block subheading %}Diese Aktion verlangt eine erneute Eingabe Ihres Passworts{% endblock %}
{% block content %} {% block content %}
<div class="alert alert--info"> <div class="alert alert--info">
{{ icon('info', size=18) }} {{ icon('info', size=18) }}
<div class="alert__body"> <div class="alert__body">
Sie greifen auf Geheimnisse oder einen Datenexport zu. Ein uebernommenes Sie greifen auf Geheimnisse oder einen Datenexport zu. Ein übernommenes
Sitzungscookie allein reicht dafuer nicht aus &ndash; die Bestaetigung gilt Sitzungscookie allein reicht dafür nicht aus &ndash; die Bestätigung gilt
danach {{ reauth_minutes }} Minuten. danach {{ reauth_minutes }} Minuten.
</div> </div>
</div> </div>
@@ -19,7 +19,7 @@
</label> </label>
<div class="row row--end"> <div class="row row--end">
<a class="btn" href="{{ cancel_url }}">Abbrechen</a> <a class="btn" href="{{ cancel_url }}">Abbrechen</a>
<button class="btn btn--primary" type="submit">Bestaetigen</button> <button class="btn btn--primary" type="submit">Bestätigen</button>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}
@@ -11,10 +11,10 @@
inputmode="numeric" autocomplete="one-time-code" maxlength="14" inputmode="numeric" autocomplete="one-time-code" maxlength="14"
placeholder="123456" style="letter-spacing:.22em;text-align:center;font-size:1.15rem"> placeholder="123456" style="letter-spacing:.22em;text-align:center;font-size:1.15rem">
<span class="field__hint"> <span class="field__hint">
Alternativ koennen Sie einen Ihrer Wiederherstellungscodes eingeben. Alternativ können Sie einen Ihrer Wiederherstellungscodes eingeben.
</span> </span>
</label> </label>
<button class="btn btn--primary btn--lg btn--block" type="submit">Bestaetigen</button> <button class="btn btn--primary btn--lg btn--block" type="submit">Bestätigen</button>
</form> </form>
<p class="center fs-sm"><a href="{{ url_for('auth.logout_get') }}">Abbrechen und zurueck zur Anmeldung</a></p> <p class="center fs-sm"><a href="{{ url_for('auth.logout_get') }}">Abbrechen und zurück zur Anmeldung</a></p>
{% endblock %} {% endblock %}
@@ -8,7 +8,7 @@
{% block content %} {% block content %}
{{ alert('Sitzungen liegen in der Datenbank, nicht im Cookie. Ein widerrufener Eintrag ist sofort {{ alert('Sitzungen liegen in der Datenbank, nicht im Cookie. Ein widerrufener Eintrag ist sofort
ungueltig -- auch wenn das Cookie noch existiert.', 'info') }} ungültig -- auch wenn das Cookie noch existiert.', 'info') }}
<section class="card"> <section class="card">
<div class="table-wrap"> <div class="table-wrap">
@@ -17,8 +17,8 @@ ungueltig -- auch wenn das Cookie noch existiert.', 'info') }}
<tr> <tr>
<th class="sortable">Benutzer</th> <th class="sortable">Benutzer</th>
<th class="sortable">Angemeldet</th> <th class="sortable">Angemeldet</th>
<th class="sortable">Letzte Aktivitaet</th> <th class="sortable">Letzte Aktivität</th>
<th class="sortable">Laeuft ab</th> <th class="sortable">Läuft ab</th>
<th class="sortable">IP</th> <th class="sortable">IP</th>
<th>Client</th> <th>Client</th>
<th class="actions">Aktion</th> <th class="actions">Aktion</th>
@@ -12,16 +12,16 @@
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %} {% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
{{ alert('Same Sign-On, nicht Single Sign-On: der Benutzer tippt sein Verzeichnispasswort auf der {{ alert('Same Sign-On, nicht Single Sign-On: der Benutzer tippt sein Verzeichnispasswort auf der
normalen Anmeldeseite ein. Es wird bei jeder Anmeldung live gegen das Verzeichnis geprueft und normalen Anmeldeseite ein. Es wird bei jeder Anmeldung live gegen das Verzeichnis geprüft und
niemals hier gespeichert.', 'info') }} niemals hier gespeichert.', 'info') }}
{% if values.ldap_security == 'none' %} {% if values.ldap_security == 'none' %}
{{ alert('Die Verbindung ist unverschluesselt. Das Passwort geht dabei im Klartext ueber das {{ alert('Die Verbindung ist unverschlüsselt. Das Passwort geht dabei im Klartext über das
Netz -- nur fuer Testumgebungen vertretbar.', 'danger', 'Unverschluesselt') }} Netz -- nur für Testumgebungen vertretbar.', 'danger', 'Unverschlüsselt') }}
{% elif not values.ldap_verify_cert %} {% elif not values.ldap_verify_cert %}
{{ alert('Das Serverzertifikat wird nicht geprueft. Damit schuetzt die Verschluesselung nur {{ alert('Das Serverzertifikat wird nicht geprüft. Damit schützt die Verschlüsselung nur
gegen passives Mitlesen, nicht gegen einen Man-in-the-Middle.', 'warning', gegen passives Mitlesen, nicht gegen einen Man-in-the-Middle.', 'warning',
'Zertifikat ungeprueft') }} 'Zertifikat ungeprüft') }}
{% endif %} {% endif %}
<div class="grid grid--split"> <div class="grid grid--split">
@@ -48,8 +48,8 @@ niemals hier gespeichert.', 'info') }}
{{ field('ldap_port', 'Port', value=values.ldap_port, type='number', {{ field('ldap_port', 'Port', value=values.ldap_port, type='number',
disabled=bool(license_hint), attrs='id="ldap-port"') }} disabled=bool(license_hint), attrs='id="ldap-port"') }}
{# Der Transport setzt den Port mit. Wer umstellt und den Port stehen {# Der Transport setzt den Port mit. Wer umstellt und den Port stehen
laesst, bekommt eine Verbindung, die auf gut Glueck scheitert -- lässt, bekommt eine Verbindung, die auf gut Glück scheitert --
und die Meldung ("Zeitlimit") sagt nichts ueber die Ursache. Ein und die Meldung ("Zeitlimit") sagt nichts über die Ursache. Ein
absichtlich abweichender Port bleibt erhalten. #} absichtlich abweichender Port bleibt erhalten. #}
{{ select_field('ldap_security', 'Transport', specs['ldap_security'].choices, {{ select_field('ldap_security', 'Transport', specs['ldap_security'].choices,
selected=values.ldap_security, disabled=bool(license_hint), selected=values.ldap_security, disabled=bool(license_hint),
@@ -68,21 +68,21 @@ niemals hier gespeichert.', 'info') }}
disabled=bool(license_hint)) }} disabled=bool(license_hint)) }}
</div> </div>
<div class="stack stack--tight"> <div class="stack stack--tight">
{{ checkbox('ldap_verify_cert', 'Serverzertifikat pruefen', checked=values.ldap_verify_cert, {{ checkbox('ldap_verify_cert', 'Serverzertifikat prüfen', checked=values.ldap_verify_cert,
disabled=bool(license_hint)) }} disabled=bool(license_hint)) }}
{{ checkbox('ldap_group_sync', 'Gruppen aus dem Verzeichnis uebernehmen', {{ checkbox('ldap_group_sync', 'Gruppen aus dem Verzeichnis übernehmen',
checked=values.ldap_group_sync, disabled=bool(license_hint)) }} checked=values.ldap_group_sync, disabled=bool(license_hint)) }}
{{ checkbox('ldap_allow_insecure', 'Unverschluesselte Verbindung ausdruecklich erlauben', {{ checkbox('ldap_allow_insecure', 'Unverschlüsselte Verbindung ausdrücklich erlauben',
checked=values.ldap_allow_insecure, disabled=bool(license_hint), checked=values.ldap_allow_insecure, disabled=bool(license_hint),
hint_text=specs['ldap_allow_insecure'].hint) }} hint_text=specs['ldap_allow_insecure'].hint) }}
</div> </div>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend>Dienstkonto fuer die Suche (nur Lesezugriff noetig)</legend> <legend>Dienstkonto für die Suche (nur Lesezugriff nötig)</legend>
<div class="form-grid"> <div class="form-grid">
{{ field('bind_username', 'Benutzer', value=bind_username, autocomplete='off', {{ field('bind_username', 'Benutzer', value=bind_username, autocomplete='off',
disabled=not current_user.has('settings_directory.secrets')) }} disabled=not current_user.has('settings_directory.secrets')) }}
{{ field('bind_password', 'Passwort (leer = unveraendert)', type='password', {{ field('bind_password', 'Passwort (leer = unverändert)', type='password',
autocomplete='new-password', autocomplete='new-password',
disabled=not current_user.has('settings_directory.secrets')) }} disabled=not current_user.has('settings_directory.secrets')) }}
</div> </div>
@@ -125,7 +125,7 @@ niemals hier gespeichert.', 'info') }}
action="{{ url_for('hostsettings.directory_mapping_delete', mapping_id=entry.id) }}"> action="{{ url_for('hostsettings.directory_mapping_delete', mapping_id=entry.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="Zuordnung entfernen? Betroffene Benutzer verlieren die Gruppe bei der naechsten Anmeldung." data-confirm="Zuordnung entfernen? Betroffene Benutzer verlieren die Gruppe bei der nächsten Anmeldung."
data-confirm-title="Zuordnung entfernen" data-confirm-ok="Entfernen"> data-confirm-title="Zuordnung entfernen" data-confirm-ok="Entfernen">
{{ icon('trash', size=14) }}</button> {{ icon('trash', size=14) }}</button>
</form> </form>
@@ -5,30 +5,30 @@
{% block page_sub %}Backend: {{ backend }} &middot; Sicherheitsnetz {{ revert_seconds }} s{% endblock %} {% block page_sub %}Backend: {{ backend }} &middot; Sicherheitsnetz {{ revert_seconds }} s{% endblock %}
{% block content %} {% block content %}
{{ alert('Nach einer Aenderung laeuft ein Zeitgeber. Bestaetigen Sie nicht innerhalb von {{ alert('Nach einer Änderung läuft ein Zeitgeber. Bestätigen Sie nicht innerhalb von
' ~ revert_seconds ~ ' Sekunden von der neuen Adresse aus, wird automatisch ' ~ revert_seconds ~ ' Sekunden von der neuen Adresse aus, wird automatisch
zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning', zurückgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
'Automatischer Rueckfall') }} 'Automatischer Rückfall') }}
{% if pending %} {% if pending %}
<section class="card card--danger"> <section class="card card--danger">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('alert', size=17) }} Aenderung wartet auf Bestaetigung</div> <div class="card__title">{{ icon('alert', size=17) }} Änderung wartet auf Bestätigung</div>
<div class="card__sub">Interface {{ pending.interface }}</div> <div class="card__sub">Interface {{ pending.interface }}</div>
</div> </div>
</div> </div>
<div class="card__body"> <div class="card__body">
<form method="post" action="{{ url_for('hostsettings.network_confirm') }}" data-no-guard> <form method="post" action="{{ url_for('hostsettings.network_confirm') }}" 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--primary" type="submit">Verbindung bestaetigen</button> <button class="btn btn--primary" type="submit">Verbindung bestätigen</button>
</form> </form>
</div> </div>
</section> </section>
{% endif %} {% endif %}
{% if not sysops_available %} {% if not sysops_available %}
{{ alert('Systemhelfer nicht verfuegbar -- die Anzeige ist rein lesend.', 'info') }} {{ alert('Systemhelfer nicht verfügbar -- die Anzeige ist rein lesend.', 'info') }}
{% endif %} {% endif %}
<section class="card"> <section class="card">
@@ -54,7 +54,7 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
</table> </table>
</div> </div>
{% else %} {% else %}
{{ empty_state('Keine Interfaces gelesen', 'Das System gibt hier nichts zurueck.', 'network') }} {{ empty_state('Keine Interfaces gelesen', 'Das System gibt hier nichts zurück.', 'network') }}
{% endif %} {% endif %}
</section> </section>
@@ -62,11 +62,11 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
<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('sliders', size=17) }} Aendern</div> <div class="card__title">{{ icon('sliders', size=17) }} Ändern</div>
<div class="card__sub"> <div class="card__sub">
Es wird nur der Block des gewaehlten Interfaces geschrieben (Datei Es wird nur der Block des gewählten Interfaces geschrieben (Datei
<span class="mono">{{ netplan_path }}</span>) &ndash; andere Interfaces bleiben <span class="mono">{{ netplan_path }}</span>) &ndash; andere Interfaces bleiben
unberuehrt. unberührt.
</div> </div>
</div> </div>
</div> </div>
@@ -88,18 +88,18 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
</select> </select>
</label> </label>
{{ field('address', 'IP-Adresse', placeholder='192.168.1.10') }} {{ field('address', 'IP-Adresse', placeholder='192.168.1.10') }}
{{ field('prefix', 'Praefixlaenge', value='24', type='number') }} {{ field('prefix', 'Präfixlänge', value='24', type='number') }}
{{ field('gateway', 'Gateway', placeholder='192.168.1.1') }} {{ field('gateway', 'Gateway', placeholder='192.168.1.1') }}
{{ field('dns', 'DNS-Server', placeholder='192.168.1.1, 9.9.9.9') }} {{ field('dns', 'DNS-Server', placeholder='192.168.1.1, 9.9.9.9') }}
</div> </div>
{{ alert('Das Gateway muss im angegebenen Netz liegen -- sonst waere der Host nach der {{ alert('Das Gateway muss im angegebenen Netz liegen -- sonst wäre der Host nach der
Aenderung nicht mehr erreichbar. Das wird vor dem Schreiben geprueft.', 'info') }} Änderung nicht mehr erreichbar. Das wird vor dem Schreiben geprüft.', 'info') }}
</div> </div>
<div class="card__footer"> <div class="card__footer">
<button class="btn btn--danger" type="submit" <button class="btn btn--danger" type="submit"
data-confirm="Netzwerkeinstellungen jetzt uebernehmen? Die Verbindung kann kurz abbrechen. Ohne Bestaetigung wird nach {{ revert_seconds }} Sekunden zurueckgerollt." data-confirm="Netzwerkeinstellungen jetzt übernehmen? Die Verbindung kann kurz abbrechen. Ohne Bestätigung wird nach {{ revert_seconds }} Sekunden zurückgerollt."
data-confirm-title="Netzwerk aendern" data-confirm-ok="Uebernehmen"> data-confirm-title="Netzwerk ändern" data-confirm-ok="Übernehmen">
Uebernehmen Übernehmen
</button> </button>
</div> </div>
</form> </form>
@@ -10,14 +10,14 @@
{% block content %} {% block content %}
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %} {% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
{% if not sysops_available %} {% if not sysops_available %}
{{ alert('Systemhelfer nicht verfuegbar -- die Konfiguration kann hier erzeugt und angesehen, {{ alert('Systemhelfer nicht verfügbar -- die Konfiguration kann hier erzeugt und angesehen,
aber nicht angewandt werden.', 'info') }} aber nicht angewandt werden.', 'info') }}
{% endif %} {% endif %}
{{ alert('Die Site-Datei wird bei jeder Uebernahme vollstaendig neu geschrieben. {{ alert('Die Site-Datei wird bei jeder Übernahme vollständig neu geschrieben.
Alles, was ein Administrator sonst per Hand hineinschreiben wuerde, ist hier Einstellung. Der Alles, was ein Administrator sonst per Hand hineinschreiben würde, ist hier Einstellung. Der
Vorgaenger schrieb die Datei nur, wenn sie noch nicht existierte -- ein einmal falscher Pfad Vorgänger schrieb die Datei nur, wenn sie noch nicht existierte -- ein einmal falscher Pfad
blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.', 'info', blieb dadurch über Jahre bestehen und lieferte alle statischen Dateien als 404.', 'info',
'Warum die Datei immer neu geschrieben wird') }} 'Warum die Datei immer neu geschrieben wird') }}
<div class="grid grid--split"> <div class="grid grid--split">
@@ -32,12 +32,12 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
<div class="form-grid"> <div class="form-grid">
{{ field('web_domain', 'Domain', value=config.domain, {{ field('web_domain', 'Domain', value=config.domain,
placeholder='tesm.firma.de', disabled=bool(license_hint), placeholder='tesm.firma.de', disabled=bool(license_hint),
hint_text='Leer = Zugriff ueber IP-Adresse.') }} hint_text='Leer = Zugriff über IP-Adresse.') }}
{{ field('web_acme_email', 'E-Mail fuer Let\'s Encrypt', value=acme_email, type='email', {{ field('web_acme_email', 'E-Mail für Let\'s Encrypt', value=acme_email, type='email',
disabled=bool(license_hint)) }} disabled=bool(license_hint)) }}
{{ field('web_cert_path', 'Zertifikat (Pfad)', value=config.cert_path, {{ field('web_cert_path', 'Zertifikat (Pfad)', value=config.cert_path,
disabled=bool(license_hint), extra_class='field--full') }} disabled=bool(license_hint), extra_class='field--full') }}
{{ field('web_key_path', 'Privater Schluessel (Pfad)', value=config.key_path, {{ field('web_key_path', 'Privater Schlüssel (Pfad)', value=config.key_path,
disabled=bool(license_hint), extra_class='field--full') }} disabled=bool(license_hint), extra_class='field--full') }}
{{ field('web_acme_webroot', 'ACME-Webroot', value=config.acme_webroot, {{ field('web_acme_webroot', 'ACME-Webroot', value=config.acme_webroot,
disabled=bool(license_hint), extra_class='field--full') }} disabled=bool(license_hint), extra_class='field--full') }}
@@ -47,7 +47,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
checked=config.https_enabled, disabled=bool(license_hint)) }} checked=config.https_enabled, disabled=bool(license_hint)) }}
{{ checkbox('web_redirect_http', 'HTTP (Port 80) auf HTTPS umleiten', {{ checkbox('web_redirect_http', 'HTTP (Port 80) auf HTTPS umleiten',
checked=config.redirect_http, disabled=bool(license_hint), checked=config.redirect_http, disabled=bool(license_hint),
hint_text='Der ACME-Pfad bleibt dabei erreichbar -- sonst koennte Let\'s hint_text='Der ACME-Pfad bleibt dabei erreichbar -- sonst könnte Let\'s
Encrypt nie erneuern.') }} Encrypt nie erneuern.') }}
{{ checkbox('web_hsts_enabled', 'HSTS-Header senden', {{ checkbox('web_hsts_enabled', 'HSTS-Header senden',
checked=config.hsts_enabled, disabled=bool(license_hint), checked=config.hsts_enabled, disabled=bool(license_hint),
@@ -67,7 +67,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
<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 nginx-Konfiguration</div> <div class="card__title">{{ icon('file-text', size=17) }} Erzeugte nginx-Konfiguration</div>
<div class="card__sub">Vorschau der Datei, die geschrieben wuerde</div> <div class="card__sub">Vorschau der Datei, die geschrieben würde</div>
</div> </div>
{{ icon('chevron-down', size=16) }} {{ icon('chevron-down', size=16) }}
</summary> </summary>
@@ -89,8 +89,8 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
{% if certificate.present and not certificate.get('error') %} {% if certificate.present and not certificate.get('error') %}
<dl class="kv fs-sm"> <dl class="kv fs-sm">
<dt>Inhaber</dt><dd class="break-all">{{ certificate.subject }}</dd> <dt>Inhaber</dt><dd class="break-all">{{ certificate.subject }}</dd>
<dt>Aussteller</dt><dd class="break-all">{{ certificate.issuer }}</dd> <dt>Aussteller</dt><dd class="break-all">{{ certificate.issür }}</dd>
<dt>Gueltig bis</dt><dd>{{ certificate.not_valid_after | dt }}</dd> <dt>Gültig bis</dt><dd>{{ certificate.not_valid_after | dt }}</dd>
<dt>Seriennummer</dt><dd class="mono fs-xs break-all">{{ certificate.serial }}</dd> <dt>Seriennummer</dt><dd class="mono fs-xs break-all">{{ certificate.serial }}</dd>
</dl> </dl>
{% elif certificate.get('error') %} {% elif certificate.get('error') %}
@@ -107,7 +107,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
<button class="btn btn--sm" type="submit" <button class="btn btn--sm" type="submit"
{% if license_hint or not certbot_available or not config.domain %}disabled{% endif %} {% if license_hint or not certbot_available or not config.domain %}disabled{% endif %}
data-busy-label="Fordere an ..." data-busy-label="Fordere an ..."
data-confirm="Zertifikat fuer {{ config.domain }} per Let's Encrypt anfordern? Die Domain muss oeffentlich auf diesen Host zeigen." data-confirm="Zertifikat für {{ config.domain }} per Let's Encrypt anfordern? Die Domain muss öffentlich auf diesen Host zeigen."
data-confirm-title="Zertifikat anfordern" data-confirm-ok="Anfordern" data-confirm-title="Zertifikat anfordern" data-confirm-ok="Anfordern"
data-confirm-style="primary"> data-confirm-style="primary">
{{ icon('lock', size=14) }} Let's-Encrypt-Zertifikat anfordern {{ icon('lock', size=14) }} Let's-Encrypt-Zertifikat anfordern
@@ -117,15 +117,15 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
<p class="fs-xs faint">certbot ist auf diesem Host nicht installiert.</p> <p class="fs-xs faint">certbot ist auf diesem Host nicht installiert.</p>
{% elif not config.domain %} {% elif not config.domain %}
<p class="fs-xs faint"> <p class="fs-xs faint">
Fuer Let's Encrypt wird oben eine Domain gebraucht, die oeffentlich auf Für Let's Encrypt wird oben eine Domain gebraucht, die öffentlich auf
diesen Host zeigt. diesen Host zeigt.
</p> </p>
{% endif %} {% endif %}
<hr> <hr>
<p class="muted fs-sm"> <p class="muted fs-sm">
Kein oeffentlicher Name? Dann ein eigenes Zertifikat erzeugen. Browser Kein öffentlicher Name? Dann ein eigenes Zertifikat erzeugen. Browser
zeigen dafuer eine Warnung, die Verbindung ist aber verschluesselt -- zeigen dafür eine Warnung, die Verbindung ist aber verschlüsselt --
und das Sitzungscookie darf endlich <span class="mono">Secure</span> tragen. und das Sitzungscookie darf endlich <span class="mono">Secure</span> tragen.
</p> </p>
{% if local_names %} {% if local_names %}
@@ -133,7 +133,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
Wird ausgestellt auf: Wird ausgestellt auf:
<span class="mono">{{ (config.domain or local_names[0]) }}</span> <span class="mono">{{ (config.domain or local_names[0]) }}</span>
{% if local_names|length > 1 %} {% if local_names|length > 1 %}
(zusaetzlich {{ local_names[1:] | join(', ') }}) (zusätzlich {{ local_names[1:] | join(', ') }})
{% endif %} {% endif %}
</p> </p>
{% endif %} {% endif %}
@@ -156,7 +156,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
<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) }} Uebernehmen</div> <div class="card__title">{{ icon('upload', size=17) }} Übernehmen</div>
<div class="card__sub">Schreiben, <span class="mono">nginx -t</span>, dann neu laden</div> <div class="card__sub">Schreiben, <span class="mono">nginx -t</span>, dann neu laden</div>
</div> </div>
</div> </div>
@@ -165,11 +165,11 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--primary btn--block" type="submit" <button class="btn btn--primary btn--block" type="submit"
{% if license_hint or not sysops_available %}disabled{% endif %} {% if license_hint or not sysops_available %}disabled{% endif %}
data-busy-label="Uebernehme ..." data-busy-label="Übernehme ..."
data-confirm="Konfiguration schreiben, pruefen und nginx neu laden? Eine fehlerhafte Konfiguration wird vorher abgewiesen." data-confirm="Konfiguration schreiben, prüfen und nginx neu laden? Eine fehlerhafte Konfiguration wird vorher abgewiesen."
data-confirm-title="nginx uebernehmen" data-confirm-ok="Uebernehmen" data-confirm-title="nginx übernehmen" data-confirm-ok="Übernehmen"
data-confirm-style="primary"> data-confirm-style="primary">
Konfiguration uebernehmen Konfiguration übernehmen
</button> </button>
</form> </form>
</div> </div>
@@ -1,11 +1,11 @@
{# ========================================================================== {# ==========================================================================
Grundgeruest fuer Seiten ohne Anwendungsrahmen: Anmeldung, zweiter Faktor, Grundgerüst für Seiten ohne Anwendungsrahmen: Anmeldung, zweiter Faktor,
Kundenportal, Self-Service. Kundenportal, Self-Service.
Wichtiger Unterschied zu base.html: Flash-Meldungen werden hier **sichtbar Wichtiger Unterschied zu base.html: Flash-Meldungen werden hier **sichtbar
serverseitig** gerendert, nicht ueber die JSON-Bruecke. Der Vorgaenger hatte serverseitig** gerendert, nicht über die JSON-Brücke. Der Vorgänger hatte
dieselbe Trennung; sie ist wichtig, weil diese Seiten auch ohne JavaScript dieselbe Trennung; sie ist wichtig, weil diese Seiten auch ohne JavaScript
nutzbar bleiben muessen (und weil Tests hier direkt auf Text pruefen). nutzbar bleiben müssen (und weil Tests hier direkt auf Text prüfen).
========================================================================== #} ========================================================================== #}
<!doctype html> <!doctype html>
<html lang="de"> <html lang="de">
@@ -23,11 +23,11 @@
<body data-csrf="{{ csrf_token() }}"> <body data-csrf="{{ csrf_token() }}">
<div class="auth-page"> <div class="auth-page">
{% if auth_tagline %} {% if auth_tagline %}
{# Wortmarke mit Funktionszeile als tatsaechlicher Seitenhintergrund -- {# Wortmarke mit Funktionszeile als tatsächlicher Seitenhintergrund --
uebernommen vom Vorgaenger. Bewusst inline und mit Themen-Tokens statt übernommen vom Vorgänger. Bewusst inline und mit Themen-Tokens statt
zweier Dateien fuer hell und dunkel: bei der Einstellung "System" und zweier Dateien für hell und dunkel: bei der Einstellung "System" und
beim Umschalten von Hand waere die versteckte Variante die falsche. beim Umschalten von Hand wäre die versteckte Variante die falsche.
``aria-hidden``, weil der Name schon in der Ueberschrift steht. #} ``aria-hidden``, weil der Name schon in der Überschrift steht. #}
<div class="auth-backdrop" aria-hidden="true"> <div class="auth-backdrop" aria-hidden="true">
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(150,15)"> <g transform="translate(150,15)">
@@ -71,7 +71,7 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
<p class="auth-footnote">{{ app_name }} {{ app_version }} &middot; &copy; {{ current_year }} WiS GmbH</p> <p class="auth-footnote">{{ app_name }} {{ app_version }} &middot; &copy; {{ current_year }} {{ copyright_notice }}</p>
</div> </div>
</div> </div>
<script type="module" src="{{ asset_url('js/app.js') }}" nonce="{{ csp_nonce }}"></script> <script type="module" src="{{ asset_url('js/app.js') }}" nonce="{{ csp_nonce }}"></script>
@@ -2,11 +2,11 @@
{% from "tesm_core/macros.html" import empty_state, alert, pill %} {% from "tesm_core/macros.html" import empty_state, alert, pill %}
{% block page_title %}Papierkorb{% endblock %} {% block page_title %}Papierkorb{% endblock %}
{% block page_sub %}{{ total }} Eintraege &middot; Aufbewahrung {{ retention_days }} Tage{% endblock %} {% block page_sub %}{{ total }} Einträge &middot; Aufbewahrung {{ retention_days }} Tage{% endblock %}
{% block content %} {% block content %}
{{ alert('Ansehen ist immer moeglich. Wiederherstellen und endgueltiges Loeschen erfordern eine {{ alert('Ansehen ist immer möglich. Wiederherstellen und endgültiges Löschen erfordern eine
gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Seite entfernt.', gültige, aktivierte Lizenz. Abgelaufene Einträge werden beim Aufruf dieser Seite entfernt.',
'info') }} 'info') }}
{% for section in sections %} {% for section in sections %}
@@ -23,8 +23,8 @@ gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Sei
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="kind" value="{{ section.key }}"> <input type="hidden" name="kind" value="{{ section.key }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit" <button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Alle {{ section.entries | length }} Eintraege unter {{ section.label }} endgueltig loeschen? Das laesst sich nicht rueckgaengig machen." data-confirm="Alle {{ section.entries | length }} Einträge unter {{ section.label }} endgültig löschen? Das lässt sich nicht rückgängig machen."
data-confirm-title="Endgueltig loeschen" data-confirm-ok="Endgueltig loeschen"> data-confirm-title="Endgültig löschen" data-confirm-ok="Endgültig löschen">
Bereich leeren Bereich leeren
</button> </button>
</form> </form>
@@ -33,7 +33,7 @@ gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Sei
{% if section.entries %} {% if section.entries %}
<div class="table-wrap"> <div class="table-wrap">
<table class="table table--compact"> <table class="table table--compact">
<thead><tr><th>Eintrag</th><th>Detail</th><th>Geloescht</th><th class="actions">Aktion</th></tr></thead> <thead><tr><th>Eintrag</th><th>Detail</th><th>Gelöscht</th><th class="actions">Aktion</th></tr></thead>
<tbody> <tbody>
{% for entry in section.entries %} {% for entry in section.entries %}
<tr> <tr>
@@ -53,9 +53,9 @@ gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Sei
action="{{ url_for('trash.purge', kind=section.key, ident=entry.id) }}"> action="{{ url_for('trash.purge', kind=section.key, ident=entry.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="{{ entry.label }} endgueltig loeschen?" data-confirm="{{ entry.label }} endgültig löschen?"
data-confirm-title="Endgueltig loeschen" data-confirm-ok="Loeschen"> data-confirm-title="Endgültig löschen" data-confirm-ok="Löschen">
Endgueltig Endgültig
</button> </button>
</form> </form>
{% endif %} {% endif %}
@@ -20,7 +20,7 @@
<span class="table-toolbar__spacer"></span> <span class="table-toolbar__spacer"></span>
{% if directory_enabled and current_user.has('users.create') %} {% if directory_enabled and current_user.has('users.create') %}
<button class="btn btn--sm" type="button" data-dialog-open="user-directory"> <button class="btn btn--sm" type="button" data-dialog-open="user-directory">
{{ icon('users', size=15) }} Aus Verzeichnis uebernehmen {{ icon('users', size=15) }} Aus Verzeichnis übernehmen
</button> </button>
{% endif %} {% endif %}
{% if current_user.is_admin %} {% if current_user.is_admin %}
@@ -90,9 +90,9 @@
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" <button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Konto {{ user.username }} wirklich loeschen?{% if user.auth_source == 'ldap' %} Verzeichniskonten werden sofort endgueltig entfernt.{% else %} Es landet im Papierkorb.{% endif %}" data-confirm="Konto {{ user.username }} wirklich löschen?{% if user.auth_source == 'ldap' %} Verzeichniskonten werden sofort endgültig entfernt.{% else %} Es landet im Papierkorb.{% endif %}"
data-confirm-title="Konto loeschen" data-confirm-ok="Loeschen" data-confirm-title="Konto löschen" data-confirm-ok="Löschen"
aria-label="Loeschen">{{ icon('trash', size=15) }}</button> aria-label="Löschen">{{ icon('trash', size=15) }}</button>
</form> </form>
{% endif %} {% endif %}
</td> </td>
@@ -106,8 +106,8 @@
{% endif %} {% endif %}
</section> </section>
{{ alert('Rechte werden ausschliesslich ueber Gruppen vergeben. Das Admin-Kennzeichen ist davon {{ alert('Rechte werden ausschliesslich über Gruppen vergeben. Das Admin-Kennzeichen ist davon
unabhaengig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes unabhängig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes
Administratorkonto muss immer bestehen bleiben.', 'info') }} Administratorkonto muss immer bestehen bleiben.', 'info') }}
{% endblock %} {% endblock %}
@@ -137,7 +137,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
</div> </div>
</fieldset> </fieldset>
{% if current_user.is_admin %} {% if current_user.is_admin %}
{{ checkbox('is_admin', 'Administrator (darf alles, unabhaengig von Gruppen)') }} {{ checkbox('is_admin', 'Administrator (darf alles, unabhängig von Gruppen)') }}
{% endif %} {% endif %}
{{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }} {{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }}
</div> </div>
@@ -149,7 +149,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
{% endcall %} {% endcall %}
{% if directory_enabled %} {% if directory_enabled %}
{% call modal('user-directory', 'Aus Verzeichnis uebernehmen', {% call modal('user-directory', 'Aus Verzeichnis übernehmen',
'Konto vorbereiten, damit Gruppen vor der ersten Anmeldung sitzen') %} 'Konto vorbereiten, damit Gruppen vor der ersten Anmeldung sitzen') %}
<form method="post" action="{{ url_for('admin.directory_adopt') }}"> <form method="post" action="{{ url_for('admin.directory_adopt') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -169,7 +169,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
</div> </div>
</fieldset> </fieldset>
{{ alert('Name und E-Mail werden bei der ersten echten Anmeldung aus dem Verzeichnis {{ alert('Name und E-Mail werden bei der ersten echten Anmeldung aus dem Verzeichnis
uebernommen und ueberschrieben.', 'info') }} übernommen und überschrieben.', 'info') }}
</div> </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>
@@ -198,7 +198,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
{{ field('email', 'E-Mail', value=user.email, type='email') }} {{ field('email', 'E-Mail', value=user.email, type='email') }}
{{ field('new_password', 'Neues Passwort setzen (optional)', type='password', {{ field('new_password', 'Neues Passwort setzen (optional)', type='password',
autocomplete='new-password', autocomplete='new-password',
hint_text='Setzt das Passwort zurueck, erzwingt einen Wechsel und beendet alle Sitzungen des Kontos.') }} hint_text='Setzt das Passwort zurück, erzwingt einen Wechsel und beendet alle Sitzungen des Kontos.') }}
{% endif %} {% endif %}
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend>Gruppen</legend> <legend>Gruppen</legend>
+60
View File
@@ -126,6 +126,37 @@ def format_date(value: Any) -> str:
return format_datetime(value, with_time=False) return format_datetime(value, with_time=False)
def format_millis(value: Any) -> str:
"""Millisekunden lesbar: ms, dann Sekunden, dann Minuten:Sekunden.
Vierzehn Stellen Millisekunden liest niemand. "315895 ms" sagt nichts,
"5:15" sofort etwas. Die Grenzen liegen dort, wo die naechstgroessere
Einheit anfaengt, und die Sekunden bekommen zwei Nachkommastellen -- bei
einem Neustart ist der Unterschied zwischen 15,2 s und 15,97 s durchaus
von Interesse.
"""
try:
millis = float(value)
except (TypeError, ValueError):
return "-"
if millis < 0:
return "-"
# Unter 10 ms mit einer Nachkommastelle: Antwortzeiten liegen oft bei
# 0,3 ms, und "0 ms" waere schlicht falsch.
if millis < 10:
return f"{millis:.1f} ms".replace(".", ",")
if millis < 1000:
return f"{int(round(millis))} ms"
# Erst runden, dann die Einheit waehlen: 59999 ms sind gerundet 60,00 s und
# gehoeren damit in die Minutenform, nicht in eine Sekundenangabe, die wie
# eine Minute aussieht.
hundertstel = int(round(millis / 10))
if hundertstel < 60 * 100:
return f"{hundertstel / 100:.2f} s".replace(".", ",")
minuten, rest = divmod(hundertstel, 60 * 100)
return f"{minuten}:{rest / 100:05.2f}".replace(".", ",")
def format_duration(seconds: Any) -> str: def format_duration(seconds: Any) -> str:
try: try:
total = int(seconds) total = int(seconds)
@@ -154,6 +185,32 @@ def yes_no(value: Any) -> str:
return "ja" if value else "nein" return "ja" if value else "nein"
#: Woher ein gepinnter Ausstellerschluessel kommt -- in Klartext.
#:
#: In der Datenbank stehen die Kuerzel des Protokolls. "tofu" ist dabei
#: Fachjargon (Trust On First Use) und sah in der Spalte aus wie ein
#: Tippfehler. Uebersetzt wird an genau einer Stelle, damit die beiden
#: Anwendungen nicht auseinanderlaufen.
ISSUER_SOURCES: dict[str, tuple[str, str]] = {
"tofu": ("Erstkontakt", "beim ersten Einspielen automatisch uebernommen"),
"admin-confirmed": (
"Bestaetigt",
"Ausstellerwechsel von einem Administrator freigegeben",
),
}
def issuer_source(value: Any) -> str:
"""Kurzform der Herkunft. Unbekannte Werte bleiben unveraendert sichtbar."""
label, _ = ISSUER_SOURCES.get(str(value or ""), (str(value or "-"), ""))
return label
def issuer_source_hint(value: Any) -> str:
"""Erklaerung zur Herkunft, oder leer."""
return ISSUER_SOURCES.get(str(value or ""), ("", ""))[1]
def from_json(value: Any) -> Any: def from_json(value: Any) -> Any:
"""Parst einen JSON-String aus der Datenbank -- fuer Modul-/Rechtelisten. """Parst einen JSON-String aus der Datenbank -- fuer Modul-/Rechtelisten.
@@ -230,9 +287,12 @@ def init_app(app: Flask) -> None:
dt=format_datetime, dt=format_datetime,
date=format_date, date=format_date,
duration=format_duration, duration=format_duration,
millis=format_millis,
number=format_number, number=format_number,
yes_no=yes_no, yes_no=yes_no,
from_json=from_json, from_json=from_json,
issuer_source=issuer_source,
issuer_source_hint=issuer_source_hint,
) )
app.jinja_env.trim_blocks = True app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True app.jinja_env.lstrip_blocks = True
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-licensing" name = "tesm-licensing"
version = "2.0.0" version = "2.0.2"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["cryptography>=42"] dependencies = ["cryptography>=42"]
@@ -62,7 +62,7 @@ from .protocol import (
verify_master_response, verify_master_response,
) )
__version__ = "2.0.0" __version__ = "2.0.2"
__all__ = [ __all__ = [
"ACTIONS", "ACTIONS",
Binary file not shown.