Compare commits

10 Commits
Author SHA1 Message Date
alientimandClaude Opus 5 168d10bf87 Leerlaufabmeldung: die zweite Schreibstelle und ein Tab, der das alte Skript faehrt
Die Abmeldung hat weiterhin nicht ausgeloest. Zwei Gruende, beide fuer sich
ausreichend:

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

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

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

Version 2.0.4.

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

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

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

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

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

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

Ausserdem: Version 2.0.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 11:43:04 +02:00
alientimandClaude Opus 5 50da075c15 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:17:01 +02:00
alientimandClaude Opus 5 23980182b0 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:07 +02:00
alientimandClaude Opus 5 85bec34d7d 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:22 +02:00
alientimandClaude Opus 5 ab5fda658e 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:35 +02:00
alientimandClaude Opus 5 4e488625fe 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:44 +02:00
alientimandClaude Opus 5 9799843cd2 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:44 +02:00
alientimandClaude Opus 5 8b39bfaf10 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:03 +02:00
alientimandClaude Opus 5 8e66d9e7bc apt-Installation laeuft ausserhalb des Dienst-Sandkastens
Der Sandkasten wird an dpkg und dessen Maintainer-Skripte weitervererbt; mit
ProtectKernelLogs=yes liefert "dmesg" ein EPERM und ein Postinst-Skript bricht
daran ab -- die Installation scheitert mit "dpkg returned an error code (1)",
obwohl mit dem Paket alles in Ordnung ist. apt laeuft jetzt ueber systemd-run,
also von PID 1 gestartet und damit ohne unsere Haertung.

Der zuvor eingebaute nsenter-Umweg beim Einbinden von Freigaben ist
zurueckgenommen. Diese Verben ruft der Lizenzserver nicht auf -- der Helfer
bleibt aber in beiden Repositorien dieselbe Datei, weil eine zweite Fassung
zwei Stellen waere, die auseinanderlaufen koennen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:49:56 +02:00
74 changed files with 1711 additions and 555 deletions
+6
View File
@@ -22,3 +22,9 @@ dist/
.pytest_cache/
.ruff_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
+28 -28
View File
@@ -1,17 +1,17 @@
# TESM-Lizenzserver
Ausgabe und Verwaltung der Lizenzen fuer TESM: Kunde -> Ticket -> Lizenz,
Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand ueber
Ausgabe und Verwaltung der Lizenzen für TESM: Kunde -> Ticket -> Lizenz,
Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand über
MS Graph und eine Hierarchie aus Unter-Lizenzservern.
**Dieses Repository enthaelt genau das, was zum Installieren und Betreiben des
Lizenzservers gebraucht wird** -- nichts darueber hinaus:
**Dieses Repository enthält genau das, was zum Installieren und Betreiben des
Lizenzservers gebraucht wird** -- nichts darüber hinaus:
```
apps/tesm-license die Anwendung
packages/tesm-core gemeinsamer Kern (Anmeldung, Rechte, Sicherung, nginx, ...)
packages/tesm-licensing Lizenzprotokoll, gemeinsame Haelfte
packages/tesm-licensing-server Lizenzprotokoll, Serverhaelfte: ausstellen, erneuern, antworten
packages/tesm-licensing Lizenzprotokoll, gemeinsame Hälfte
packages/tesm-licensing-server Lizenzprotokoll, Serverhälfte: ausstellen, erneuern, antworten
deploy/ install.sh, update.sh, release.sh, systemd, privilegierter Helfer
docs/ Betrieb und Sicherheit
```
@@ -28,7 +28,7 @@ Die verwaltete Anwendung liegt hier **nicht** mit drin.
| Zweig | Inhalt |
|---|---|
| `main` | dieser Neubau (Version 2.0.0) |
| `SONNET5` | der Stand davor, unveraendert aufbewahrt |
| `SONNET5` | der Stand davor, unverändert aufbewahrt |
---
@@ -41,18 +41,18 @@ sudo -u tesm-license tesm-license-admin bootstrap
sudo -u tesm-license tesm-license-admin create-admin
```
**`init-key` erzeugt den Signaturschluessel dieses Servers. Sichern Sie ihn
sofort.** Ohne ihn laesst sich keine ausgestellte Lizenz mehr erneuern, und
Kunden muessten einen Ausstellerwechsel bestaetigen.
**`init-key` erzeugt den Signaturschlüssel dieses Servers. Sichern Sie ihn
sofort.** Ohne ihn lässt sich keine ausgestellte Lizenz mehr erneuern, und
Kunden müssten einen Ausstellerwechsel bestätigen.
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.
Sitzt ein eigener Reverse Proxy davor, der TLS beendet, laeuft der Dienst ohne
Sitzt ein eigener Reverse Proxy davor, der TLS beendet, läuft der Dienst ohne
`--https` schlicht auf Port 80.
Der Lizenzserver darf neben TESM auf demselben Host laufen; nginx unterscheidet
die beiden am Servernamen, und jede Installation hat ihren eigenen `SITE_KEY`
fuer nginx-Site, logrotate, Zertifikate und Sitzungscookie.
für nginx-Site, logrotate, Zertifikate und Sitzungscookie.
### Aktualisieren
@@ -60,7 +60,7 @@ fuer nginx-Site, logrotate, Zertifikate und Sitzungscookie.
sudo bash deploy/update.sh --app tesm-license
```
Vor jedem Update entsteht ein vollstaendiges Backup, danach laeuft ein
Vor jedem Update entsteht ein vollständiges Backup, danach läuft ein
Health-Check mit automatischem Rollback.
---
@@ -69,16 +69,16 @@ Health-Check mit automatischem Rollback.
```bash
sudo -u tesm-license tesm-license-admin status
sudo -u tesm-license tesm-license-admin init-key # Signaturschluessel
sudo -u tesm-license tesm-license-admin init-key # Signaturschlüssel
sudo -u tesm-license tesm-license-admin bootstrap # Grunddaten
sudo -u tesm-license tesm-license-admin create-admin
sudo -u tesm-license tesm-license-admin migrate-legacy # Altbestand uebernehmen
sudo -u tesm-license tesm-license-admin migrate-legacy # Altbestand übernehmen
```
`migrate-legacy` liest die SQLite-Datei eines alten Lizenzservers **nur
lesend**, uebernimmt dessen Signaturschluessel und stellt die Lizenzen mit ihrer
restlichen Laufzeit neu aus. Wird der Schluessel nicht uebernommen, muessen
Kunden den Ausstellerwechsel einmal bestaetigen.
lesend**, übernimmt dessen Signaturschlüssel und stellt die Lizenzen mit ihrer
restlichen Laufzeit neu aus. Wird der Schlüssel nicht übernommen, müssen
Kunden den Ausstellerwechsel einmal bestätigen.
---
@@ -101,14 +101,14 @@ genau einmal.
(`tesm/license-file/v2\0`) -- eine Signatur aus einem Kontext ist in einem
anderen nicht verwendbar.
* Kanonisches JSON, damit dieselbe Aussage genau eine Signatur hat.
* Nonce gegen Wiedereinspielung, `seq` gegen das Zurueckrollen auf eine aeltere
* Nonce gegen Wiedereinspielung, `seq` gegen das Zurückrollen auf eine ältere
Lizenz.
* Aussteller wird beim ersten Kontakt gemerkt (TOFU); ein Wechsel muss
ausdruecklich bestaetigt werden.
* Der Fingerabdruck der Maschine enthaelt **keinen** Hostnamen -- Umbenennen
brach im Vorgaenger die Bindung.
* Offline-Aktivierung ueber einen kurzen Code in beide Richtungen; die Lizenz
bleibt auf dem Server als "Aktivierung offen" markiert, bis sie zurueckkommt.
ausdrücklich bestätigt werden.
* Der Fingerabdruck der Maschine enthält **keinen** Hostnamen -- Umbenennen
brach im Vorgänger die Bindung.
* Offline-Aktivierung über einen kurzen Code in beide Richtungen; die Lizenz
bleibt auf dem Server als "Aktivierung offen" markiert, bis sie zurückkommt.
* Keine Lizenz ohne `master_endpoint`: eine Ausstellung ohne Endpunkt wird
abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann.
@@ -130,8 +130,8 @@ Entwicklungsrepository, ebenso die Tests.
* **Keine doppelte `licensing.py`.** Ein Paket, das beide Anwendungen
installieren -- statt zweier Dateien, die byte-identisch bleiben sollen.
* **Kein root fuer die Anwendung.** Ein einziger allowlisted Helfer mit
Argumentpruefung.
* **CSRF-Schutz ueberhaupt.** Der Vorgaenger hatte keinen.
* **Rechte werden serverseitig geprueft**, nicht nur im Template ausgeblendet.
* **Kein root für die Anwendung.** Ein einziger allowlisted Helfer mit
Argumentprüfung.
* **CSRF-Schutz überhaupt.** Der Vorgänger hatte keinen.
* **Rechte werden serverseitig geprüft**, nicht nur im Template ausgeblendet.
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-license"
version = "2.0.0"
version = "2.0.4"
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
requires-python = ">=3.11"
dependencies = [
@@ -23,7 +23,7 @@ from .permissions import PERMISSIONS, ROLE_PRESETS
from .schema import LICENSE_MIGRATIONS
from .settings_spec import LICENSE_SERVER_SETTINGS
__version__ = "2.0.0"
__version__ = "2.0.2"
APP_KEY = "tesm-license"
APP_NAME = "TESM-Lizenzserver"
+25 -2
View File
@@ -336,6 +336,28 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
key = master_key.load(key_path)
with app.app_context(), extension.database.session() as conn:
# Anbieterdaten und Endpunkt zuerst: bei einem Upgrade ist die neue
# Installation frisch, dort steht also nichts, und die Pruefung
# weiter unten haette jedes Upgrade abgebrochen. Beides gehoert zur
# Anbieteridentitaet -- genau die soll ein Upgrade nicht wechseln.
# Uebernommen wird nur, was hier leer ist.
if not args.dry_run:
with extension.database.transaction(conn):
uebernommen = legacy_import.adopt_vendor_settings(
legacy,
lambda key: extension.settings.get(conn, key),
lambda key, wert: extension.settings.set(
conn, key, wert, actor="migration"
),
)
# Nicht "key" als Laufvariable: darueber steht der geladene
# Signaturschluessel mit demselben Namen, und ihn zu
# ueberschreiben lief in ein AttributeError mitten in der
# Uebernahme.
for einstellung, wert in uebernommen.items():
gekuerzt = wert if len(wert) <= 60 else wert[:57] + "..."
print(f"Uebernommen: {einstellung} = {gekuerzt}")
vendor = {
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
"email": str(extension.settings.get(conn, "vendor_email") or ""),
@@ -346,8 +368,9 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
endpoint = str(extension.settings.get(conn, "master_endpoint") or "")
if not endpoint and not args.dry_run:
print(
"Es ist kein Endpunkt hinterlegt (Einstellungen -> Anbieter). Ohne ihn "
"koennen uebernommene Lizenzen sich spaeter nicht aktivieren.",
"Es ist kein Endpunkt hinterlegt, und die alte Installation nennt "
"auch keinen. Bitte unter Einstellungen -> Anbieter eintragen: ohne "
"ihn koennen uebernommene Lizenzen sich spaeter nicht aktivieren.",
file=sys.stderr,
)
return 2
@@ -86,7 +86,7 @@ NAVIGATION = NavTree(
),
NavItem(
key="logs_audit",
label="Aenderungen",
label="Audit",
icon="history",
endpoint="logs.audit_view",
permissions=("logs_audit.view", "logs_audit.export"),
@@ -25,6 +25,8 @@ angefasst, nicht angehalten und nicht veraendert.
from __future__ import annotations
import base64
import binascii
import json
import sqlite3
from dataclasses import dataclass, field
@@ -110,6 +112,39 @@ def open_legacy(root: Path) -> sqlite3.Connection:
return conn
def _standard_b64(wert: str, feld: str) -> str:
"""Nimmt Standard- **und** URL-Alphabet an, gibt Standard-Base64 zurueck.
Der Vorgaenger kodierte seine Schluessel mit ``urlsafe_b64encode``: dort
stehen ``-`` und ``_`` an den Stellen von ``+`` und ``/``. Das neue
Protokoll prueft Base64 streng -- richtig so, aber beim Lesen einer alten
Datei fuehrte es dazu, dass die Uebernahme mit einem Traceback ueber
"kein gueltiges Base64" abbrach, bei jedem echten Altbestand.
"""
roh = wert.strip()
if not roh:
raise LegacyImportError(f"Im alten Signaturschluessel fehlt {feld!r}.")
# Fehlende Polsterung ergaenzen -- der Vorgaenger schrieb sie mit, andere
# Werkzeuge lassen sie weg.
gepolstert = roh + "=" * (-len(roh) % 4)
# Erst das Standard-, dann das URL-Alphabet -- beide mit Pruefung. Ohne
# ``validate`` verschluckt der Decoder unbekannte Zeichen einfach, und aus
# "###" wuerde eine leere Ausgabe statt einer Fehlermeldung.
versuche = (gepolstert, gepolstert.replace("-", "+").replace("_", "/"))
for kandidat in versuche:
try:
rohbytes = base64.b64decode(kandidat, validate=True)
except (ValueError, binascii.Error):
continue
if len(rohbytes) != 32:
raise LegacyImportError(
f"{feld} im alten Signaturschluessel ist {len(rohbytes)} Byte lang, "
"erwartet werden 32 (Ed25519)."
)
return base64.b64encode(rohbytes).decode("ascii")
raise LegacyImportError(f"{feld} im alten Signaturschluessel ist kein Base64.")
def read_legacy_key(root: Path) -> tuple[str, str]:
"""Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``."""
path = Path(root) / "master_signing_key.json"
@@ -120,12 +155,23 @@ def read_legacy_key(root: Path) -> tuple[str, str]:
except (OSError, ValueError) as exc:
raise LegacyImportError(f"Der alte Signaturschluessel ist unlesbar: {exc}") from exc
# Die Vorgaengerfassung nannte die Felder "private"/"public".
# Die Vorgaengerfassung nannte die Felder "private"/"public" und kodierte
# sie URL-sicher.
private = str(data.get("private") or data.get("private_key") or "")
public = str(data.get("public") or data.get("public_key") or "")
if not private or not public:
raise LegacyImportError("Der alte Signaturschluessel ist unvollstaendig.")
if licsrv.public_from_private(private) != public:
private = _standard_b64(private, "private")
public = _standard_b64(public, "public")
# Alles, was aus der Protokollschicht kommt, wird hier zu einer Meldung im
# Klartext. Ein Traceback ueber Base64 mitten in einer Uebernahme sagt dem
# Betreiber nichts -- und die Uebernahme bricht ohne --adopt-key gar nicht
# ab, sie merkt sich nur den alten Aussteller nicht.
try:
passt = licsrv.public_from_private(private) == public
except (ValueError, TypeError) as exc:
raise LegacyImportError(f"Der alte Signaturschluessel ist unbrauchbar: {exc}") from exc
if not passt:
raise LegacyImportError(
"Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht "
"zueinander. Die Datei ist beschaedigt."
@@ -187,6 +233,55 @@ def _modules(raw: Any) -> list[str]:
return sorted(set(str(value) for value in values) & set(lic.ALL_MODULES))
#: Einstellungen, die zur Anbieteridentitaet gehoeren und in beiden Fassungen
#: gleich heissen. ``master_endpoint`` ist darunter das entscheidende: ohne ihn
#: findet eine uebernommene Lizenz ihren Server spaeter nicht wieder.
VENDOR_SETTINGS = (
"master_endpoint",
"vendor_name",
"vendor_email",
"vendor_phone",
"vendor_address",
"vendor_logo_base64",
)
def read_vendor_settings(legacy: sqlite3.Connection) -> dict[str, str]:
"""Liest die Anbietereinstellungen der alten Installation."""
try:
rows = legacy.execute(
"SELECT key, value FROM settings WHERE key IN ({})".format(
",".join("?" for _ in VENDOR_SETTINGS)
),
VENDOR_SETTINGS,
).fetchall()
except sqlite3.Error: # pragma: no cover - alte Fassung ohne settings
return {}
return {str(row["key"]): str(row["value"] or "") for row in rows if str(row["value"] or "")}
def adopt_vendor_settings(
legacy: sqlite3.Connection,
read: Any,
write: Any,
) -> dict[str, str]:
"""Fuellt leere Anbietereinstellungen aus der alten Installation.
``read(key)`` und ``write(key, wert)`` kommen von aussen, damit diese
Funktion nichts ueber den Einstellungsspeicher wissen muss. Rueckgabe: was
tatsaechlich uebernommen wurde -- der Aufrufer soll es benennen koennen.
"""
alt = read_vendor_settings(legacy)
uebernommen: dict[str, str] = {}
for key, wert in alt.items():
if str(read(key) or "").strip():
continue # was hier schon steht, gilt
write(key, wert)
uebernommen[key] = wert
return uebernommen
def import_all(
legacy: sqlite3.Connection,
conn: sqlite3.Connection,
@@ -1,26 +1,26 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, alert, pill %}
{% block page_title %}Sicherung und Uebertragung{% endblock %}
{% block page_sub %}Verschluesselter Export &middot; zweistufiger Import{% endblock %}
{% block page_title %}Sicherung und Übertragung{% endblock %}
{% block page_sub %}Verschlüsselter Export &middot; zweistufiger Import{% endblock %}
{% block content %}
{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschluesselt (AES-256-GCM). Ohne
diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertuer.', 'warning',
{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschlüsselt (AES-256-GCM). Ohne
diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertür.', 'warning',
'Passphrase') }}
{{ alert('Der Signaturschluessel (' ~ signing_key_path ~ ') ist bewusst
nicht Teil dieser Sicherung. Er ist der Kern des Vertrauens und gehoert in eine
{{ alert('Der Signaturschlüssel (' ~ signing_key_path ~ ') ist bewusst
nicht Teil dieser Sicherung. Er ist der Kern des Vertrauens und gehört in eine
getrennte, bewusst verwahrte Sicherung -- nicht in dieselbe Datei wie die Kundendaten. Ohne ihn
ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'danger',
'Wichtig: Schluessel getrennt sichern') }}
ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr prüfbar.', 'danger',
'Wichtig: Schlüssel getrennt sichern') }}
<div class="grid grid--split">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('download', size=17) }} Sicherung erstellen</div>
<div class="card__sub">Verlangt eine frische Passwortbestaetigung</div>
<div class="card__sub">Verlangt eine frische Passwortbestätigung</div>
</div>
</div>
<form method="post" action="{{ url_for('backup.export') }}" data-no-guard>
@@ -40,7 +40,7 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
{% if category.admin_only %}{{ pill('nur Admin', 'accent') }}{% endif %}
<div class="fs-xs faint">{{ category.hint }}</div>
{% if not allowed %}
<div class="fs-xs danger-text">Dafuer fehlt Ihnen das Recht.</div>
<div class="fs-xs danger-text">Dafür fehlt Ihnen das Recht.</div>
{% endif %}
</span>
</label>
@@ -66,8 +66,8 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
<section class="card card--accent">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Geoeffnete Sicherung</div>
<div class="card__sub">Gueltig fuer {{ preview_ttl }} Minuten</div>
<div class="card__title">{{ icon('upload', size=17) }} Geöffnete Sicherung</div>
<div class="card__sub">Gültig für {{ preview_ttl }} Minuten</div>
</div>
</div>
<form method="post" action="{{ url_for('backup.import_apply') }}">
@@ -75,7 +75,7 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
<div class="card__body">
<div class="table-wrap">
<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>
{% for entry in preview.summary %}
<tr>
@@ -89,17 +89,17 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
</tbody>
</table>
</div>
{{ alert('Bereits vorhandene Lizenzen werden nie ueberschrieben -- ihr Inhalt ist
kryptographisch festgelegt. Kunden werden ueber ihren Namen zusammengefuehrt.',
{{ alert('Bereits vorhandene Lizenzen werden nie überschrieben -- ihr Inhalt ist
kryptographisch festgelegt. Kunden werden über ihren Namen zusammengeführt.',
'info') }}
</div>
<div class="card__footer modal__footer--split">
<button class="btn btn--sm btn--ghost" type="submit"
formaction="{{ url_for('backup.import_discard') }}">Verwerfen</button>
<button class="btn btn--primary" type="submit"
data-confirm="Ausgewaehlte Kategorien jetzt uebernehmen?"
data-confirm-title="Sicherung uebernehmen" data-confirm-ok="Uebernehmen"
data-confirm-style="primary">Uebernehmen</button>
data-confirm="Ausgewählte Kategorien jetzt übernehmen?"
data-confirm-title="Sicherung übernehmen" data-confirm-ok="Übernehmen"
data-confirm-style="primary">Übernehmen</button>
</div>
</form>
</section>
@@ -108,7 +108,7 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Sicherung einspielen</div>
<div class="card__sub">Erst oeffnen und pruefen, dann uebernehmen</div>
<div class="card__sub">Erst öffnen und prüfen, dann übernehmen</div>
</div>
</div>
<form method="post" enctype="multipart/form-data" data-no-guard
@@ -120,8 +120,8 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
autocomplete='off') }}
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit" data-busy-label="Oeffne ...">
Datei oeffnen
<button class="btn btn--primary" type="submit" data-busy-label="Öffne ...">
Datei öffnen
</button>
</div>
</form>
@@ -70,7 +70,7 @@
{% if licenses %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Lizenz</th><th>Typ</th><th>Zustand</th><th>Laeuft ab</th><th>Host</th></tr></thead>
<thead><tr><th>Lizenz</th><th>Typ</th><th>Zustand</th><th>Läuft ab</th><th>Host</th></tr></thead>
<tbody>
{% for entry in licenses %}
<tr>
@@ -119,7 +119,7 @@
</div>
<div class="card__body stack">
{{ alert('Diese Daten verwaltet der Sub-Lizenzserver selbst. Sie sind hier rein
informativ und werden bei jedem Bericht vollstaendig ersetzt.', 'info') }}
informativ und werden bei jedem Bericht vollständig ersetzt.', 'info') }}
{% for entry in reported.customers %}
<details class="card">
<summary class="card__header" style="cursor:pointer">
@@ -193,7 +193,7 @@
<div class="divider"></div>
<dl class="kv fs-xs">
<dt>Angelegt</dt><dd>{{ customer.created_at | dt }} von {{ customer.created_by or '-' }}</dd>
<dt>Geaendert</dt><dd>{{ customer.updated_at | dt }}</dd>
<dt>Geändert</dt><dd>{{ customer.updated_at | dt }}</dd>
<dt>Portal-Zugang</dt>
<dd><span class="mono break-all">{{ portal_url }}</span>
<button class="btn btn--sm btn--ghost" type="button" data-copy="{{ portal_url }}">
@@ -205,9 +205,9 @@
{% if current_user.has('customers.delete') %}
<button class="btn btn--sm btn--ghost danger-text" type="submit"
formaction="{{ url_for('customers.delete', customer_id=customer.id) }}"
data-confirm="Kunde {{ customer.name }} loeschen? Das ist nur moeglich, solange kein Ticket mehr an ihm haengt."
data-confirm-title="Kunde loeschen" data-confirm-ok="Loeschen">
Loeschen
data-confirm="Kunde {{ customer.name }} löschen? Das ist nur möglich, solange kein Ticket mehr an ihm hängt."
data-confirm-title="Kunde löschen" data-confirm-ok="Löschen">
Löschen
</button>
{% else %}<span></span>{% endif %}
<button class="btn btn--primary" type="submit">Speichern</button>
@@ -7,7 +7,7 @@
{{ field('name', 'Firmenname', value=customer.name if customer else '', required=True) }}
{{ field('contact_email', 'E-Mail', value=customer.contact_email if customer else '',
type='email', required=not customer,
hint_text='Pflicht bei der Neuanlage -- ueber diese Adresse laeuft das Kundenportal.') }}
hint_text='Pflicht bei der Neuanlage -- über diese Adresse läuft das Kundenportal.') }}
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person if customer else '') }}
{{ field('contact_phone', 'Telefon', value=customer.contact_phone if customer else '') }}
{{ field('street', 'Strasse', value=customer.street if customer else '') }}
@@ -72,7 +72,7 @@
{% block dialogs %}
{% if current_user.has('customers.create') %}
{% call modal('customer-new', 'Kunde anlegen', 'Adresse in Einzelfeldern -- fuer Rechnungen weiterverwendbar', 'wide') %}
{% call modal('customer-new', 'Kunde anlegen', 'Adresse in Einzelfeldern -- für Rechnungen weiterverwendbar', 'wide') %}
<form method="post" action="{{ url_for('customers.create') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">{{ customer_form() }}</div>
@@ -1,7 +1,7 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import pill, empty_state, alert, stat %}
{% block page_title %}Uebersicht{% endblock %}
{% block page_title %}Übersicht{% endblock %}
{% block page_sub %}
{% if summary %}
{{ summary.counts.customers }} Kunden &middot; {{ summary.counts.licenses_active }} aktive Lizenzen
@@ -24,13 +24,13 @@
</div>
{% if summary.counts.tickets_blocked %}
{{ alert(summary.counts.tickets_blocked ~ ' Ticket(s) sind gesperrt -- fuer diese Kunden
{{ alert(summary.counts.tickets_blocked ~ ' Ticket(s) sind gesperrt -- für diese Kunden
lassen sich keine neuen Lizenzen ausstellen.', 'warning') }}
{% endif %}
{% for key, label, kind, hint in [
('red', 'Kritisch', 'danger', 'Weniger als 30 Tage Restlaufzeit oder bereits abgelaufen'),
('orange', 'Bald faellig', 'warning', '30 bis 90 Tage Restlaufzeit'),
('orange', 'Bald fällig', 'warning', '30 bis 90 Tage Restlaufzeit'),
('green', 'In Ordnung', 'ok', 'Mehr als 90 Tage oder unbefristet')] %}
{% set entries = summary.buckets[key] %}
{% if entries %}
@@ -26,13 +26,13 @@
{% block content %}
{% if state.revision_pending %}
{{ alert('Es wurde eine neue Revision signiert, die der Kunde noch nicht abgeholt hat.
Eine erreichbare Installation holt sie beim naechsten Heartbeat; eine Installation ohne
Verbindung braucht dafuer den Offline-Weg -- der Kunde findet ihn in seinem Portal.',
Eine erreichbare Installation holt sie beim nächsten Heartbeat; eine Installation ohne
Verbindung braucht dafür den Offline-Weg -- der Kunde findet ihn in seinem Portal.',
'warning', 'Revision ausstehend') }}
{% endif %}
{% if license.status == 'revoked' %}
{{ alert('Diese Lizenz ist widerrufen. Der Client erfaehrt das beim naechsten Heartbeat und
{{ alert('Diese Lizenz ist widerrufen. Der Client erfährt das beim nächsten Heartbeat und
schaltet seine lizenzpflichtigen Funktionen ab.' ~ ((' Grund: ' ~ license.revoke_reason) if
license.revoke_reason else ''), 'danger', 'Widerrufen') }}
{% endif %}
@@ -72,7 +72,7 @@
{% else %}<span class="faint">keine</span>{% endif %}
</dd>
<dt>Ausgestellt</dt><dd>{{ license.issued_at | dt }} von {{ license.created_by or '-' }}</dd>
<dt>Laeuft ab</dt>
<dt>Läuft ab</dt>
<dd>
{% if license.lifetime %}{{ pill('unbefristet', 'online') }}
{% else %}
@@ -105,9 +105,9 @@
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('refresh', size=17) }} Nachtraeglich anpassen</div>
<div class="card__title">{{ icon('refresh', size=17) }} Nachträglich anpassen</div>
<div class="card__sub">
Signiert eine neue Revision. Der Client erhaelt sie beim naechsten Heartbeat --
Signiert eine neue Revision. Der Client erhält sie beim nächsten Heartbeat --
ohne erneute Aktivierung.
</div>
</div>
@@ -126,9 +126,9 @@
</div>
</fieldset>
{% endif %}
{{ field('extend_days', 'Laufzeit verlaengern (Tage)', type='number',
{{ field('extend_days', 'Laufzeit verlängern (Tage)', type='number',
placeholder='0',
hint_text='Wird auf das aktuelle Ablaufdatum aufgeschlagen.') }}
hint_text='Wird auf das aktülle Ablaufdatum aufgeschlagen.') }}
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Neue Revision signieren</button>
@@ -141,13 +141,13 @@
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('transfer', size=17) }} Zustellungen</div>
<div class="card__sub">{{ deliveries | length }} Vorgaenge</div>
<div class="card__sub">{{ deliveries | length }} Vorgänge</div>
</div>
</div>
{% if deliveries %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Zeit</th><th>Weg</th><th>Empfaenger</th><th>Durch</th><th>Detail</th></tr></thead>
<thead><tr><th>Zeit</th><th>Weg</th><th>Empfänger</th><th>Durch</th><th>Detail</th></tr></thead>
<tbody>
{% for entry in deliveries %}
<tr>
@@ -170,7 +170,7 @@
<summary class="card__header" style="cursor:pointer">
<div class="flex-1">
<div class="card__title">{{ icon('file-text', size=17) }} Signiertes Dokument</div>
<div class="card__sub">Genau so, wie es signiert wurde -- jede Aenderung bricht die Signatur</div>
<div class="card__sub">Genau so, wie es signiert wurde -- jede Änderung bricht die Signatur</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
@@ -185,7 +185,7 @@
<div class="flex-1">
<div class="card__title">{{ icon('mail', size=17) }} Per E-Mail versenden</div>
<div class="card__sub">
{% if mail_ready %}Ueber Microsoft Graph{% else %}Nicht konfiguriert{% endif %}
{% if mail_ready %}Über Microsoft Graph{% else %}Nicht konfiguriert{% endif %}
</div>
</div>
</div>
@@ -193,7 +193,7 @@
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
{{ field('recipient', 'Empfaenger', value=license.contact_email, type='email',
{{ field('recipient', 'Empfänger', value=license.contact_email, type='email',
disabled=not mail_ready) }}
</div>
<div class="card__footer">
@@ -215,7 +215,7 @@
data-dialog-open="revoke-dialog">Widerrufen</button>
<p class="fs-xs faint">
Das Ticket bleibt offen -- eine neue Lizenz kann sofort ausgestellt werden. Genau so
laeuft ein Systemwechsel.
läuft ein Systemwechsel.
</p>
{% endif %}
{% if current_user.has('licenses.delete') %}
@@ -225,9 +225,9 @@
<button class="btn btn--sm btn--ghost danger-text btn--block" type="submit"
{% if license.status not in ('revoked', 'issued') %}disabled
title="Eine aktive Lizenz muss erst widerrufen werden"{% endif %}
data-confirm="Lizenz endgueltig aus der Datenbank entfernen? Die Historie geht verloren."
data-confirm-title="Lizenz loeschen" data-confirm-ok="Loeschen">
Endgueltig loeschen
data-confirm="Lizenz endgültig aus der Datenbank entfernen? Die Historie geht verloren."
data-confirm-title="Lizenz löschen" data-confirm-ok="Löschen">
Endgültig löschen
</button>
</form>
{% endif %}
@@ -244,7 +244,7 @@
<form method="post" action="{{ url_for('licenses.revoke', license_id=license.license_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">
{{ alert('Der Client erfaehrt den Widerruf beim naechsten Heartbeat und schaltet
{{ alert('Der Client erfährt den Widerruf beim nächsten Heartbeat und schaltet
lizenzpflichtige Funktionen ab. Das Ticket bleibt offen.', 'warning') }}
{{ field('reason', 'Grund (erscheint im Protokoll)', placeholder='Systemwechsel') }}
</div>
@@ -2,12 +2,12 @@
{% from "tesm_core/macros.html" import alert %}
{% block page_title %}Offline-Aktivierung{% endblock %}
{% block page_sub %}Code des Kunden verarbeiten und Antwortcode zurueckgeben{% endblock %}
{% block page_sub %}Code des Kunden verarbeiten und Antwortcode zurückgeben{% endblock %}
{% block content %}
{{ alert('Beide Codes sind signierte Protokollobjekte -- inhaltlich identisch mit dem
Online-Weg, nur als kopierbarer Text. Die Pruefungen (Signatur, Frische, Nonce, Fingerprint)
laufen exakt gleich; es gibt hier keine Abkuerzung.', 'info') }}
Online-Weg, nur als kopierbarer Text. Die Prüfungen (Signatur, Frische, Nonce, Fingerprint)
laufen exakt gleich; es gibt hier keine Abkürzung.', 'info') }}
<div class="grid grid--split">
<section class="card">
@@ -20,8 +20,8 @@ laufen exakt gleich; es gibt hier keine Abkuerzung.', 'info') }}
<label class="field">
<span class="field__label">Anfragecode</span>
<textarea class="textarea input--mono" name="code" rows="8" required
placeholder="Vom Kunden erhaltenen Code einfuegen ..."></textarea>
<span class="field__hint">Umbrueche und Leerzeichen sind erlaubt.</span>
placeholder="Vom Kunden erhaltenen Code einfügen ..."></textarea>
<span class="field__hint">Umbrüche und Leerzeichen sind erlaubt.</span>
</label>
</div>
<div class="card__footer">
@@ -5,14 +5,14 @@
{% block page_sub %}
{% if state and state.valid and state.activated %}
aktiv &middot; Rolle {{ role_labels.get(state.role, state.role) }}
{% elif state and state.valid %}gueltig, nicht aktiviert
{% elif state and state.present %}ungueltig
{% elif state and state.valid %}gültig, nicht aktiviert
{% elif state and state.present %}ungültig
{% else %}keine Lizenz installiert{% endif %}
{% endblock %}
{% block topbar_extra %}
<a class="btn btn--sm" href="{{ url_for('own_license.signing_key_info') }}">
{{ icon('key', size=15) }} Signaturschluessel
{{ icon('key', size=15) }} Signaturschlüssel
</a>
{% endblock %}
@@ -23,14 +23,14 @@
{% if state and state.error %}{{ alert(state.error, 'danger') }}{% endif %}
{% if state and state.role == 'master' %}
{{ alert('Diese Instanz ist die Wurzelinstanz. Nur sie darf Lizenzen fuer
weitere Lizenzserver ausstellen -- diese Grenze haengt an der signierten Rolle dieser
{{ alert('Diese Instanz ist die Wurzelinstanz. Nur sie darf Lizenzen für
weitere Lizenzserver ausstellen -- diese Grenze hängt an der signierten Rolle dieser
Lizenzdatei, nicht an einem Gruppenrecht. Ein lokaler Administrator eines Sub-Lizenzservers
kann sie sich damit nicht selbst einraeumen.', 'info', 'Rolle: Master') }}
kann sie sich damit nicht selbst einräumen.', 'info', 'Rolle: Master') }}
{% elif state and state.role == 'license_server' %}
{{ alert('Diese Instanz ist ein Sub-Lizenzserver. Sie darf eigene Kunden,
Tickets und Lizenzen verwalten -- aber keine weiteren Lizenzserver-Lizenzen ausstellen. Der
eigene Bestand wird automatisch an den uebergeordneten Master gemeldet.', 'info',
eigene Bestand wird automatisch an den übergeordneten Master gemeldet.', 'info',
'Rolle: Lizenzserver') }}
{% endif %}
@@ -57,7 +57,7 @@
<dt>Inhaber</dt><dd>{{ state.customer_name or '&ndash;' | safe }}</dd>
<dt>Lizenz-ID</dt><dd class="mono fs-xs break-all">{{ state.status.license_id }}</dd>
<dt>Revision</dt><dd>{{ state.status.seq }}</dd>
<dt>Laeuft ab</dt>
<dt>Läuft ab</dt>
<dd>
{% if state.status.lifetime %}{{ pill('unbefristet', 'online') }}
{% else %}
@@ -66,7 +66,7 @@
{{ pill('abgelaufen', 'danger') }}
{% if state.status.grace_active %}
<div class="fs-xs warning-text">
Kulanzfrist laeuft ({{ grace_days }} Tage).
Kulanzfrist läuft ({{ grace_days }} Tage).
</div>
{% endif %}
{% elif state.status.expiring_soon %}
@@ -76,7 +76,7 @@
{% endif %}
{% endif %}
</dd>
<dt>Uebergeordneter Server</dt>
<dt>Übergeordneter Server</dt>
<dd class="break-all">
{{ state.status.master_endpoint or 'keiner (Wurzelinstanz oder Offline-Betrieb)' }}
</dd>
@@ -116,7 +116,7 @@
{% endif %}
</section>
{% else %}
{{ empty_state('Keine gueltige Lizenz',
{{ empty_state('Keine gültige Lizenz',
'Spielen Sie eine Lizenzdatei ein oder erzeugen Sie mit "tesm-license-admin bootstrap"
die eigene Lizenz der Wurzelinstanz.', 'key') }}
{% endif %}
@@ -133,12 +133,12 @@
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<label class="field">
<span class="field__label">Antwortcode des uebergeordneten Servers</span>
<span class="field__label">Antwortcode des übergeordneten Servers</span>
<textarea class="textarea input--mono" name="code" rows="4" required></textarea>
</label>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Antwort uebernehmen</button>
<button class="btn btn--primary" type="submit">Antwort übernehmen</button>
</div>
</form>
</section>
@@ -150,7 +150,7 @@
<div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Lizenzdatei einspielen</div>
<div class="card__sub">
Fuer einen Sub-Lizenzserver: die Datei, die der Master ausgestellt hat
Für einen Sub-Lizenzserver: die Datei, die der Master ausgestellt hat
</div>
</div>
</div>
@@ -160,7 +160,7 @@
<div class="card__body stack">
<input class="input" type="file" name="license_file" accept=".json,.lic" required>
{% if trusted_keys and current_user.is_admin %}
{{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdruecklich zulassen') }}
{{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdrücklich zulassen') }}
{% endif %}
</div>
<div class="card__footer">
@@ -182,7 +182,7 @@
<dl class="kv fs-sm">
<dt>Hostname</dt><dd class="mono">{{ hostname }}</dd>
<dt>Fingerprint</dt><dd class="mono fs-xs break-all">{{ fingerprint }}</dd>
<dt>Signaturschluessel</dt>
<dt>Signaturschlüssel</dt>
<dd class="mono">{{ signing_key_health.get('key_id') or 'fehlt' }}</dd>
</dl>
</div>
@@ -191,8 +191,8 @@
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswuerdige Aussteller</div>
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schluessel</div>
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswürdige Aussteller</div>
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schlüssel</div>
</div>
</div>
<div class="card__body">
@@ -200,7 +200,7 @@
<dl class="kv fs-sm">
{% for key in trusted_keys %}
<dt class="mono">{{ key.key_id }}</dt>
<dd>{{ key.label }} <span class="faint">({{ key.source }}, {{ key.added_at | date }})</span></dd>
<dd>{{ key.label }} <span class="faint">({{ key.source | issuer_source }}, {{ key.added_at | date }})</span></dd>
{% endfor %}
</dl>
{% else %}
@@ -19,7 +19,7 @@
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Ihre Lizenzen</div>
<div class="card__sub">{{ tickets | length }} Vertrag/Vertraege</div>
<div class="card__sub">{{ tickets | length }} Vertrag/Verträge</div>
</div>
</div>
<div class="card__body stack stack--tight">
@@ -68,7 +68,7 @@
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person) }}
{{ field('contact_phone', 'Telefon', value=customer.contact_phone) }}
{{ field('contact_email', 'E-Mail', value=customer.contact_email, type='email',
hint_text='Ueber diese Adresse laeuft der Zugang zu diesem Portal.') }}
hint_text='Über diese Adresse läuft der Zugang zu diesem Portal.') }}
{{ field('street', 'Strasse', value=customer.street) }}
{{ field('house_number', 'Hausnummer', value=customer.house_number) }}
{{ field('postal_code', 'PLZ', value=customer.postal_code) }}
@@ -6,7 +6,7 @@
{% block content %}
<p class="fs-sm muted center">
Geben Sie die bei uns hinterlegte E-Mail-Adresse ein. Sie erhalten einen Link, der
{{ minutes }} Minuten gueltig ist und sich einmal verwenden laesst.
{{ minutes }} Minuten gültig ist und sich einmal verwenden lässt.
</p>
<form method="post" class="stack" data-no-guard>
@@ -22,7 +22,7 @@
</form>
<p class="auth-footnote">
Aus Datenschutzgruenden bekommen Sie immer dieselbe Rueckmeldung -- unabhaengig davon, ob die
Aus Datenschutzgründen bekommen Sie immer dieselbe Rückmeldung -- unabhängig davon, ob die
Adresse bei uns bekannt ist.
</p>
{% endblock %}
@@ -13,7 +13,7 @@
{% block content %}
{{ alert('Diese Daten verwaltet der Sub-Lizenzserver selbst. Sie sind hier rein informativ und
werden bei jedem Bericht vollstaendig ersetzt.', 'info') }}
werden bei jedem Bericht vollständig ersetzt.', 'info') }}
{% if reported.customers %}
<div class="stack">
@@ -71,7 +71,7 @@ werden bei jedem Bericht vollstaendig ersetzt.', 'info') }}
</div>
{% else %}
{{ empty_state('Noch kein Bericht',
'Dieser Lizenzserver hat noch nichts gemeldet. Der erste Bericht kommt mit dem naechsten
'Dieser Lizenzserver hat noch nichts gemeldet. Der erste Bericht kommt mit dem nächsten
Heartbeat.', 'share') }}
{% endif %}
{% endblock %}
@@ -6,8 +6,8 @@
{% block content %}
{{ alert('Ein Sub-Lizenzserver meldet seinen kompletten Bestand -- huckepack auf dem
' ~ heartbeat_hours ~ '-Stunden-Heartbeat und zusaetzlich sofort nach jeder Aenderung. Jeder
Bericht ist ein vollstaendiger Schnappschuss: Geloeschtes verschwindet automatisch auch hier.',
' ~ heartbeat_hours ~ '-Stunden-Heartbeat und zusätzlich sofort nach jeder Änderung. Jeder
Bericht ist ein vollständiger Schnappschuss: Gelöschtes verschwindet automatisch auch hier.',
'info') }}
{% if sources %}
@@ -20,7 +20,7 @@
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('shield', size=17) }} Aktuelle Lizenz
{{ icon('shield', size=17) }} Aktülle Lizenz
{% if current.status == 'active' %}{{ pill('aktiv', 'online') }}
{% else %}{{ pill('bereit zum Einspielen', 'info') }}{% endif %}
</div>
@@ -34,7 +34,7 @@
</div>
<div class="card__body stack">
<dl class="kv fs-sm">
<dt>Gueltig bis</dt>
<dt>Gültig bis</dt>
<dd>{% if current.lifetime %}unbefristet{% else %}{{ current.expires_at | date }}{% endif %}</dd>
{% if current.hostname %}<dt>Installiert auf</dt><dd>{{ current.hostname }}</dd>{% endif %}
<dt>Ausgestellt</dt><dd>{{ current.issued_at | date }}</dd>
@@ -64,7 +64,7 @@
action="{{ url_for('selfservice.revoke', ticket_id=ticket.id, license_id=current.license_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--ghost danger-text btn--block" type="submit"
data-confirm="Lizenz freigeben? Ihre bisherige Installation verliert damit die lizenzpflichtigen Funktionen. Danach koennen Sie sofort eine neue Lizenz fuer Ihr neues System ausstellen."
data-confirm="Lizenz freigeben? Ihre bisherige Installation verliert damit die lizenzpflichtigen Funktionen. Danach können Sie sofort eine neue Lizenz für Ihr neues System ausstellen."
data-confirm-title="Lizenz freigeben" data-confirm-ok="Freigeben">
Lizenz freigeben (Systemwechsel)
</button>
@@ -81,7 +81,7 @@
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Noch keine Lizenz</div>
<div class="card__sub">Sie koennen Ihre Lizenz jetzt erstellen</div>
<div class="card__sub">Sie können Ihre Lizenz jetzt erstellen</div>
</div>
</div>
<div class="card__body">
@@ -101,8 +101,8 @@
<details class="card">
<summary class="card__header" style="cursor:pointer">
<div class="flex-1">
<div class="card__title">{{ icon('history', size=17) }} Frueher ausgestellt</div>
<div class="card__sub">{{ history | length }} Eintraege</div>
<div class="card__title">{{ icon('history', size=17) }} Früher ausgestellt</div>
<div class="card__sub">{{ history | length }} Einträge</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
@@ -115,7 +115,7 @@
<td class="fs-sm">{{ entry.issued_at | date }}</td>
<td class="fs-sm">
{% if entry.status == 'revoked' %}freigegeben
{% elif entry.status == 'deactivated' %}abgeloest
{% elif entry.status == 'deactivated' %}abgelöst
{% else %}{{ entry.status }}{% endif %}
</td>
<td class="fs-sm">{{ entry.hostname or '&ndash;' | safe }}</td>
@@ -9,9 +9,9 @@
<section class="card">
<div class="card__body stack">
{{ alert('In Ihrer TESM-Installation unter Verwaltung -- Lizenz auf "Lizenz aktivieren"
klicken. Erscheint dort ein Anfragecode, fuegen Sie ihn hier ein. Sie bekommen einen
Antwortcode zurueck, den Sie in TESM eintragen. Beide Codes sind signiert und lassen
sich unterwegs nicht veraendern.', 'info', 'So geht es') }}
klicken. Erscheint dort ein Anfragecode, fügen Sie ihn hier ein. Sie bekommen einen
Antwortcode zurück, den Sie in TESM eintragen. Beide Codes sind signiert und lassen
sich unterwegs nicht verändern.', 'info', 'So geht es') }}
{% if response_code %}
<div class="field">
@@ -35,7 +35,7 @@
<label class="field">
<span class="field__label">Anfragecode aus Ihrer TESM-Installation</span>
<textarea class="textarea input--mono" name="code" rows="6" required
placeholder="Code einfuegen ..."></textarea>
placeholder="Code einfügen ..."></textarea>
</label>
<div class="row row--end mt-3">
<button class="btn btn--primary" type="submit">Antwortcode erzeugen</button>
@@ -44,7 +44,7 @@
{% endif %}
<p class="fs-xs faint">
<a href="{{ url_for('selfservice.overview', ticket_id=ticket.id) }}">Zurueck zur Uebersicht</a>
<a href="{{ url_for('selfservice.overview', ticket_id=ticket.id) }}">Zurück zur Übersicht</a>
</p>
</div>
</section>
@@ -4,7 +4,7 @@
{% block page_title %}E-Mail-Versand{% endblock %}
{% block page_sub %}
Microsoft Graph (App-only) &middot;
{% if ready %}einsatzbereit{% else %}nicht vollstaendig konfiguriert{% endif %}
{% if ready %}einsatzbereit{% else %}nicht vollständig konfiguriert{% endif %}
{% endblock %}
{% block topbar_extra %}
@@ -19,8 +19,8 @@
{% endblock %}
{% block content %}
{{ alert('Der Versand laeuft ueber den Client-Credentials-Flow von Microsoft Graph -- kein SMTP,
kein zusaetzliches Paket. Die Anwendung braucht in Azure die Anwendungsberechtigung
{{ alert('Der Versand läuft über den Client-Credentials-Flow von Microsoft Graph -- kein SMTP,
kein zusätzliches Paket. Die Anwendung braucht in Azure die Anwendungsberechtigung
Mail.Send. Der Verbindungstest holt nur ein Token und verschickt
bewusst keine echte Nachricht.', 'info') }}
@@ -46,7 +46,7 @@ bewusst keine echte Nachricht.', 'info') }}
</div>
<fieldset class="fieldset">
<legend>Client-Secret {% if has_secret %}{{ pill('gesetzt', 'online') }}{% else %}{{ pill('fehlt', 'warning') }}{% endif %}</legend>
{{ field('client_secret', 'Neues Secret (leer = unveraendert)', type='password',
{{ field('client_secret', 'Neues Secret (leer = unverändert)', type='password',
autocomplete='new-password', disabled=not can_secrets) }}
{% if not can_secrets %}
<p class="fs-xs faint mt-2">
@@ -63,17 +63,17 @@ bewusst keine echte Nachricht.', 'info') }}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Wofuer der Versand genutzt wird</div></div>
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Wofür der Versand genutzt wird</div></div>
</div>
<div class="card__body">
<ul class="stack stack--tight fs-sm muted">
<li>Lizenzdatei an den Kunden senden (mit Anhang).</li>
<li>Zugangslink fuer das Kundenportal.</li>
<li>Zugangslink für das Kundenportal.</li>
</ul>
<div class="divider"></div>
<p class="fs-sm muted">
Das Secret liegt AES-256-GCM-verschluesselt in der Datenbank, gebunden an seinen
Verwendungszweck. Ohne die Schluesseldatei dieser Installation ist es nicht lesbar.
Das Secret liegt AES-256-GCM-verschlüsselt in der Datenbank, gebunden an seinen
Verwendungszweck. Ohne die Schlüsseldatei dieser Installation ist es nicht lesbar.
</p>
</div>
</section>
@@ -6,8 +6,8 @@
{% block content %}
{% if not sysops_available %}
{{ alert('Systemhelfer nicht verfuegbar -- Hostname und Zeitzone koennen hier nur angezeigt,
nicht geaendert werden.', 'info') }}
{{ alert('Systemhelfer nicht verfügbar -- Hostname und Zeitzone können hier nur angezeigt,
nicht geändert werden.', 'info') }}
{% endif %}
<div class="grid grid--split">
@@ -16,7 +16,7 @@
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('users', size=17) }} Kundenportal und Self-Service</div>
<div class="card__sub">Zwei unabhaengige Zeitfenster: Link und Sitzung</div>
<div class="card__sub">Zwei unabhängige Zeitfenster: Link und Sitzung</div>
</div>
</div>
<form method="post" action="{{ url_for('settings.system_save') }}">
@@ -24,10 +24,10 @@
<div class="card__body stack">
{{ checkbox('portal_enabled', 'Kundenportal aktiv', checked=values.portal_enabled) }}
<div class="form-grid">
{{ field('portal_link_minutes', 'Zugangslink gueltig (Minuten)',
{{ field('portal_link_minutes', 'Zugangslink gültig (Minuten)',
value=values.portal_link_minutes, type='number',
hint_text=specs['portal_link_minutes'].hint) }}
{{ field('portal_session_minutes', 'Portalsitzung gueltig (Minuten)',
{{ field('portal_session_minutes', 'Portalsitzung gültig (Minuten)',
value=values.portal_session_minutes, type='number',
hint_text=specs['portal_session_minutes'].hint) }}
</div>
@@ -35,7 +35,7 @@
{{ checkbox('self_service_enabled', 'Self-Service-Link je Ticket aktiv',
checked=values.self_service_enabled,
hint_text=specs['self_service_enabled'].hint) }}
{{ checkbox('allow_customer_revoke', 'Kunden duerfen ihre Lizenz selbst freigeben',
{{ checkbox('allow_customer_revoke', 'Kunden dürfen ihre Lizenz selbst freigeben',
checked=values.allow_customer_revoke,
hint_text=specs['allow_customer_revoke'].hint) }}
</div>
@@ -60,6 +60,9 @@
selected=values.log_rotate_interval) }}
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
type='number') }}
{{ field('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
value=values.session_idle_minutes, type='number',
hint_text=specs['session_idle_minutes'].hint) }}
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
value=values.trash_retention_days, type='number') }}
</div>
@@ -105,7 +108,7 @@
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
<div class="card__sub">Gilt fuer alle Benutzer</div>
<div class="card__sub">Gilt für alle Benutzer</div>
</div>
</div>
<form method="post" action="{{ url_for('admin.nav_order') }}">
@@ -6,7 +6,7 @@
{% block content %}
{{ alert('Diese Angaben werden in jede Lizenzdatei eingebettet und beim Kunden auf der
Lizenzseite angezeigt -- inklusive Logo. Der oeffentliche Endpunkt entscheidet zusaetzlich,
Lizenzseite angezeigt -- inklusive Logo. Der öffentliche Endpunkt entscheidet zusätzlich,
welche Basis-URL in kundengerichteten Links steht.', 'info') }}
<div class="grid grid--split">
@@ -26,18 +26,18 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
disabled=not current_user.has('settings_vendor.edit')) }}
</div>
{{ textarea_field('vendor_address', 'Adresse', value=values.vendor_address, rows=3) }}
{{ field('master_endpoint', 'Oeffentlicher Endpunkt', value=values.master_endpoint,
{{ field('master_endpoint', 'Öffentlicher Endpunkt', value=values.master_endpoint,
placeholder='https://lizenz.firma.de',
disabled=not current_user.has('settings_vendor.edit'),
hint_text='Basis-URL, unter der Kundeninstanzen diesen Server erreichen. Wird in
jede Lizenz eingebettet und fuer alle kundengerichteten Links verwendet -- statt
des Host-Headers der aktuellen Anfrage, der bei internen Aufrufen falsch waere.',
jede Lizenz eingebettet und für alle kundengerichteten Links verwendet -- statt
des Host-Headers der aktüllen Anfrage, der bei internen Aufrufen falsch wäre.',
extra_class='field--full') }}
<fieldset class="fieldset">
<legend>Logo (maximal {{ max_logo_kb }} KB)</legend>
<div class="stack stack--tight">
{% if values.vendor_logo_base64 %}
<img src="{{ values.vendor_logo_base64 }}" alt="Aktuelles Logo"
<img src="{{ values.vendor_logo_base64 }}" alt="Aktülles Logo"
style="max-height:64px;background:var(--surface-2);padding:var(--sp-2);border-radius:var(--r-sm)">
{% else %}
<p class="faint fs-sm">Kein Logo hinterlegt.</p>
@@ -45,7 +45,7 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
<input class="input" type="file" name="vendor_logo" accept="image/png,image/jpeg,image/svg+xml"
{% if not current_user.has('settings_vendor.edit') %}disabled{% endif %}>
<span class="field__hint">
Ein zu grosses Logo wird abgelehnt, die uebrigen Angaben werden trotzdem gespeichert.
Ein zu grosses Logo wird abgelehnt, die übrigen Angaben werden trotzdem gespeichert.
</span>
</div>
</fieldset>
@@ -75,7 +75,7 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
<div class="divider"></div>
<p class="fs-sm muted">
Die Angaben werden zum Zeitpunkt der Ausstellung in die Lizenz kopiert und signiert. Eine
Aenderung hier wirkt auf neue Lizenzen -- bestehende behalten ihren Stand, bis eine neue
Änderung hier wirkt auf neue Lizenzen -- bestehende behalten ihren Stand, bis eine neue
Revision signiert wird.
</p>
</div>
@@ -1,34 +1,34 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import alert %}
{% block page_title %}Signaturschluessel{% endblock %}
{% block page_sub %}Schluessel-ID {{ key_id }}{% endblock %}
{% block page_title %}Signaturschlüssel{% endblock %}
{% block page_sub %}Schlüssel-ID {{ key_id }}{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('own_license.overview') }}">Eigene Lizenz</a></span><span>Signaturschluessel</span>
<span><a href="{{ url_for('own_license.overview') }}">Eigene Lizenz</a></span><span>Signaturschlüssel</span>
</nav>
{% endblock %}
{% block content %}
{{ alert('Der oeffentliche Teil ist kein Geheimnis -- er reist in jeder ausgestellten Lizenz mit.
Ihn hier anzuzeigen erlaubt es, ihn unabhaengig zu vergleichen, bevor ein Kunde ihn als
vertrauenswuerdig hinterlegt.', 'info') }}
{{ alert('Der öffentliche Teil ist kein Geheimnis -- er reist in jeder ausgestellten Lizenz mit.
Ihn hier anzuzeigen erlaubt es, ihn unabhängig zu vergleichen, bevor ein Kunde ihn als
vertrauenswürdig hinterlegt.', 'info') }}
{% if health.problems %}
{% for problem in health.problems %}{{ alert(problem, 'danger') }}{% endfor %}
{% endif %}
<section class="card" style="max-width:760px">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('key', size=17) }} Oeffentlicher Schluessel</div>
<div class="flex-1"><div class="card__title">{{ icon('key', size=17) }} Öffentlicher Schlüssel</div>
<div class="card__sub">Erzeugt {{ created_at | dt }}</div></div>
<button class="btn btn--sm" type="button" data-copy="#pubkey">{{ icon('copy', size=14) }} Kopieren</button>
</div>
<div class="card__body stack">
<dl class="kv fs-sm">
<dt>Schluessel-ID</dt><dd class="mono">{{ key_id }}</dd>
<dt>Schlüssel-ID</dt><dd class="mono">{{ key_id }}</dd>
<dt>Verfahren</dt><dd>Ed25519 (EdDSA)</dd>
</dl>
<pre class="code-block code-block--wrap" id="pubkey">{{ public_key }}</pre>
{{ alert('Der private Teil liegt ausschliesslich auf diesem Host (Modus 0600) und ist aus dem
Deployment-Abgleich ausgeschlossen. Ginge er verloren, waere jede
ausgestellte Lizenz nicht mehr pruefbar -- bitte getrennt sichern.', 'warning') }}
Deployment-Abgleich ausgeschlossen. Ginge er verloren, wäre jede
ausgestellte Lizenz nicht mehr prüfbar -- bitte getrennt sichern.', 'warning') }}
</div>
</section>
{% endblock %}
@@ -17,11 +17,11 @@
{% block content %}
{% if endpoint_missing %}
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung moeglich') }}
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung möglich') }}
{% endif %}
{% if ticket.status == 'blocked' %}
{{ alert('Dieses Ticket ist gesperrt. Es lassen sich keine neuen Lizenzen ausstellen -- weder
hier noch ueber den Self-Service des Kunden. Bereits ausgestellte Lizenzen bleiben gueltig.',
hier noch über den Self-Service des Kunden. Bereits ausgestellte Lizenzen bleiben gültig.',
'danger', 'Gesperrt') }}
{% endif %}
@@ -58,7 +58,7 @@
<input type="hidden" name="as_trial" value="1">
<button class="btn" type="submit"
{% if has_open_license or ticket.status == 'blocked' %}disabled{% endif %}
data-confirm="Testlizenz ausstellen? {{ trial_days }} Tage Laufzeit mit allen Modulen. Das Ticket selbst bleibt unveraendert."
data-confirm="Testlizenz ausstellen? {{ trial_days }} Tage Laufzeit mit allen Modulen. Das Ticket selbst bleibt unverändert."
data-confirm-title="Testlizenz" data-confirm-ok="Ausstellen"
data-confirm-style="primary">
Als Testlizenz ({{ trial_days }} Tage, alle Module)
@@ -81,7 +81,7 @@
{% if licenses %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Lizenz</th><th>Ausgestellt</th><th>Zustand</th><th>Laeuft ab</th><th>Host</th></tr></thead>
<thead><tr><th>Lizenz</th><th>Ausgestellt</th><th>Zustand</th><th>Läuft ab</th><th>Host</th></tr></thead>
<tbody>
{% for entry in licenses %}
<tr>
@@ -115,7 +115,7 @@
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('link', size=17) }} Self-Service fuer den Kunden</div>
<div class="card__title">{{ icon('link', size=17) }} Self-Service für den Kunden</div>
<div class="card__sub">
Ohne Anmeldung nutzbar &ndash; die Berechtigung ist der Besitz dieses Links
</div>
@@ -128,7 +128,7 @@
</div>
<p class="fs-xs faint">
Damit kann der Kunde seine Lizenz herunterladen, bei einem Systemwechsel freigeben und
neu ausstellen. Der Link enthaelt 192 Bit Zufall und ist nicht ratbar -- behandeln Sie
neu ausstellen. Der Link enthält 192 Bit Zufall und ist nicht ratbar -- behandeln Sie
ihn wie ein Passwort.
</p>
<div class="divider"></div>
@@ -143,7 +143,7 @@
</div>
</section>
{% elif ticket.role != 'customer' %}
{{ alert('Fuer Lizenzserver-Tickets ist der Self-Service vollstaendig gesperrt -- diese
{{ alert('Für Lizenzserver-Tickets ist der Self-Service vollständig gesperrt -- diese
Lizenzen werden ausschliesslich hier verwaltet.', 'info') }}
{% endif %}
</div>
@@ -158,8 +158,8 @@
<form method="post" action="{{ url_for('tickets.update', ticket_id=ticket.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ alert('Aenderungen wirken auf die naechste Ausstellung. Kunde und Rolle sind
unveraenderlich.', 'info') }}
{{ alert('Änderungen wirken auf die nächste Ausstellung. Kunde und Rolle sind
unveränderlich.', 'info') }}
<label class="field">
<span class="field__label">Lizenztyp</span>
<select class="select" name="type" id="ticket-type"
@@ -206,7 +206,7 @@
{% if current_user.has('tickets.edit') or current_user.has('tickets.delete') %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Sperre und Loeschung</div></div>
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Sperre und Löschung</div></div>
</div>
<div class="card__body stack stack--tight">
{% if current_user.has('tickets.edit') %}
@@ -216,7 +216,7 @@
<button class="btn btn--sm btn--block {{ 'btn--danger' if ticket.status != 'blocked' }}"
type="submit"
data-confirm="{% if ticket.status == 'blocked' %}Ticket entsperren?{% else %}Ticket sperren? Der Kunde kann danach keine neuen Lizenzen mehr ausstellen.{% endif %}"
data-confirm-title="Ticketsperre" data-confirm-ok="Bestaetigen"
data-confirm-title="Ticketsperre" data-confirm-ok="Bestätigen"
data-confirm-style="{{ 'primary' if ticket.status == 'blocked' else 'danger' }}">
{{ 'Entsperren' if ticket.status == 'blocked' else 'Sperren' }}
</button>
@@ -228,9 +228,9 @@
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text btn--block" type="submit"
{% if has_open_license %}disabled title="Erst die offene Lizenz widerrufen"{% endif %}
data-confirm="Ticket und seine gesamte Lizenzhistorie loeschen? Das laesst sich nicht rueckgaengig machen."
data-confirm-title="Ticket loeschen" data-confirm-ok="Loeschen">
Ticket loeschen
data-confirm="Ticket und seine gesamte Lizenzhistorie löschen? Das lässt sich nicht rückgängig machen."
data-confirm-title="Ticket löschen" data-confirm-ok="Löschen">
Ticket löschen
</button>
</form>
{% endif %}
@@ -23,7 +23,7 @@
<label class="field">
<span class="field__label">Kunde <span class="req">*</span></span>
<select class="select" name="customer_id" required>
<option value="">bitte waehlen</option>
<option value="">bitte wählen</option>
{% for customer in customers %}
<option value="{{ customer.id }}"
{% if preselected_customer == customer.id %}selected{% endif %}>
@@ -32,7 +32,7 @@
{% endfor %}
</select>
<span class="field__hint">
Der Kunde ist nach dem Anlegen unveraenderlich -- der Self-Service-Link ist an ihn
Der Kunde ist nach dem Anlegen unveränderlich -- der Self-Service-Link ist an ihn
gebunden.
</span>
</label>
@@ -47,13 +47,13 @@
{% endfor %}
</select>
<span class="field__hint">
Standard: Grundfunktionen. Individuell: Module frei waehlbar. Enterprise: alle
Standard: Grundfunktionen. Individüll: Module frei wählbar. Enterprise: alle
Module. Testlizenz: 30 Tage.
</span>
</label>
<fieldset class="fieldset" id="module-box">
<legend>Module (nur bei &bdquo;Individuell&ldquo;)</legend>
<legend>Module (nur bei &bdquo;Individüll&ldquo;)</legend>
<div class="stack stack--tight">
{% for module in modules %}
{{ checkbox('modules', module_labels[module], value=module) }}
@@ -65,7 +65,7 @@
{{ field('valid_days', 'Laufzeit (Tage)', value=default_valid_days, type='number') }}
</div>
{{ checkbox('lifetime', 'Unbefristet',
hint_text='Nicht kombinierbar mit einer Testlizenz -- das waere eine
hint_text='Nicht kombinierbar mit einer Testlizenz -- das wäre eine
permanente Lizenz ohne Module.') }}
{{ textarea_field('notes', 'Interne Notiz', rows=2) }}
</div>
@@ -77,15 +77,15 @@
<div class="flex-1">
<div class="card__title">{{ icon('share', size=17) }} Lizenzserver-Lizenz</div>
<div class="card__sub">
Erzeugt eine neue Vertrauensgrenze -- der Empfaenger verwaltet danach eigene Kunden
Erzeugt eine neue Vertrauensgrenze -- der Empfänger verwaltet danach eigene Kunden
</div>
</div>
</div>
<div class="card__body stack">
{{ alert('Ein so lizenzierter Lizenzserver darf alles, was ein Lizenzserver koennen
{{ alert('Ein so lizenzierter Lizenzserver darf alles, was ein Lizenzserver können
soll -- ausser selbst weitere Lizenzserver-Lizenzen auszustellen. Er meldet seinen
Bestand automatisch hierher zurueck. Module sind fuer diese Rolle bedeutungslos und
werden ignoriert. Self-Service und Kundenportal sind fuer solche Tickets gesperrt.',
Bestand automatisch hierher zurück. Module sind für diese Rolle bedeutungslos und
werden ignoriert. Self-Service und Kundenportal sind für solche Tickets gesperrt.',
'warning') }}
<label class="check">
<input type="checkbox" name="role" value="license_server">
@@ -17,11 +17,11 @@
{% block content %}
{% if endpoint_missing %}
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung moeglich') }}
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung möglich') }}
{% endif %}
{{ alert('Ein Ticket beschreibt den Anspruch (Typ, Module, Laufzeit); die Lizenz ist die daraus
erzeugte, signierte Datei. Aenderungen am Ticket wirken auf die naechste
Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.', 'info') }}
erzeugte, signierte Datei. Änderungen am Ticket wirken auf die nächste
Ausstellung -- bereits ausgestellte Lizenzen bleiben unverändert.', 'info') }}
<section class="card">
<div class="table-wrap">
@@ -85,7 +85,7 @@ Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.', 'info') }}
</table>
</div>
{% if not tickets %}
{{ empty_state('Keine Tickets', 'Legen Sie das erste Ticket fuer einen Kunden an.', 'ticket') }}
{{ empty_state('Keine Tickets', 'Legen Sie das erste Ticket für einen Kunden an.', 'ticket') }}
{% endif %}
</section>
{% endblock %}
+16 -1
View File
@@ -112,6 +112,21 @@ MONITOR_UNIT="$NAME-monitor.service"
NGINX_SITE="/etc/nginx/sites-available/$NAME"
ENV_PREFIX="TESM_"
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
# Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst
# verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz
# faellt auf 8080/8443 zurueck, auch wenn sie seit der Installation auf Port 80
# lief -- und ein Reverse Proxy davor zeigt dann ins Leere. Dieselbe Regel wie
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
# nicht ausdruecklich neu gesetzt wird.
ENV_FILE="/etc/tesm/$NAME.env"
if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then
PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)"
fi
if [[ -f "$NGINX_SITE" ]]; then
[[ -z "$HTTP_PORT" ]] && HTTP_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\);.*/\1/p' "$NGINX_SITE" | head -1)"
[[ -z "$HTTPS_PORT" ]] && HTTPS_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\)[[:space:]]\+ssl.*/\1/p' "$NGINX_SITE" | head -1)"
fi
if [[ -z "$PORT" ]]; then
PORT=5000
[[ "$APP" == "tesm-license" ]] && PORT=5001
@@ -134,7 +149,7 @@ fi
# Browser sendet das Cookie dann weiter nur ueber HTTPS, die Anwendung setzt es
# aber ohne das Flag neu, und beide reden aneinander vorbei.
COOKIE_SECURE=0
if [[ -f "/etc/tesm/$NAME.env" ]] && grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "/etc/tesm/$NAME.env"; then
if [[ -f "$ENV_FILE" ]] && grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "$ENV_FILE"; then
COOKIE_SECURE=1
fi
[[ $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
MemoryDenyWriteExecute=no
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
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
# 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
# (/proc/<pid>/environ).
read_stdin_limited > "$creds"
# Der aufrufende Dienst laeuft in einem eigenen Mount-Namensraum
# (ProtectSystem, PrivateTmp, ReadWritePaths). Ein Einbinden *darin*
# scheitert mit "mount error(1): Operation not permitted" -- gemessen: mit
# denselben Optionen und denselben Zugangsdaten gelingt es aus einer
# normalen Root-Shell sofort. Deshalb wird im Namensraum des Wirts
# eingebunden; von dort wird der Mount in den Namensraum des Dienstes
# weitergegeben (er haengt als "slave" darunter), die Anwendung sieht ihn
# also. Die Zugangsdatei unter /run ist ueber die Grenze lesbar, /run ist
# nicht privatisiert.
im_wirt=(nsenter --mount=/proc/1/ns/mnt --)
command -v nsenter >/dev/null 2>&1 || im_wirt=()
"${im_wirt[@]}" mkdir -p "$target"
if "${im_wirt[@]}" mount -t cifs "$share" "$target" \
# Eingebunden wird im Mount-Namensraum des aufrufenden Dienstes -- absichtlich:
# nur dieser Dienst liest die Dateien, und mit ihm verschwinden die
# Einbindungen wieder. Damit der Syscall durchkommt, braucht die Unit
# "@mount" im SystemCallFilter. Ohne das scheitert es mit
# "mount error(1): Operation not permitted"; gemessen war der Syscall-Filter
# die Ursache, nicht der Namensraum. Ein Umweg ueber nsenter in den
# Namensraum des Wirts ist keine Option -- "RestrictNamespaces=yes"
# verbietet setns, und das Recht dafuer wieder aufzugeben waere teurer als
# der Gewinn.
mkdir -p "$target"
if mount -t cifs "$share" "$target" \
-o "credentials=$creds,uid=$(id -u tesm 2>/dev/null || echo 0),gid=$(id -g tesm 2>/dev/null || echo 0),file_mode=0640,dir_mode=0750,vers=3.1.1,seal"
then
shred -u "$creds" 2>/dev/null || rm -f "$creds"
@@ -282,13 +280,12 @@ case "$verb" in
umount)
check_mount_target "${1:?Ziel fehlt}"
# Gegenstueck zu mount-cifs: der Mount liegt im Namensraum des Wirts, also
# muss er auch dort geloest werden. Ein umount im Dienst-Namensraum wuerde
# ihn nur dort ausblenden und die Freigabe bliebe eingebunden.
im_wirt=(nsenter --mount=/proc/1/ns/mnt --)
command -v nsenter >/dev/null 2>&1 || im_wirt=()
"${im_wirt[@]}" umount -l "$1" || true
"${im_wirt[@]}" rmdir "$1" 2>/dev/null || true
# Gegenstueck zu mount-cifs, und deshalb im selben Namensraum: loesen kann
# nur, wer die Einbindung auch sieht. Ein Aufruf aus einem anderen Dienst
# greift ins Leere -- genau deshalb laeuft die Aufraeumaufgabe fuer
# abgelaufene Einbindungen im Webprozess und nicht im Ueberwachungsdienst.
umount -l "$1" || true
rmdir "$1" 2>/dev/null || true
;;
apt-install)
@@ -296,9 +293,27 @@ case "$verb" in
for package in "$@"; do
check_in_list "$package" "${PACKAGES_ALLOWED[@]}" || fail "Paket nicht erlaubt: $package"
done
# apt laeuft ausserhalb des Sandkastens des aufrufenden Dienstes. Der wird
# sonst an dpkg und dessen Maintainer-Skripte weitervererbt, und dort
# scheitert etwas voellig Beilaeufiges: mit "ProtectKernelLogs=yes" gibt
# "dmesg" ein EPERM zurueck, ein Postinst-Skript bricht daran ab, und dpkg
# endet mit Code 1 -- die Installation schlaegt fehl, obwohl mit dem Paket
# alles in Ordnung ist. Gemessen und mit systemd-run reproduzierbar
# behoben: PID 1 startet den Prozess, also ohne unsere Haertung.
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq "$@"
if command -v systemd-run >/dev/null 2>&1 && [[ -S /run/dbus/system_bus_socket ]]; then
# Zwei getrennte Aufrufe, damit keine Shell dazwischen steht: die
# Paketnamen gehen als eigene Argumente an apt-get, nicht durch eine
# Kommandozeile, die sie erst wieder zerlegen muesste.
ausserhalb=(systemd-run --wait --pipe --collect --quiet
--setenv=DEBIAN_FRONTEND=noninteractive)
"${ausserhalb[@]}" apt-get update -qq
"${ausserhalb[@]}" apt-get install -y -qq "$@"
else
# Kein systemd (Container, Entwicklungsrechner): direkt, wie bisher.
apt-get update -qq
apt-get install -y -qq "$@"
fi
;;
certbot-issue)
+41 -8
View File
@@ -12,17 +12,47 @@
# =============================================================================
set -euo pipefail
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel
# unbrauchbar ("bad interpreter"). Die Pruefung darf dabei nicht selbst
# von einem Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken
# Server fehlt -- endete sie in "command not found", grep bekam nichts zu
# sehen, und das Skript machte weiter, als waere alles in Ordnung.
hat_crlf() {
local cr
cr="$(printf '\r')"
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer Windows-Buildmaschine
# aber entscheidend: dort entfernt grep den Wagenruecklauf beim Lesen und
# findet deshalb nie einen.
LC_ALL=C grep -qU "$cr" "$1"
}
APP="${1:-}"
TAG="${2:-latest}"
BASE="${TESM_RELEASE_BASE:-https://gitea.example.invalid}"
OWNER="${TESM_RELEASE_OWNER:-wis}"
REPO="${TESM_RELEASE_REPO:-tesm}"
# Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
# sich eine benannte Instanz nicht aktualisieren: install.sh leitet den Namen
# aus "--instance" ab, und ohne den Schalter entstuende neben "tesm-opus" eine
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
# nginx-Site und demselben Port.
shift $(( $# > 2 ? 2 : $# ))
EXTRA=("$@")
# Bezugsquelle. Die Vorgaben zeigen dorthin, wo die Releases tatsaechlich
# liegen -- ein Platzhalter wie "gitea.example.invalid" macht das Skript auf
# jedem Host unbrauchbar, und genau das war hier der Fall.
#
# Das Repository heisst wie die Anwendung; getrennte Repositorien, getrennte
# Releases. Der Anhang heisst "<repo>-<tag>.tar.gz", siehe unten.
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
OWNER="${TESM_RELEASE_OWNER:-alientim}"
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
echo "Verwendung: update.sh <tesm|tesm-license> [tag]" >&2
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
echo "Beispiel : update.sh tesm latest --instance opus" >&2
exit 2
}
REPO="${TESM_RELEASE_REPO:-$APP}"
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
@@ -39,18 +69,21 @@ curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
echo "==> Entpacke ..."
tar -xzf "$WORK/$ASSET" -C "$WORK"
SRC="$(find "$WORK" -maxdepth 2 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)"
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
SRC="$(find "$WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)"
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; }
SRC="$(cd "$SRC" && pwd)"
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
if 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 "'git -c core.autocrlf=false archive'." >&2
exit 1
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
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"}
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env bash
# =============================================================================
# Upgrade vom Stand SONNET5 (Version 1.x) auf diese Fassung -- mit Datenuebernahme.
#
# Aufruf (aus dem entpackten Release-Verzeichnis):
# sudo bash deploy/upgrade.sh --app tesm
# sudo bash deploy/upgrade.sh --app tesm-license
# sudo bash deploy/upgrade.sh --app tesm --instance opus --source /srv/tesm
#
# Der Ablauf ist die Vorgabe und braucht keine Schalter:
#
# 1. **Sichern.** Vollstaendige Kopie der alten Installation nach
# /srv/<name>-upgrade-<zeitstempel>/alt. Erst danach wird irgendetwas
# angefasst -- und aus dieser Kopie stammen auch alle weiteren Schritte.
# 2. **Altes System abschalten.** Dienst und Timer werden gestoppt und
# deaktiviert, damit sie nach einem Neustart nicht zurueckkommen.
# 3. **Neu installieren.** install.sh dieses Verzeichnisses.
# 4. **Exportieren.** Aus der Kopie entsteht eine Exportdatei im **alten**
# Format -- genau die, die der Vorgaenger selbst geschrieben haette.
# 5. **Uebersetzen.** Daraus eine Datei im neuen Format.
# 6. **Einspielen.** Diese Datei geht durch denselben Import wie eine
# Sicherung.
#
# Warum die Sicherung vor dem Export steht und nicht danach: laeuft das
# Upgrade **in dasselbe Verzeichnis** (also ohne --instance), ueberschreibt
# install.sh die alte Installation. Wer erst exportiert und dann sichert,
# sichert im schlechtesten Fall nichts mehr. Gelesen wird ausschliesslich aus
# der Kopie -- das Ergebnis ist dasselbe, die Reihenfolge ist die sichere.
#
# Der Lizenzserver kennt Schritt 4 und 5 nicht: seine Uebernahme liest die alte
# Datenbank unmittelbar und **signiert jede Lizenz neu**. Dabei wird der alte
# Signaturschluessel uebernommen -- sonst muesste jeder Kunde einen
# Ausstellerwechsel bestaetigen. Wer das nicht will: --new-key. Dort ist die
# Kopie aus Schritt 1 der Export.
# =============================================================================
set -euo pipefail
APP=""
INSTANCE=""
SOURCE=""
PASSPHRASE=""
ASSUME_YES=0
DRY_RUN=0
WITH_DATA=1
STOP_OLD=1
ADOPT_KEY=1
EXTRA=()
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
info() { printf ' %s\n' "$*"; }
usage() {
cat <<'USAGE'
Verwendung: upgrade.sh --app <tesm|tesm-license> [Optionen]
--app <name> Welche Anwendung (Pflicht)
--source <pfad> Verzeichnis der alten Installation
(Vorgabe: /srv/tesm bzw. /srv/tesm-license)
--instance <name> Neue Installation als benannte Instanz anlegen, statt
das alte Verzeichnis zu ueberschreiben. Damit bleibt die
alte Installation liegen und ist zurueckholbar.
--passphrase <text> Passphrase der Zwischendateien. Ohne Angabe wird eine
erzeugt und in der Sicherung abgelegt.
--no-data Nur installieren, keine Datenuebernahme
--keep-old-running Alten Dienst nicht stoppen (nur mit --instance sinnvoll)
--new-key Nur Lizenzserver: einen neuen Signaturschluessel
erzeugen, statt den alten zu uebernehmen. Dann muessen
alle Kunden den Ausstellerwechsel bestaetigen -- die
Vorgabe ist deshalb die Uebernahme.
--dry-run Nur zeigen, was geschehen wuerde
--yes Keine Rueckfrage
-h, --help Diese Hilfe
Alles weitere wird unveraendert an install.sh weitergegeben, etwa --https
oder --domain.
Beispiele:
# In dasselbe Verzeichnis, alte Installation wird ersetzt:
sudo bash deploy/upgrade.sh --app tesm
# Daneben, zum Vergleich, alte Installation bleibt unangetastet:
sudo bash deploy/upgrade.sh --app tesm --instance opus --keep-old-running
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--app) APP="${2:?}"; shift 2 ;;
--source) SOURCE="${2:?}"; shift 2 ;;
--instance) INSTANCE="${2:?}"; shift 2 ;;
--passphrase) PASSPHRASE="${2:?}"; shift 2 ;;
--no-data) WITH_DATA=0; shift ;;
--keep-old-running) STOP_OLD=0; shift ;;
--new-key) ADOPT_KEY=0; shift ;;
--adopt-key) ADOPT_KEY=1; shift ;; # Vorgabe, hier nur zur Deutlichkeit
--dry-run) DRY_RUN=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) EXTRA+=("$1"); shift ;;
esac
done
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
[[ -f "$SCRIPT_DIR/install.sh" ]] || fail "install.sh fehlt neben diesem Skript"
[[ -n "$SOURCE" ]] || SOURCE="/srv/$APP"
[[ -d "$SOURCE" ]] || fail "Die alte Installation $SOURCE gibt es nicht (--source setzen)"
[[ -f "$SOURCE/sqlite.db" ]] || fail "In $SOURCE liegt keine sqlite.db -- das ist keine Installation der Vorgaengerfassung"
NAME="$APP"
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
ADMIN="$NAME-admin"
NEW_ROOT="/srv/$NAME"
IN_PLACE=0
[[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1
STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP="/srv/$NAME-upgrade-$STAMP"
ALT_KOPIE="$BACKUP/alt"
EXPORT_ALT="$BACKUP/export-alt.json"
IMPORT_NEU="$BACKUP/import-neu.json"
# Alte Einheiten der Vorgaengerfassung. Der Timer gehoert dazu: nur den Dienst
# zu stoppen liesse ihn beim naechsten Ablauf wieder anlaufen.
if [[ "$APP" == "tesm" ]]; then
ALTE_UNITS=(tesm.service tesm-check.timer tesm-check.service
tesm-check-restart.timer tesm-check-restart.service)
else
ALTE_UNITS=(tesm-license.service)
fi
step "Vorhaben"
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
info "Alt : $SOURCE"
info "Neu : $NEW_ROOT$([[ $IN_PLACE -eq 1 ]] && echo ' << dasselbe Verzeichnis')"
info "Sicherung : $ALT_KOPIE"
info "Datenuebernahme: $([[ $WITH_DATA -eq 1 ]] && echo ja || echo nein)"
info "Altes System : $([[ $STOP_OLD -eq 1 ]] && echo 'stoppen und deaktivieren' || echo 'laeuft weiter')"
if [[ $IN_PLACE -eq 1 && $STOP_OLD -eq 0 ]]; then
warn "--keep-old-running bei gleichem Verzeichnis: der alte Dienst laeuft auf Dateien,"
warn "die gleich ersetzt werden. Das geht schief. Bitte --instance verwenden."
fi
if [[ $DRY_RUN -eq 1 ]]; then
step "--dry-run: hier ist Schluss."
exit 0
fi
if [[ $ASSUME_YES -eq 0 ]]; then
read -r -p "Fortfahren? [j/N] " answer
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
fi
# -- 1. Sichern -------------------------------------------------------------- #
step "Sichern"
mkdir -p "$ALT_KOPIE"
chmod 700 "$BACKUP"
# -a: Rechte und Zeiten bleiben, damit die Kopie auch als Rueckweg taugt.
# Das venv der alten Installation bleibt aussen vor -- es ist nachbaubar,
# gross, und seine Shebangs zeigen ohnehin auf den alten Pfad.
tar -C "$SOURCE" --exclude=venv --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)"
[[ -f "$ALT_KOPIE/sqlite.db" ]] || fail "In der Kopie fehlt die Datenbank -- Abbruch"
# -- 2. Altes System abschalten ---------------------------------------------- #
if [[ $STOP_OLD -eq 1 ]]; then
step "Altes System abschalten"
for unit in "${ALTE_UNITS[@]}"; do
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then
systemctl disable --now "$unit" >/dev/null 2>&1 || true
info "$unit gestoppt und deaktiviert"
fi
done
else
step "Altes System bleibt in Betrieb"
fi
# -- 3. Neu installieren ----------------------------------------------------- #
step "Neu installieren"
INSTALL_ARGS=(--app "$APP" --yes)
[[ -n "$INSTANCE" ]] && INSTALL_ARGS+=(--instance "$INSTANCE")
bash "$SCRIPT_DIR/install.sh" "${INSTALL_ARGS[@]}" ${EXTRA[0]+"${EXTRA[@]}"}
if [[ $WITH_DATA -eq 0 ]]; then
step "Fertig -- ohne Datenuebernahme"
info "Sicherung der alten Installation: $ALT_KOPIE"
exit 0
fi
command -v "$ADMIN" >/dev/null 2>&1 || fail "$ADMIN nicht gefunden -- die Installation ist unvollstaendig"
if [[ -z "$PASSPHRASE" ]]; then
PASSPHRASE="$(head -c 24 /dev/urandom | base64 | tr -d '=+/' | cut -c1-24)"
# Die Passphrase liegt neben den Dateien, die sie schuetzt. Das ist hier kein
# Widerspruch: das alte Format verlangt eine, das Verzeichnis gehoert root
# allein (0700), und ohne sie waeren die beiden Dateien spaeter unbrauchbar --
# auch als Rueckweg.
printf '%s\n' "$PASSPHRASE" > "$BACKUP/passphrase.txt"
chmod 600 "$BACKUP/passphrase.txt"
fi
if [[ "$APP" == "tesm" ]]; then
# -- 4. Exportieren -------------------------------------------------------- #
step "Daten der alten Installation lesen"
"$ADMIN" legacy-export --source "$ALT_KOPIE" --out "$EXPORT_ALT" \
--passphrase "$PASSPHRASE" --force
chmod 600 "$EXPORT_ALT"
# -- 5. Uebersetzen -------------------------------------------------------- #
step "Uebersetzen"
"$ADMIN" convert-legacy "$EXPORT_ALT" --ausgabe "$IMPORT_NEU" \
--passphrase "$PASSPHRASE" --neue-passphrase "$PASSPHRASE" --force
chmod 600 "$IMPORT_NEU"
# -- 6. Einspielen --------------------------------------------------------- #
step "Einspielen"
"$ADMIN" legacy-import "$IMPORT_NEU" --passphrase "$PASSPHRASE" --actor upgrade
else
# -- 4.-6. in einem: die Uebernahme liest die alte Datenbank unmittelbar --- #
step "Lizenzbestand uebernehmen"
# Der alte Signaturschluessel wird uebernommen. Fuer ein Upgrade ist das
# die einzige sinnvolle Vorgabe: es ist derselbe Server mit neuerer Software,
# und ohne den alten Schluessel muesste jeder Kunde den Ausstellerwechsel
# bestaetigen. Ueberschrieben wird dabei nichts -- die Uebernahme lehnt ab,
# wenn die neue Installation schon einen eigenen Schluessel hat.
MIGRATE_ARGS=(migrate-legacy --source "$ALT_KOPIE")
[[ $ADOPT_KEY -eq 1 ]] && MIGRATE_ARGS+=(--adopt-key)
"$ADMIN" "${MIGRATE_ARGS[@]}"
if [[ $ADOPT_KEY -eq 0 ]]; then
warn "--new-key: es gilt ein neuer Signaturschluessel. Die Kunden muessen den"
warn "Ausstellerwechsel einmal bestaetigen."
fi
fi
# -- Abschluss --------------------------------------------------------------- #
step "Fertig"
info "Sicherung der alten Installation : $ALT_KOPIE"
if [[ "$APP" == "tesm" ]]; then
info "Export im alten Format : $EXPORT_ALT"
info "Uebersetzte Datei : $IMPORT_NEU"
[[ -f "$BACKUP/passphrase.txt" ]] && info "Passphrase beider Dateien : $BACKUP/passphrase.txt"
fi
cat <<TEXT
Von Hand nachzuziehen -- das laesst sich nicht uebersetzen:
* Lokale Konten: der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash
ist nicht umrechenbar; das ist sein Zweck. Erstes Konto anlegen mit
sudo -u $APP $ADMIN create-admin
* Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.
* Verzeichnisdienst (LDAP), Webserver und Zertifikat.
TEXT
# Der Rueckweg sieht in den beiden Faellen verschieden aus. Ein Hinweis, der
# das alte Verzeichnis wiederherstellen laesst, obwohl es nie angefasst wurde,
# waere schlimmer als keiner: er wuerde die Daten der neuen Installation mit
# einem alten Stand ueberschreiben.
if [[ $IN_PLACE -eq 1 ]]; then
cat <<TEXT
Zurueck auf den alten Stand -- das Verzeichnis wurde ersetzt, die Kopie
bringt es zurueck:
sudo systemctl disable --now $NAME
sudo rm -rf $SOURCE && sudo mkdir -p $SOURCE
sudo tar -C $ALT_KOPIE -cf - . | sudo tar -C $SOURCE -xf -
# venv der alten Installation neu bauen:
sudo python3 -m venv $SOURCE/venv
sudo $SOURCE/venv/bin/python -m pip install -r $SOURCE/requirements.txt
sudo systemctl enable --now ${ALTE_UNITS[0]}
TEXT
else
cat <<TEXT
Zurueck auf den alten Stand -- $SOURCE wurde nicht angefasst, es genuegt,
die neue Instanz abzuschalten:
sudo systemctl disable --now $NAME $NAME-monitor
sudo rm -f /etc/nginx/sites-enabled/$NAME && sudo systemctl reload nginx
sudo systemctl enable --now ${ALTE_UNITS[0]}
TEXT
fi
+161 -65
View File
@@ -2,7 +2,7 @@
Dieses Dokument richtet sich an die Person, die die Anlage installiert,
aktualisiert und im Fehlerfall wieder zum Laufen bringt. Es beschreibt, was auf
dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird.
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 |
|---|---|
| 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>/` |
| Umgebungsdatei | `/etc/tesm/<name>.env` |
| 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` |
| 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
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
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.
---
@@ -43,10 +43,10 @@ Wichtige Optionen:
| Option | Wirkung |
|---|---|
| `--https` | Port 443 mit TLS, Port 80 leitet dorthin um. Ohne `--domain` entsteht ein selbstsigniertes Zertifikat. |
| `--domain <name>` | Oeffentlicher Name. Setzt `server_name`; die Site ist dann **nicht** mehr die Vorgabe-Site. |
| `--acme-email <a>` | Erst zusammen mit `--domain` wird ein Let's-Encrypt-Zertifikat angefordert (und HSTS gesetzt). Ohne die Adresse bleibt es selbstsigniert -- interne Namen wie `lizenz.firma.local` kann keine CA bestaetigen. |
| `--instance <name>` | Zusaetzliche Instanz neben der Hauptinstallation. |
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur fuer Testinstanzen sinnvoll). |
| `--domain <name>` | Öffentlicher Name. Setzt `server_name`; die Site ist dann **nicht** mehr die Vorgabe-Site. |
| `--acme-email <a>` | Erst zusammen mit `--domain` wird ein Let's-Encrypt-Zertifikat angefordert (und HSTS gesetzt). Ohne die Adresse bleibt es selbstsigniert -- interne Namen wie `lizenz.firma.local` kann keine CA bestätigen. |
| `--instance <name>` | Zusätzliche Instanz neben der Hauptinstallation. |
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur für Testinstanzen sinnvoll). |
| `--skip-packages` | Keine Systempakete installieren. |
Danach:
@@ -55,17 +55,17 @@ Danach:
sudo -u tesm tesm-admin create-admin
```
Beim Lizenzserver zusaetzlich, **in dieser Reihenfolge**:
Beim Lizenzserver zusätzlich, **in dieser Reihenfolge**:
```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 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
> pruefbar. Sichern Sie ihn getrennt vom Server.
> prüfbar. Sichern Sie ihn getrennt vom Server.
### 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
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.
---
@@ -90,19 +90,115 @@ sudo bash deploy/install.sh --app tesm
```
Derselbe Befehl. Das Skript erkennt an der vorhandenen Datenbank, dass es eine
Aktualisierung ist, und tut dann zusaetzlich:
Aktualisierung ist, und tut dann zusätzlich:
1. **Vollstaendige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` --
unabhaengig davon, ob sich das Schema geaendert hat.
1. **Vollständige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` --
unabhängig davon, ob sich das Schema geändert hat.
2. Dateiabgleich; `data/` bleibt unangetastet.
3. Schema-Migrationen beim Start (versioniert, mit Pruefsumme).
4. **Gesundheitspruefung.** Antwortet die Anwendung nicht, wird der alte Stand
automatisch zurueckgeholt; die fehlgeschlagene Version bleibt unter
3. Schema-Migrationen beim Start (versioniert, mit Prüfsumme).
4. **Gesundheitsprüfung.** Antwortet die Anwendung nicht, wird der alte Stand
automatisch zurückgeholt; die fehlgeschlagene Version bleibt unter
`/srv/<name>-failed-<zeitstempel>` zur Analyse liegen.
Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
`--https`.
### Ohne Quellbaum: `update.sh`
Liegt kein entpacktes Release vor, holt `update.sh` es selbst -- es lädt das
Release-Asset, entpackt es und ruft dessen `install.sh` auf:
```bash
sudo bash /opt/tesm/deploy/update.sh tesm
sudo bash /opt/tesm-license/deploy/update.sh tesm-license
```
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
eine **benannte Instanz** zu aktualisieren:
```bash
sudo bash /opt/tesm-opus/deploy/update.sh tesm latest --instance opus
```
Ohne `--instance` entstünde daneben eine zweite Installation namens `tesm`, mit
eigener Datenbank und eigener nginx-Site. Die Ports einer bestehenden
Installation bleiben erhalten, auch wenn sie von den Vorgaben abweichen: eine
Aktualisierung verschiebt nichts, was nicht ausdrücklich neu gesetzt wird.
Ohne Tag wird `latest` **über die API aufgelöst**, nicht in die URL geschrieben:
Gitea liefert unter einem Tag-Namen sonst ein automatisch erzeugtes
Quell-Archiv mit HTTP 200 statt des echten Anhangs -- und das enthält kein
Release, sondern den Repositoriumsstand.
Die Bezugsquelle ist voreingestellt und lässt sich über die Umgebung umlenken,
etwa auf eine Spiegelung:
| Variable | Vorgabe |
|---|---|
| `TESM_RELEASE_BASE` | `https://gitea.int.eertmoed.net` |
| `TESM_RELEASE_OWNER` | `alientim` |
| `TESM_RELEASE_REPO` | der Anwendungsname (`tesm` bzw. `tesm-license`) |
Der erwartete Anhang heisst `<repo>-<tag>.tar.gz`.
### Upgrade vom Stand 1.x (Branch `SONNET5`)
Ein eigenes Skript, weil dabei mehr passiert als ein Dateiabgleich: die alte
Fassung hat ein anderes Schema, andere Rechteschlüssel und ein anderes
Exportformat.
```bash
sudo bash deploy/upgrade.sh --app tesm
sudo bash deploy/upgrade.sh --app tesm-license
```
Der Ablauf ist die Vorgabe und braucht keine Schalter:
| | |
|---|---|
| 1. Sichern | Vollständige Kopie der alten Installation nach `/srv/<name>-upgrade-<zeitstempel>/alt` (ohne `venv`, Verzeichnis 0700) |
| 2. Abschalten | Alter Dienst **und seine Timer** werden gestoppt und deaktiviert |
| 3. Installieren | `install.sh` dieses Verzeichnisses |
| 4. Exportieren | Aus der Kopie entsteht `export-alt.json` im **alten** Format |
| 5. Übersetzen | Daraus `import-neu.json` im neuen Format |
| 6. Einspielen | Diese Datei geht durch denselben Import wie eine Sicherung |
Warum die Sicherung **vor** dem Export steht: ohne `--instance` installiert
`install.sh` in dasselbe Verzeichnis und ersetzt die alte Installation. Wer
erst exportiert und dann sichert, sichert im schlechtesten Fall nichts mehr.
Gelesen wird ausschliesslich aus der Kopie -- die alte Installation wird nie
verändert.
Die beiden Zwischendateien bleiben liegen; ihre Passphrase steht daneben in
`passphrase.txt` (0600, Verzeichnis 0700). `export-alt.json` ist ein echter
Export des Vorgängers und taugt deshalb auch als Rückweg.
**Daneben statt darüber.** Mit `--instance` entsteht eine zweite Installation,
und die alte bleibt vollständig liegen:
```bash
sudo bash deploy/upgrade.sh --app tesm --instance opus --keep-old-running --port 5100 --http-port 8080
```
**Was nicht mitkommt** -- und warum:
* **Lokale Konten.** Der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash
ist nicht umrechenbar; das ist sein Zweck. Erstes Konto mit
`tesm-admin create-admin`.
* **Gruppen und Rechte.** Die Rechteschlüssel wurden neu geschnitten.
* **LDAP, Webserver, Zertifikat.** Von Hand nachziehen.
Beim **Lizenzserver** gibt es Schritt 4 und 5 nicht: die Übernahme liest die
alte Datenbank unmittelbar, holt Endpunkt und Anbieterdaten aus ihren
Einstellungen und **signiert jede Lizenz neu**. Der alte Signaturschlüssel wird
dabei übernommen -- sonst müsste jeder Kunde einen Ausstellerwechsel
bestätigen; `--new-key` erzwingt das Gegenteil. Der Bericht nennt namentlich,
wer eine neue Lizenzdatei braucht, denn die Bindung an eine Maschine gilt nur
für die alte Datei.
Nur die Anwendung, ohne Daten: `--no-data`. Nur zeigen, was geschähe:
`--dry-run`.
---
## 4. HTTPS
@@ -114,7 +210,7 @@ Drei Wege dorthin:
**a) Bei der Installation** -- siehe oben, `--https`.
**b) Nachtraeglich ueber die Kommandozeile:**
**b) Nachträglich über die Kommandozeile:**
```bash
sudo -u tesm tesm-admin web-setup --https --self-signed --apply
@@ -124,42 +220,42 @@ sudo -u tesm tesm-admin web-setup --https --domain tesm.firma.de \
`--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
Wege erledigen das selbst) und der Dienst neu gestartet werden.
### Die Site-Datei wird immer neu erzeugt
Sie ist **kein** Ort fuer Handarbeit. Jede Uebernahme schreibt sie vollstaendig
aus der Vorlage neu. Was frueher von Hand hineingeschrieben wurde -- Domain,
Sie ist **kein** Ort für Handarbeit. Jede Übernahme schreibt sie vollständig
aus der Vorlage neu. Was früher von Hand hineingeschrieben wurde -- Domain,
Ports, Zertifikatspfade, HSTS, Upload-Grenze -- ist heute Einstellung in der
Anwendung.
Der Grund steht im Vorgaengerprojekt: dort wurde die Datei nur angelegt, wenn
sie fehlte. Ein einmal falscher `alias`-Pfad ueberlebte dadurch jahrelang jedes
Update, und die Oberflaeche kam die ganze Zeit ohne Design.
Der Grund steht im Vorgängerprojekt: dort wurde die Datei nur angelegt, wenn
sie fehlte. Ein einmal falscher `alias`-Pfad überlebte dadurch jahrelang jedes
Update, und die Oberfläche kam die ganze Zeit ohne Design.
### Selbstsigniert oder Let's Encrypt?
* **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.
* **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
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`
(bewusst nicht ueber das nginx-Plugin, das die erzeugte Site umschreiben
wuerde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
Die Erneuerung von Let's Encrypt läuft über `certbot certonly --webroot`
(bewusst nicht über das nginx-Plugin, das die erzeugte Site umschreiben
würde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
---
## 5. Dienste
```bash
systemctl status tesm.service # Weboberflaeche (gunicorn)
systemctl status tesm-monitor.service # Ueberwachungsschleife (nur TESM)
systemctl status tesm.service # Weboberfläche (gunicorn)
systemctl status tesm-monitor.service # Überwachungsschleife (nur TESM)
systemctl status tesm-license.service
journalctl -u tesm -n 100 -f
```
@@ -179,8 +275,8 @@ Die Anwendung hat keine. Alles, was Root braucht, geht durch **einen** Pfad:
/usr/local/lib/tesm/tesm-helper <verb> [argumente]
```
Der Helfer gehoert `root`, ist per sudoers ausschliesslich fuer genau diesen
Pfad mit `NOPASSWD` freigegeben und prueft jedes Argument gegen eine
Der Helfer gehört `root`, ist per sudoers ausschliesslich für genau diesen
Pfad mit `NOPASSWD` freigegeben und prüft jedes Argument gegen eine
Positivliste. Er reicht nie etwas an eine Shell weiter (kein `eval`, kein
`sh -c`).
@@ -191,16 +287,16 @@ sudo /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
im Helfer vorkommt und dass jede Pfadpruefung `..` abweist.
im Helfer vorkommt und dass jede Pfadprüfung `..` abweist.
---
## 7. Sicherung und Wiederherstellung
In der Oberflaeche unter *Sicherung* -- verschluesselte Ausfuhr (Argon2id +
AES-256-GCM) mit einem selbst gewaehlten Kennwort. Der Import zeigt zuerst eine
In der Oberfläche unter *Sicherung* -- verschlüsselte Ausfuhr (Argon2id +
AES-256-GCM) mit einem selbst gewählten Kennwort. Der Import zeigt zuerst eine
Vorschau, bevor irgendetwas geschrieben wird.
Auf dem Host reicht:
@@ -214,10 +310,10 @@ sudo systemctl start tesm
Unverzichtbar:
* `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/license.json` und `data/license_key.json`
* Lizenzserver zusaetzlich: `data/master_signing_key.json`
* Lizenzserver zusätzlich: `data/master_signing_key.json`
---
@@ -230,17 +326,17 @@ curl -s http://127.0.0.1:5000/gesundheit
```
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
**nginx laesst sich nicht neu laden.**
**nginx lässt sich nicht neu laden.**
```bash
sudo nginx -t
```
Die Uebernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
zurueck. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
Die Übernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
zurück. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
`install.sh`, das eine unbrauchbare Site durch die Vorlage ersetzt.
**Anmeldung schlaegt ohne Fehlermeldung fehl.**
**Anmeldung schlägt ohne Fehlermeldung fehl.**
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
grep COOKIE_SECURE /etc/tesm/tesm.env
```
@@ -250,38 +346,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
den Port.
**Statische Dateien fehlen, die Oberflaeche ist unformatiert.**
**Statische Dateien fehlen, die Oberfläche ist unformatiert.**
```bash
grep alias /etc/nginx/sites-available/tesm
ls /srv/tesm/static /srv/tesm/static-core
```
Beide `alias`-Pfade 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
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
Schluessel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
der Detailseite des Geraets (*Clients -> Geraet -> SSH-Host-Schluessel*):
erst **Auslesen**, dann den Fingerprint mit einer unabhaengigen Quelle
vergleichen, dann **Freigeben**. Die Wartungsuebersicht zeigt in einer eigenen
Spalte, fuer welche Geraete das noch offen ist. Fuer Switche liegt dieselbe
Schlüssel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
der Detailseite des Geräts (*Clients -> Gerät -> SSH-Host-Schlüssel*):
erst **Auslesen**, dann den Fingerprint mit einer unabhängigen Quelle
vergleichen, dann **Freigeben**. Die Wartungsübersicht zeigt in einer eigenen
Spalte, für welche Geräte das noch offen ist. Für Switche liegt dieselbe
Freigabe auf der Switch-Detailseite.
Der Schluessel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
verbindet sich immer ueber die hinterlegte IP-Adresse. Aendert sich die IP
eines Geraets, ist der Schluessel erneut freizugeben.
Der Schlüssel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
verbindet sich immer über die hinterlegte IP-Adresse. Ändert sich die IP
eines Geräts, ist der Schlüssel erneut freizugeben.
**Die Pruefkette des Aenderungsprotokolls ist gebrochen.**
**Die Prüfkette des Änderungsprotokolls ist gebrochen.**
Unter *Diagnose* sichtbar. Das Protokoll ist verkettet gehasht; ein Bruch heisst,
dass jemand direkt in die Datenbank geschrieben hat. Der Eintrag, ab dem es
nicht mehr stimmt, wird genannt.
---
## 9. Zurueck auf eine aeltere Version
## 9. Zurück auf eine ältere Version
```bash
sudo systemctl stop tesm
@@ -290,6 +386,6 @@ sudo cp -a /srv/tesm-backup-<zeitstempel> /srv/tesm
sudo systemctl start tesm
```
Schema-Migrationen laufen nur vorwaerts. Ein Ruecksprung ueber eine
Schema-Aenderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
Schema-Migrationen laufen nur vorwärts. Ein Rücksprung über eine
Schema-Änderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
genau das liegt im Backup-Verzeichnis.
+56 -56
View File
@@ -1,6 +1,6 @@
# 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) |
| Altbestand | bcrypt wird noch geprueft und bei der naechsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
| Zweiter Faktor | TOTP, optional erzwingbar fuer Administratoren |
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
| Sperre je Adresse | zusaetzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst laesst sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
| Verzeichnisdienst | LDAP nur ueber TLS, Zertifikat wird geprueft. Ohne gueltiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
Nach einer Anmeldung wird die Sitzungskennung neu vergeben (gegen Session
Fixation) und das CSRF-Token gewechselt.
### Erneute 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,
Ausfuhr, Herunterladen eines Lizenzbundles, Schluesselrotation,
2FA-Aenderungen, Deaktivierung einer Lizenz.
Ausfuhr, Herunterladen eines Lizenzbundles, Schlüsselrotation,
2FA-Änderungen, Deaktivierung einer Lizenz.
Ein uebernommenes Sitzungscookie allein reicht dafuer nicht.
Ein übernommenes Sitzungscookie allein reicht dafür nicht.
---
## 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.
* `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist
* Leerlaufzeit **und** absolute Hoechstdauer
* Der Cookiename enthaelt den `SITE_KEY` -- zwei Installationen auf einem Host
koennen sich die Sitzung nicht gegenseitig ueberschreiben (Cookies
* Leerlaufzeit **und** absolute Höchstdauer
* Der Cookiename enthält den `SITE_KEY` -- zwei Installationen auf einem Host
können sich die Sitzung nicht gegenseitig überschreiben (Cookies
unterscheiden keine Ports)
* Aktive Sitzungen sind einsehbar und einzeln beendbar
@@ -45,22 +45,22 @@ Zufallskennung.
## 3. CSRF
Drei voneinander unabhaengige Schichten:
Drei voneinander unabhängige Schichten:
1. `SameSite=Lax` am Sitzungscookie.
2. Ein Token je Sitzung, in jedem Formular, in konstanter Zeit verglichen.
3. Origin-/Referer-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
Standardports -- **nicht** aber beliebige Ports: ein anderer Dienst auf
demselben Rechner ist ein anderer Ursprung.
Der Vorgaenger hatte gar keinen CSRF-Schutz. Eine praeparierte Seite konnte im
Namen eines angemeldeten Administrators Geraete loeschen oder Benutzer anlegen.
Der Vorgänger hatte gar keinen CSRF-Schutz. Eine präparierte Seite konnte im
Namen eines angemeldeten Administrators Geräte löschen oder Benutzer anlegen.
Ausnahmen (`@csrf.exempt`) gibt es nur fuer Maschinenschnittstellen, die sich
**nicht** ueber Cookies autorisieren. Eine cookie-autorisierte Route ohne
CSRF-Schutz waere eine Luecke.
Ausnahmen (`@csrf.exempt`) gibt es nur für Maschinenschnittstellen, die sich
**nicht** über Cookies autorisieren. Eine cookie-autorisierte Route ohne
CSRF-Schutz wäre eine Lücke.
---
@@ -69,75 +69,75 @@ CSRF-Schutz waere eine Luecke.
Bei jeder Antwort:
* `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein**
`unsafe-inline` fuer Skripte -- deshalb gibt es im ganzen Projekt kein
Inline-Script ohne Nonce, und JavaScript haengt ueber
`unsafe-inline` für Skripte -- deshalb gibt es im ganzen Projekt kein
Inline-Script ohne Nonce, und JavaScript hängt über
`data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil
einzelne berechnete Breiten (Fortschrittsbalken) als Style-Attribut gesetzt
werden; Style-Attribute fuehren keinen Code aus.
werden; Style-Attribute führen keinen Code aus.
* `X-Content-Type-Options: nosniff`
* `X-Frame-Options: DENY`, `frame-ancestors 'none'`
* `Referrer-Policy: same-origin`
* `Permissions-Policy` -- Kamera, Mikrofon, Ort, Zahlung, USB abgeschaltet
* `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
* `Strict-Transport-Security` nur, wenn TLS tatsaechlich terminiert wird
* `Strict-Transport-Security` nur, wenn TLS tatsächlich terminiert wird
> HSTS gehoert **nicht** zu einem selbstsignierten Zertifikat. Der Browser
> merkt sich die Vorgabe monatelang; die Warnung laesst sich danach nicht mehr
> HSTS gehört **nicht** zu einem selbstsignierten Zertifikat. Der Browser
> merkt sich die Vorgabe monatelang; die Warnung lässt sich danach nicht mehr
> wegklicken. `install.sh` setzt HSTS deshalb nur zusammen mit Let's Encrypt.
---
## 5. Geheimnisse im Ruhezustand
Ein 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.
* Der Verwendungszweck geht als AAD in die Verschluesselung ein. Ein Chiffrat
aus einem Kontext laesst sich in einem anderen nicht entschluesseln.
* Beim Start wird geprueft, ob die Rechte auf Schluessel- und Lizenzdateien
* Der Verwendungszweck geht als AAD in die Verschlüsselung ein. Ein Chiffrat
aus einem Kontext lässt sich in einem anderen nicht entschlüsseln.
* Beim Start wird geprüft, ob die Rechte auf Schlüssel- und Lizenzdateien
noch stimmen; Abweichungen erscheinen unter *Diagnose*.
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)
und nie ueber die Umgebung (`/proc/<pid>/environ`).
und nie über die Umgebung (`/proc/<pid>/environ`).
---
## 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
sudoers ausschliesslich fuer diesen Pfad freigegeben ist.
sudoers ausschliesslich für diesen Pfad freigegeben ist.
Regeln, die im Repository durch Tests erzwungen werden:
* Jedes Verb, das die Anwendung kennt, existiert im Helfer.
* Kein `eval`, kein `bash -c`, kein `sh -c`.
* Jede 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 zulaessigen Basisnamen `x`).
den zulässigen Basisnamen `x`).
* Der Instanzname ist auf `tesm[-<instanz>]` begrenzt; er landet in Pfaden.
* Keine sudoers-Regel ausser der einen. `NOPASSWD: ALL` gibt es nicht.
Die systemd-Units haben `NoNewPrivileges=yes`, `ProtectSystem=strict`,
`PrivateTmp=yes` und eine enge `RestrictAddressFamilies`-Liste.
Der Vorgaenger liess die komplette Flask-Anwendung als root laufen. Eine
einzige Luecke in irgendeinem Pfad haette den Host bedeutet.
Der Vorgänger liess die komplette Flask-Anwendung als root laufen. Eine
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
nachtraegliches Aendern oder Loeschen faellt auf und wird unter *Diagnose*
Verkettet gehasht: jeder Eintrag enthält den Hash seines Vorgängers. Ein
nachträgliches Ändern oder Löschen fällt auf und wird unter *Diagnose*
angezeigt, mitsamt dem Eintrag, ab dem die Kette bricht.
Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdruecklich
Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdrücklich
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:
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.
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
Passwortbestaetigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
Aenderungsprotokoll.
Passwortbestätigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
Änderungsprotokoll.
---
## 9. Ein- und Ausfuhr
Verschluesselte Umschlaege (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
gewaehlten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
Verschlüsselte Umschläge (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
gewählten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb
braucht der Dienst kein `--workers 1` mehr.
@@ -166,11 +166,11 @@ braucht der Dienst kein `--workers 1` mehr.
## 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.
* **Kein CSRF-Freibrief fuer cookie-autorisierte Routen.**
* **Kein `unsafe-inline` fuer Skripte**, auch nicht "voruebergehend".
* **Keine Rechteausweitung**, auch nicht fuer Administratoren untereinander.
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander.
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
nicht brechen.
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
@@ -179,5 +179,5 @@ braucht der Dienst kein `--workers 1` mehr.
## 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.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.0.0"
version = "2.0.4"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
+1 -1
View File
@@ -8,7 +8,7 @@ Design-System.
from __future__ import annotations
__version__ = "2.0.0"
__version__ = "2.0.2"
from .app_factory import create_base_app
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 trash as trash_views
#: Rechteinhaber, in dieser Reihenfolge. Steht an einer Stelle, weil die
#: Fusszeile in zwei Vorlagen auftaucht (Anwendungsrahmen und Anmeldeseiten)
#: und zwei Texte fuer dieselbe Aussage auseinanderlaufen.
COPYRIGHT_NOTICE = "Tim Eertmoed · WiS IT-Solutions GmbH"
CORE_SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"),
SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"),
@@ -56,6 +61,18 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
minimum=1,
maximum=3650,
),
SettingSpec(
"session_idle_minutes",
30,
"int",
"Automatische Abmeldung nach Leerlauf (Minuten)",
hint="Jede Anfrage setzt den Zaehler zurueck -- gezaehlt wird nur Untaetigkeit. "
"Gilt auch fuer die eingebundenen Dateifreigaben: mit der Sitzung endet die "
"Einbindung.",
section="system",
minimum=5,
maximum=1440,
),
SettingSpec("timezone", "", "str", "Zeitzone des Hosts", section="system"),
SettingSpec("hostname", "", "str", "Hostname", section="system"),
SettingSpec(
@@ -117,7 +134,7 @@ def create_base_app(
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
#: Funktionsliste von TESM waere dort schlicht falsch.
auth_tagline: str = "",
app_version: str = "2.0.0",
app_version: str = "2.0.4",
built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True,
template_folder: str | None = None,
@@ -192,11 +209,25 @@ def create_base_app(
)
app.extensions["tesm"] = extension
def _leerlauf(conn: Any) -> int:
"""Leerlaufgrenze aus den Einstellungen -- ueber die offene Verbindung.
Bewusst mit der Verbindung, die die Sitzungsverwaltung schon haelt: eine
zweite zu oeffnen waere eine verschachtelte Datenbankverbindung, und die
gilt hier als Fehler.
"""
try:
wert = int(settings.get(conn, "session_idle_minutes") or 0)
except (TypeError, ValueError):
wert = 0
return wert if wert > 0 else config.security.session_idle_minutes
app.session_interface = SqliteSessionInterface(
database,
cookie_name=app.config["SESSION_COOKIE_NAME"],
idle_minutes=config.security.session_idle_minutes,
absolute_hours=config.security.session_absolute_hours,
idle_lookup=_leerlauf,
)
# -- Schema und Startzustand ------------------------------------------ #
@@ -228,6 +259,17 @@ def create_base_app(
@app.after_request
def _finish_request(response): # type: ignore[no-untyped-def]
# Ein Aktualisierungsdurchlauf, der mit Sitzungscookie kommt und
# unangemeldet endet, hat gerade seine Sitzung verloren. Bei einer
# geschuetzten Seite sagt das schon der Status (401); bei einer, die
# auch oeffentlich antwortet -- die Statusuebersicht etwa -- kommt eine
# 200 mit den oeffentlichen Kacheln zurueck, und der offene Tab merkt
# nichts: er zeigt weiter eine Oberflaeche, als sei alles in Ordnung.
# Deshalb ein Kopf, den der Browser unabhaengig vom Status auswerten
# kann.
if session_store.is_automatic(request) and not current_user.is_authenticated:
if request.cookies.get(app.config["SESSION_COOKIE_NAME"], ""):
response.headers["X-Session-Expired"] = "1"
hsts = ""
if config.security.hsts_enabled:
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
@@ -264,6 +306,7 @@ def create_base_app(
"auth_tagline": auth_tagline,
"app_version": app_version,
"current_year": datetime.now(timezone.utc).year,
"copyright_notice": COPYRIGHT_NOTICE,
"current_user": current_user,
"nav_items": nav_items,
"palette_entries": palette,
@@ -42,7 +42,7 @@ AAD_LDAP_BIND = "service_accounts.ldap_bind"
#: userAccountControl-Bit fuer "Konto deaktiviert".
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"
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, ...]:
"""Rekursive Gruppenauflösung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
"""Rekursive Gruppenaufloesung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
try:
connection.search(
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)
except Exception as exc: # noqa: BLE001
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
logger.warning("Gruppenaufloesung fehlgeschlagen: %s", exc)
return ()
@@ -10,6 +10,7 @@ from flask import abort, flash, g, redirect, request, session, url_for
from werkzeug.local import LocalProxy
from ..rbac.model import Action, PermissionTree
from ..security.sessions import is_automatic
AUTH_SOURCES = ("local", "ldap")
@@ -189,6 +190,14 @@ def _deny(message: str) -> Any:
if not current_user.is_authenticated:
if request.method == "GET" and not request.headers.get("X-Requested-With"):
session["_after_login"] = request.full_path if request.query_string else request.path
# Eine automatische Aktualisierung fragt einen Ausschnitt ab und
# erwartet HTML. Eine Umleitung auf die Anmeldeseite wuerde sie
# dankbar entgegennehmen und eine ganze Seite in eine Kachel setzen --
# der Tab bliebe stumm stehen, obwohl die Sitzung abgelaufen ist. 401
# sagt es deutlich; der Browser laedt daraufhin die Seite neu und der
# Mensch sieht die Anmeldemaske.
if is_automatic(request):
abort(401, description="Sitzung abgelaufen.")
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
abort(401, description="Anmeldung erforderlich.")
flash("Bitte melden Sie sich an.", "info")
+2 -2
View File
@@ -45,7 +45,7 @@ class SecurityConfig:
cookie_secure: bool = True
cookie_samesite: str = "Lax"
#: Leerlauf-Zeitfenster einer Sitzung.
session_idle_minutes: int = 60
session_idle_minutes: int = 30
#: Harte Obergrenze unabhaengig von Aktivitaet.
session_absolute_hours: int = 12
#: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt.
@@ -147,7 +147,7 @@ def load_core_config(
security = SecurityConfig(
cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True),
cookie_samesite=_env(env_prefix, "COOKIE_SAMESITE") or "Lax",
session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 60),
session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 30),
session_absolute_hours=_env_int(env_prefix, "SESSION_ABSOLUTE_HOURS", 12),
reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15),
login_max_attempts=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS", 8),
@@ -48,6 +48,10 @@ SETTING_STATUS = "license_activation_status"
SETTING_PENDING = "license_pending_request"
SETTING_LAST_HEARTBEAT = "license_last_heartbeat_at"
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"
STATUS_INACTIVE = "inactive"
@@ -596,14 +600,22 @@ class LicenseManager:
message = "Verbindung zum Lizenzserver bestaetigt."
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
audit.log(
conn,
actor=actor,
action=f"license.{action}",
target=str(request_payload["license_id"]),
detail=f"Transport: {transport}",
severity="notice",
)
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
# Ereignis, sondern der Normalzustand. Im Auditlog stand dadurch
# praktisch nur noch er, und die Eintraege, um die es dort geht --
# Aktivierung, Deaktivierung, Widerruf --, gingen darin unter. Der
# Zeitpunkt des letzten Heartbeats steht ohnehin in den Einstellungen
# und auf der Lizenzseite. Ein *fehlgeschlagener* Heartbeat wird
# 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)
return {"ok": True, "transport": transport, "code": "", "message": message}
@@ -704,6 +716,19 @@ class LicenseManager:
except LicenseError as exc:
with self.database.transaction(conn):
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)}
# ------------------------------------------------------------------ #
@@ -761,6 +786,17 @@ class LicenseManager:
"dot_class": "status-pill__dot--pulse",
"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:
try:
last = lic.parse_iso(state.last_heartbeat_at)
@@ -27,6 +27,7 @@ from datetime import datetime, timedelta, timezone
from typing import Any
from flask import Flask, Request, Response
from flask import request as aktuelle_anfrage
from flask.sessions import SessionInterface, SessionMixin
from werkzeug.datastructures import CallbackDict
@@ -34,6 +35,79 @@ from ..db import Database
SESSION_ID_BYTES = 32
#: Kopfzeile, mit der der Browser eine Anfrage als **automatisch** kennzeichnet:
#: ein Aktualisierungsdurchlauf einer Kachel, kein Klick eines Menschen.
#:
#: Ohne diese Unterscheidung ist die automatische Abmeldung wirkungslos, und das
#: war sie auch: ein offener Tab fragt seine Bereiche alle 15 Sekunden neu ab,
#: jede Abfrage verlaengerte das Leerlauffenster, und niemand wurde je
#: abgemeldet -- nachweisbar an einer Sitzung, deren ``last_seen_at`` eine
#: Minute alt war, obwohl seit zehn Stunden niemand am Rechner sass.
#:
#: Die Entscheidung gehoert zum Client: nur er weiss, ob ein Zeitgeber oder ein
#: Mensch die Anfrage ausgeloest hat. Ein Angreifer koennte den Kopf setzen --
#: er wuerde damit seine *eigene* Sitzung schneller ablaufen lassen.
AUTO_REFRESH_HEADER = "X-Auto-Refresh"
#: Marke auf der Ansichtsfunktion. Steht an der Route, damit man sie dort sieht,
#: wo sie gilt.
FRAGMENT_ATTRIBUT = "_tesm_fragment"
def fragment(view: Any) -> Any:
"""Kennzeichnet eine Route als Aktualisierungsausschnitt.
Solche Routen ruft ein Zeitgeber auf, kein Mensch. Sie duerfen die Sitzung
benutzen, halten sie aber nicht am Leben -- sonst bleibt jeder offene Tab
unbegrenzt angemeldet.
Der Kopf ``X-Auto-Refresh`` allein genuegt dafuer nicht: er setzt voraus,
dass der Browser das aktuelle Skript faehrt. Ein Tab, der seit Stunden
offen ist, faehrt das alte -- und das ist genau der Tab, um den es geht.
"""
setattr(view, FRAGMENT_ATTRIBUT, True)
return view
def _ist_fragment_route() -> bool:
"""Gehoert die laufende Anfrage zu einer als Ausschnitt markierten Route?
``request.endpoint`` steht hier nicht immer schon: Flask oeffnet die Sitzung
**vor** dem Routing (``RequestContext.push`` ruft ``open_session`` und erst
danach ``match_request``). In ``save_session`` ist der Endpunkt gesetzt, in
``open_session`` nicht -- und ohne diesen Nachtrag griff die Erkennung
genau dort nicht, wo sie gebraucht wird.
"""
from flask import current_app
try:
endpunkt = getattr(aktuelle_anfrage, "endpoint", None)
if not endpunkt:
# Selbst nachsehen. Ein zweiter Routenabgleich je Anfrage ist
# billiger als eine Abmeldung, die nicht stattfindet.
adapter = current_app.url_map.bind_to_environ(aktuelle_anfrage.environ)
endpunkt, _ = adapter.match(
aktuelle_anfrage.path, method=aktuelle_anfrage.method
)
ansicht = current_app.view_functions.get(str(endpunkt))
except Exception: # noqa: BLE001 - keine Route, Umleitung, kein App-Kontext
return False
return bool(getattr(ansicht, FRAGMENT_ATTRIBUT, False))
def is_automatic(request: Request) -> bool:
"""Kam die Anfrage von einem Zeitgeber statt von einem Menschen?
Zwei Kennzeichen, und eines genuegt: der Kopf, den das Skript setzt,
und die Route selbst. Das zweite traegt auch dann, wenn ein lange
offener Tab noch ein aelteres Skript faehrt -- und das ist der Tab,
um den es geht.
"""
if request.headers.get(AUTO_REFRESH_HEADER, "") == "1":
return True
return _ist_fragment_route()
def _now() -> datetime:
return datetime.now(timezone.utc)
@@ -88,11 +162,24 @@ class SqliteSessionInterface(SessionInterface):
cookie_name: str,
idle_minutes: int,
absolute_hours: int,
idle_lookup: Any = None,
) -> None:
self.database = database
self.cookie_name = cookie_name
self.idle_minutes = idle_minutes
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 ------------------------------------------- #
@@ -141,12 +228,21 @@ class SqliteSessionInterface(SessionInterface):
}
# Leerlauffenster verlaengern, aber hoechstens einmal pro Minute
# schreiben -- sonst kostet jeder AJAX-Poll einen Schreibvorgang.
#
# Eine automatische Abfrage verlaengert gar nichts. Sie darf die
# Sitzung benutzen, solange diese lebt, aber sie haelt sie nicht am
# Leben: sonst bleibt jeder offene Tab bis in alle Ewigkeit
# angemeldet, und "Abmeldung nach 30 Minuten Leerlauf" ist eine
# Zusage, die die Anwendung nicht einhaelt. Damit bleibt
# ``last_seen_at`` ausserdem, was der Name sagt: der letzte
# Zeitpunkt, an dem ein Mensch da war.
last_seen = _parse(row.get("last_seen_at"))
if last_seen is None or (now - last_seen) > timedelta(seconds=60):
veraltet = last_seen is None or (now - last_seen) > timedelta(seconds=60)
if veraltet and not is_automatic(request):
with self.database.transaction(conn):
conn.execute(
"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
@@ -190,7 +286,7 @@ class SqliteSessionInterface(SessionInterface):
session.get("user_id"),
_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)),
session.get("_ip", ""),
session.get("_ua", ""),
@@ -198,6 +294,31 @@ class SqliteSessionInterface(SessionInterface):
),
)
session.new = False
# ``save_session`` bekommt die Anfrage nicht als Argument -- anders als
# ``open_session``, wo ein Parameter denselben Namen traegt. Hier ist es
# der Anfragekontext von Flask; die Antwort wird darin erzeugt.
elif is_automatic(aktuelle_anfrage):
# Die zweite Schreibstelle, und die eigentliche Ursache dafuer,
# dass die Abmeldung nie ausgeloest hat: hier wurde das
# Leerlauffenster bei **jeder** Anfrage verlaengert, die die
# Sitzung anfasst. Die Pruefung in open_session lief damit ins
# Leere -- ein Aktualisierungsdurchlauf einer Kachel fasst die
# Sitzung an (Flash-Speicher, CSRF), und schon stand das Fenster
# wieder 30 Minuten offen. Auf dem Lizenzserver fiel es nicht
# auf: dort pollt keine Kachel.
#
# Geaenderte Sitzungsdaten muessen trotzdem erhalten bleiben --
# sie zu verwerfen waere eine zweite Fehlerquelle. Nur die
# Fristen bleiben stehen.
conn.execute(
"UPDATE user_sessions SET user_id=?, data=?, reauth_at=? WHERE id=?",
(
session.get("user_id"),
payload,
session.get("_reauth_at"),
session.sid,
),
)
else:
conn.execute(
"UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
@@ -206,7 +327,11 @@ class SqliteSessionInterface(SessionInterface):
session.get("user_id"),
payload,
_iso(now),
_iso(now + timedelta(minutes=self.idle_minutes)),
# ``_idle(conn)`` und nicht ``idle_minutes``: die Zeit ist
# zur Laufzeit einstellbar, und diese Stelle hat die
# Einstellung ignoriert. Bei der Vorgabe von 30 Minuten
# fiel das nicht auf -- bei einer geaenderten schon.
_iso(now + timedelta(minutes=self._idle(conn))),
session.get("_reauth_at"),
session.sid,
),
@@ -161,6 +161,40 @@
border-color: var(--accent);
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] {
background: var(--surface-inset);
color: var(--text-muted);
@@ -374,6 +408,15 @@
background: var(--danger-soft);
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 {
background: var(--warning-soft);
color: var(--warning);
@@ -402,6 +445,22 @@
background: var(--success);
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--danger { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); color: 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__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 {
padding: var(--sp-5);
overflow-y: auto;
@@ -1001,6 +1091,16 @@
.warning-text { color: var(--warning); }
.accent-text { color: var(--accent); }
.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; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.break-all { overflow-wrap: anywhere; }
@@ -28,7 +28,7 @@ function store(key, value) {
if (value === null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
} catch {
/* Privatmodus o. Ä. -- Einstellungen sind dann nur fluechtig. */
/* Privatmodus o. Ae. -- Einstellungen sind dann nur fluechtig. */
}
}
@@ -45,22 +45,45 @@ function csrfToken() {
return document.body?.dataset.csrf || "";
}
/** fetch mit CSRF-Header und JSON-Erwartung. */
/**
* fetch mit CSRF-Header und JSON-Erwartung.
*
* `auto: true` kennzeichnet eine Anfrage, die ein Zeitgeber ausgeloest hat und
* kein Mensch. Der Server verlaengert das Leerlauffenster der Sitzung dann
* nicht -- ohne diese Unterscheidung haelt jeder offene Tab die Anmeldung
* unbegrenzt am Leben, und die automatische Abmeldung findet nie statt.
*
* Der HTTP-Status haengt am geworfenen Fehler, damit der Aufrufer 401
* ("Sitzung abgelaufen") von einem Netzfehler unterscheiden kann.
*/
export async function api(url, options = {}) {
const opts = { credentials: "same-origin", ...options };
const { auto = false, ...rest } = options;
const opts = { credentials: "same-origin", ...rest };
opts.headers = {
"X-Requested-With": "fetch",
"X-CSRF-Token": csrfToken(),
...(auto ? { "X-Auto-Refresh": "1" } : {}),
...(options.headers || {}),
};
const response = await fetch(url, opts);
// Eine Seite, die auch oeffentlich antwortet -- die Statusuebersicht etwa --
// liefert nach dem Ablauf der Sitzung eine 200 mit den oeffentlichen
// Kacheln. Am Status ist der Verlust also nicht zu erkennen; an diesem Kopf
// schon. Behandelt wird er wie eine 401.
if (response.headers.get("X-Session-Expired") === "1") {
const abgelaufen = new Error("Die Sitzung ist abgelaufen.");
abgelaufen.status = 401;
throw abgelaufen;
}
const type = response.headers.get("content-type") || "";
const payload = type.includes("application/json") ? await response.json() : await response.text();
if (!response.ok) {
const message =
(payload && typeof payload === "object" && (payload.error || payload.message)) ||
`Anfrage fehlgeschlagen (HTTP ${response.status})`;
throw new Error(message);
const error = new Error(message);
error.status = response.status;
throw error;
}
return payload;
}
@@ -212,13 +235,21 @@ function initDialogs() {
event.preventDefault();
const dialog = document.getElementById(opener.dataset.dialogOpen);
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]) => {
if (!key.startsWith("fill")) return;
const name = key.slice(4);
const target = dialog.querySelector(`[data-fill-target="${name}"]`);
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;
});
openDialog(dialog);
@@ -548,7 +579,10 @@ function initAutoRefresh() {
const tick = async () => {
if (stopped || document.hidden) return schedule();
try {
const html = await api(region.dataset.refreshUrl, { headers: { Accept: "text/html" } });
const html = await api(region.dataset.refreshUrl, {
auto: true,
headers: { Accept: "text/html" },
});
// Eine vollstaendige Seite gehoert nie in einen Ausschnitt. Zeigt die
// URL versehentlich auf eine ganze Seite, waere das Ergebnis eine
// Seite in der Seite -- bei jedem Durchlauf eine Ebene tiefer.
@@ -563,7 +597,8 @@ function initAutoRefresh() {
// von innerHTML entsteht ein neues Element -- mit scrollTop 0. Genau
// deshalb sprang ein laufendes Auftragsprotokoll bei jeder
// 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 position = before.scrollTop;
// "Am Ende" grosszuegig auslegen: wer mitliest, soll mitgezogen
@@ -579,7 +614,16 @@ function initAutoRefresh() {
initRelativeTimes(region);
initCountdowns(region);
}
} catch {
} catch (error) {
// 401 heisst: die Sitzung ist abgelaufen. Weiterpollen waere sinnlos,
// und stehenbleiben waere irrefuehrend -- die Kacheln zeigten dann
// beliebig alte Zahlen, als sei alles in Ordnung. Neu laden bringt die
// Anmeldemaske mit ihrem Hinweis.
if (error && error.status === 401) {
stopped = true;
window.location.reload();
return;
}
/* Netzfehler: beim naechsten Intervall erneut versuchen. */
}
schedule();
@@ -814,6 +858,25 @@ function initTileFilters() {
/* -- 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() {
initTheme();
initSidebar();
@@ -830,6 +893,7 @@ function boot() {
initPalette();
initSubmitGuards();
initTileFilters();
initLogTails();
unsavedGuard.init();
}
@@ -166,6 +166,13 @@ class SysOps:
input=stdin,
capture_output=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,
check=False,
)
@@ -197,6 +204,8 @@ class SysOps:
[binary, *command[1:]],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
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
Kommas und Leerzeichen und ein Tippfehler faellt erst bei der naechsten
Der DN einer AD-Gruppe ist nichts, was man abtippt: er ist lang, enthält
Kommas und Leerzeichen und ein Tippfehler fällt erst bei der nächsten
Anmeldung auf -- als stille Nicht-Zuordnung. Deshalb gesucht statt getippt.
Das Suchergebnis kommt aus dem Verzeichnis selbst; die Felder werden dabei
beide gefuellt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer
Umgebung ohne Dienstkonto ist die Suche nicht moeglich, und dann ist ein
eingefuegter DN besser als gar keine Zuordnung.
beide gefüllt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer
Umgebung ohne Dienstkonto ist die Suche nicht möglich, und dann ist ein
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.
#}
{% macro group_picker(name_field, dn_field, scope, name_value='', dn_value='',
@@ -1,5 +1,5 @@
{% if lines %}
<pre class="log-view">{% for line in lines %}{{ line }}
<pre class="log-view" data-tail>{% for line in lines %}{{ line }}
{% endfor %}</pre>
{% else %}
<div class="empty"><div class="empty__title">Keine Zeilen</div>
@@ -20,7 +20,7 @@
<div class="card__body">
{% if current_user.is_directory_account %}
{{ alert('Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder
Anmeldung uebernommen.', 'info') }}
Anmeldung übernommen.', 'info') }}
{% endif %}
<div class="form-grid">
{{ field('first_name', 'Vorname', value=profile.first_name,
@@ -48,7 +48,7 @@
</div>
<div class="card__body">
{% 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 %}
<div class="row row--tight">
{% for permission in permissions %}
@@ -35,26 +35,26 @@
<div class="stack">
<section class="card">
<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>
{% if current_user.is_directory_account %}
<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>
{% else %}
<form method="post" action="{{ url_for('account.change_password') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<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') }}
{{ field('new_password', 'Neues Passwort', type='password', required=True,
autocomplete='new-password',
hint_text='Mindestens ' ~ policy.min_length ~ ' Zeichen und zwei verschiedene Zeichenarten.') }}
{{ field('repeat_password', 'Neues Passwort wiederholen', type='password', required=True,
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 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>
{% endif %}
</section>
@@ -70,7 +70,7 @@
</div>
<div class="card__body stack">
{% 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">
<dt>Geheimnis</dt>
<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 class="input input--mono" type="text" name="code" required maxlength="6"
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>
{% elif totp_enabled %}
<dl class="kv fs-sm">
@@ -100,7 +100,7 @@
<form method="post" action="{{ url_for('account.recovery_codes') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<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">
Codes erneuern</button>
</form>
@@ -114,10 +114,10 @@
</div>
{% else %}
{% 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 %}
<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>
<form method="post" action="{{ url_for('account.totp_start') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -1,6 +1,6 @@
{# ==========================================================================
Grundgeruest aller angemeldeten Seiten.
Bloecke: extra_head, page_title, page_sub, breadcrumb, topbar_extra,
Grundgerüst aller angemeldeten Seiten.
Blöcke: extra_head, page_title, page_sub, breadcrumb, topbar_extra,
content, dialogs, scripts
========================================================================== #}
<!doctype html>
@@ -23,9 +23,9 @@
<aside class="sidebar" aria-label="Hauptnavigation">
<div class="sidebar__brand">
{# Die Wortmarke traegt den Namen selbst -- ein zweiter Textblock daneben
waere eine Doppelung. Eine einzige Zeichnung, deren Farben aus den
Themen-Token kommen: zwei per Media Query versteckte SVG waeren bei
{# Die Wortmarke trägt den Namen selbst -- ein zweiter Textblock daneben
wäre eine Doppelung. Eine einzige Zeichnung, deren Farben aus den
Themen-Token kommen: zwei per Media Query versteckte SVG wären bei
"System" und beim Umschalten von Hand falsch. #}
<span class="sidebar__logo" role="img" aria-label="{{ app_name }}">
<svg viewBox="0 0 312 112" xmlns="http://www.w3.org/2000/svg">
@@ -126,9 +126,25 @@
<div class="topbar__right">
{% block topbar_extra %}{% endblock %}
{% 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 %}>
{% if pill.dot %}<span class="status-pill__dot {{ pill.dot_class }}"></span>{% endif %}
{{ 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>
{% endfor %}
<button class="btn btn--icon btn--ghost hide-sm" type="button" data-behavior="open-palette"
@@ -141,7 +157,7 @@
</main>
<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">
{% if migration_warning %}
<a class="danger-text" href="{{ url_for('diagnostics.overview') }}">Schemawarnung</a>
@@ -163,17 +179,17 @@
aria-label="Suchbegriff" autocomplete="off">
<div class="palette__list"></div>
<div class="palette__hint">
<span><kbd>&uarr;</kbd><kbd>&darr;</kbd> waehlen</span>
<span><kbd>Enter</kbd> oeffnen</span>
<span><kbd>&uarr;</kbd><kbd>&darr;</kbd> wählen</span>
<span><kbd>Enter</kbd> öffnen</span>
<span><kbd>Esc</kbd> schliessen</span>
</div>
</dialog>
<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">
<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 class="modal__body"><p data-confirm-text></p></div>
<div class="modal__footer">
@@ -2,7 +2,7 @@
{% from "tesm_core/macros.html" import alert, pill, stat %}
{% 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 %}
<div class="grid grid--4">
@@ -17,17 +17,17 @@
<div class="card__header">
<div class="flex-1">
<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 %}
</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 class="card__body">
{% if chain.ok %}
<p class="muted fs-sm">
Jede Zeile bindet den Hash ihrer Vorgaengerin ein. Eine nachtraeglich entfernte oder
geaenderte Zeile wuerde hier auffallen.
Jede Zeile bindet den Hash ihrer Vorgängerin ein. Eine nachträglich entfernte oder
geänderte Zeile würde hier auffallen.
</p>
{% else %}
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
@@ -40,17 +40,17 @@
<div class="card__header">
<div class="flex-1">
<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 %}
</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>
{% if current_user.is_admin %}
<form method="post" action="{{ url_for('diagnostics.rotate_keys') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<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-title="Schluessel rotieren" data-confirm-ok="Rotieren" data-confirm-style="primary">
data-confirm="Neuen Datenschlüssel anlegen und gespeicherte Geheimnisse neu verschlüsseln? Der alte Schlüssel bleibt zum Lesen erhalten."
data-confirm-title="Schlüssel rotieren" data-confirm-ok="Rotieren" data-confirm-style="primary">
{{ icon('refresh', size=14) }} Rotieren
</button>
</form>
@@ -63,8 +63,8 @@
</ul>
{% else %}
<p class="muted fs-sm">
Alle Schluesseldateien liegen mit Modus 0600. Feldverschluesselung ist AES-256-GCM mit
rotierbarem Schluesselbund; jedes Chiffrat ist an seinen Verwendungszweck gebunden.
Alle Schlüsseldateien liegen mit Modus 0600. Feldverschlüsselung ist AES-256-GCM mit
rotierbarem Schlüsselbund; jedes Chiffrat ist an seinen Verwendungszweck gebunden.
</p>
{% endif %}
</div>
@@ -87,7 +87,7 @@
<th class="sortable">Komponente</th>
<th class="sortable numeric">Version</th>
<th class="sortable">Migration</th>
<th>Pruefsumme</th>
<th>Prüfsumme</th>
<th class="sortable">Angewendet</th>
</tr>
</thead>
@@ -112,7 +112,7 @@
<div class="flex-1">
<div class="card__title">
{{ 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') }}
{% else %}{{ pill('Helfer antwortet nicht', 'danger') }}{% endif %}
</div>
@@ -121,7 +121,7 @@
</div>
<div class="card__body">
<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.
</p>
{% if not sysops_available %}
@@ -129,10 +129,10 @@
Alles andere funktioniert normal -- das ist der erwartete Zustand in einer
Entwicklungsumgebung.', 'info') }}
{% elif sysops_selftest and not sysops_selftest.ok %}
{# Geprueft wird ein echter Aufruf, nicht die blosse Existenz der Datei.
Genau daran lag es einmal: die Seite meldete "erreichbar", waehrend
{# Geprüft wird ein echter Aufruf, nicht die blosse Existenz der Datei.
Genau daran lag es einmal: die Seite meldete "erreichbar", während
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') }}
{% endif %}
</div>
@@ -144,7 +144,7 @@
{% if current_user.is_admin %}
<form method="post" action="{{ url_for('diagnostics.housekeeping') }}" data-no-guard>
<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>
{% endif %}
</div>
@@ -167,14 +167,14 @@
{{ icon('key', size=17) }} Lizenzzustand
{% if license_state.valid and license_state.activated %}{{ pill('aktiv', 'online') }}
{% elif license_state.valid %}{{ pill('nicht aktiviert', 'warning') }}
{% else %}{{ pill('ungueltig', 'danger') }}{% endif %}
{% else %}{{ pill('ungültig', 'danger') }}{% endif %}
</div>
</div>
</div>
<div class="card__body">
<dl class="kv fs-sm">
<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>Rolle</dt><dd>{{ license_state.role }}</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>
{% 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 %}
<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 }}">
{{ '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>
</div>
{% endblock %}
@@ -20,8 +20,8 @@
{{ icon('lock', size=18) }}
<div class="alert__body">
<div class="alert__title">Systemgruppe</div>
Name und Rechte sind gesperrt. Anders als im Vorgaenger werden die Rechte
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurueckgesetzt &ndash;
Name und Rechte sind gesperrt. Anders als im Vorgänger werden die Rechte
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt &ndash;
Anpassungen bleiben erhalten.
{% 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) }}">
@@ -49,8 +49,8 @@
{% if group.preset_key and current_user.is_admin %}
<button class="btn btn--sm" type="submit"
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-title="Vorlage anwenden" data-confirm-ok="Zuruecksetzen" data-confirm-style="primary">
data-confirm="Rechte dieser Gruppe auf die Vorlage zurücksetzen? Bestehende Anpassungen gehen verloren."
data-confirm-title="Vorlage anwenden" data-confirm-ok="Zurücksetzen" data-confirm-style="primary">
{{ icon('refresh', size=14) }} Vorlage anwenden
</button>
{% endif %}
@@ -58,7 +58,7 @@
<div class="card__body">
<div class="form-grid">
{{ 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,
extra_class='field--full') }}
</div>
@@ -2,7 +2,7 @@
{% 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_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 %}
{% if current_user.has('groups.create') %}
@@ -13,9 +13,9 @@
{% endblock %}
{% block content %}
{{ alert('Wer selbst ein Recht nicht besitzt, kann es auch nicht vergeben. Diese Regel gilt fuer
alle ausser echten Administratoren und verhindert, dass sich ein Gruppenverwalter ueber eine
neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
{{ 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 über eine
neue Gruppe selbst höherstuft.', 'info', 'Eskalationsschutz') }}
<div class="grid grid--2">
{% 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>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<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-title="Gruppe loeschen" data-confirm-ok="Loeschen"
aria-label="Loeschen">{{ icon('trash', size=15) }}</button>
data-confirm="Gruppe {{ group.name }} löschen? Sie landet im Papierkorb; Mitglieder verlieren die zugehörigen Rechte."
data-confirm-title="Gruppe löschen" data-confirm-ok="Löschen"
aria-label="Löschen">{{ icon('trash', size=15) }}</button>
</form>
{% endif %}
</div>
@@ -67,7 +67,7 @@ neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
{% block dialogs %}
{% 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') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">
@@ -24,6 +24,6 @@
</p>
{% endif %}
{% 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 %}
{% endblock %}
@@ -1,6 +1,6 @@
{% extends "tesm_core/base.html" %}
{% block page_title %}Archiv {{ day }}{% endblock %}
{% block page_sub %}{{ rows | length }} archivierte Aenderungen{% endblock %}
{% block page_sub %}{{ rows | length }} archivierte Änderungen{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb"><span><a href="{{ url_for('logs.history') }}">Verlauf</a></span><span>{{ day }}</span></nav>
{% endblock %}
@@ -19,7 +19,7 @@
<td class="fs-sm">{{ row.actor }}</td>
<td class="mono fs-xs">{{ row.action }}</td>
<td class="fs-sm">{{ row.target }}</td>
<td class="fs-sm muted">{{ row.detail }}</td>
<td class="fs-sm muted">{{ row.detail | audit_detail }}</td>
<td class="mono fs-xs faint">{{ row.hash[:12] }}</td>
</tr>
{% endfor %}
@@ -1,8 +1,8 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import pill, empty_state, alert %}
{% block page_title %}Aenderungsprotokoll{% endblock %}
{% block page_sub %}{{ total }} Eintraege &middot; Hash-Kette {{ 'unversehrt' if chain.ok else 'gebrochen' }}{% endblock %}
{% block page_title %}Änderungsprotokoll{% endblock %}
{% block page_sub %}{{ total }} Einträge &middot; Hash-Kette {{ 'unversehrt' if chain.ok else 'gebrochen' }}{% endblock %}
{% block topbar_extra %}
{% if current_user.has('logs_audit.export') %}
@@ -13,10 +13,10 @@
{% block content %}
{% if not chain.ok %}
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
'Das Protokoll wurde veraendert') }}
'Das Protokoll wurde verändert') }}
{% else %}
{{ alert('Jeder Eintrag bindet den Hash seines Vorgaengers ein. Eine nachtraeglich entfernte
oder geaenderte Zeile faellt bei der Pruefung auf -- ' ~ chain.checked ~ ' Zeilen geprueft.',
{{ alert('Jeder Eintrag bindet den Hash seines Vorgängers ein. Eine nachträglich entfernte
oder geänderte Zeile fällt bei der Prüfung auf -- ' ~ chain.checked ~ ' Zeilen geprüft.',
'success') }}
{% endif %}
@@ -41,7 +41,7 @@
<input class="input" type="text" name="benutzer" value="{{ filters.benutzer }}"
placeholder="Benutzer" style="width:150px">
<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>
<div class="table-wrap">
@@ -64,12 +64,17 @@
<td class="fs-sm fw-600">{{ row.actor }}</td>
<td class="mono fs-xs">{{ row.action }}</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 | audit_detail }}</td>
<td>
{% if row.severity == 'critical' %}{{ pill('kritisch', 'danger') }}
{% elif row.severity == 'warning' %}{{ pill('Warnung', 'warning') }}
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'info') }}
{% else %}{{ pill('Info', '') }}{% endif %}
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine
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 class="mono fs-xs faint">{{ row.ip or '&ndash;' | safe }}</td>
</tr>
@@ -77,13 +82,13 @@
</tbody>
</table>
</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">
<span class="fs-sm muted">Seite {{ page }}</span>
<span class="row row--tight">
{% 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 %}
{% if rows | length == per_page %}
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page+1, **filters) }}">Weiter</a>
@@ -6,7 +6,7 @@
{% block content %}
{{ 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">
<section class="card">
@@ -19,7 +19,7 @@
{% if rotated %}
<div class="table-wrap">
<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>
{% for entry in rotated %}
<tr>
@@ -39,7 +39,7 @@
<section class="card">
<div class="card__header">
<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>
{% if archived and current_user.has('logs_history.export') %}
@@ -52,7 +52,7 @@
{% if archived %}
<div class="table-wrap">
<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>
{% for entry in archived %}
<tr>
@@ -69,8 +69,8 @@
<form method="post" action="{{ url_for('logs.history_purge') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Alle {{ archived | length }} Archivdateien endgueltig vom Server loeschen? Bitte vorher als ZIP sichern."
data-confirm-title="Archiv loeschen" data-confirm-ok="Loeschen">
data-confirm="Alle {{ archived | length }} Archivdateien endgültig vom Server löschen? Bitte vorher als ZIP sichern."
data-confirm-title="Archiv löschen" data-confirm-ok="Löschen">
Archiv vom Server entfernen
</button>
</form>
@@ -51,8 +51,22 @@
</label>
{%- 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) -%}
<label class="check {% if disabled %}check--disabled{% endif %}">
{{ _present(name, disabled) }}
<input type="checkbox" name="{{ name }}" value="{{ value }}"
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
<span>{{ label }} {{ hint(hint_text) }}</span>
@@ -61,6 +75,7 @@
{% macro switch(name, label, checked=False, value='1', disabled=False) -%}
<label class="switch">
{{ _present(name, disabled) }}
<input type="checkbox" name="{{ name }}" value="{{ value }}"
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
<span>{{ label }}</span>
@@ -196,12 +211,12 @@
<span class="sr-only">{{ resource.label }} {{ action_meta[action][1] }}</span>
</label>
{% 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
sich anhaken, hatte aber kein name-Attribut -- es sah erteilt
aus und war nach dem Speichern weg. Was es nicht gibt, ist
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>
{% endif %}
</td>
@@ -216,7 +231,7 @@
{% for action, meta in action_meta.items() %}
<span><b>{{ meta[0] }}</b> {{ meta[1] }} &ndash; {{ meta[2] }}</span>
{% endfor %}
<span><span class="matrix__na"></span> Fuer diese Zeile nicht vorgesehen &ndash;
die Spalte gehoert zu einer anderen Zeile desselben Bereichs.</span>
<span><span class="matrix__na"></span> Für diese Zeile nicht vorgesehen &ndash;
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
</div>
{%- endmacro %}
@@ -1,13 +1,13 @@
{% 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 content %}
<div class="alert alert--info">
{{ icon('info', size=18) }}
<div class="alert__body">
Sie greifen auf Geheimnisse oder einen Datenexport zu. Ein uebernommenes
Sitzungscookie allein reicht dafuer nicht aus &ndash; die Bestaetigung gilt
Sie greifen auf Geheimnisse oder einen Datenexport zu. Ein übernommenes
Sitzungscookie allein reicht dafür nicht aus &ndash; die Bestätigung gilt
danach {{ reauth_minutes }} Minuten.
</div>
</div>
@@ -19,7 +19,7 @@
</label>
<div class="row row--end">
<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>
</form>
{% endblock %}
@@ -11,10 +11,10 @@
inputmode="numeric" autocomplete="one-time-code" maxlength="14"
placeholder="123456" style="letter-spacing:.22em;text-align:center;font-size:1.15rem">
<span class="field__hint">
Alternativ koennen Sie einen Ihrer Wiederherstellungscodes eingeben.
Alternativ können Sie einen Ihrer Wiederherstellungscodes eingeben.
</span>
</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>
<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 %}
@@ -8,7 +8,7 @@
{% block content %}
{{ 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">
<div class="table-wrap">
@@ -17,8 +17,8 @@ ungueltig -- auch wenn das Cookie noch existiert.', 'info') }}
<tr>
<th class="sortable">Benutzer</th>
<th class="sortable">Angemeldet</th>
<th class="sortable">Letzte Aktivitaet</th>
<th class="sortable">Laeuft ab</th>
<th class="sortable">Letzte Aktivität</th>
<th class="sortable">Läuft ab</th>
<th class="sortable">IP</th>
<th>Client</th>
<th class="actions">Aktion</th>
@@ -12,16 +12,16 @@
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
{{ 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') }}
{% if values.ldap_security == 'none' %}
{{ alert('Die Verbindung ist unverschluesselt. Das Passwort geht dabei im Klartext ueber das
Netz -- nur fuer Testumgebungen vertretbar.', 'danger', 'Unverschluesselt') }}
{{ alert('Die Verbindung ist unverschlüsselt. Das Passwort geht dabei im Klartext über das
Netz -- nur für Testumgebungen vertretbar.', 'danger', 'Unverschlüsselt') }}
{% 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',
'Zertifikat ungeprueft') }}
'Zertifikat ungeprüft') }}
{% endif %}
<div class="grid grid--split">
@@ -48,8 +48,8 @@ niemals hier gespeichert.', 'info') }}
{{ field('ldap_port', 'Port', value=values.ldap_port, type='number',
disabled=bool(license_hint), attrs='id="ldap-port"') }}
{# Der Transport setzt den Port mit. Wer umstellt und den Port stehen
laesst, bekommt eine Verbindung, die auf gut Glueck scheitert --
und die Meldung ("Zeitlimit") sagt nichts ueber die Ursache. Ein
lässt, bekommt eine Verbindung, die auf gut Glück scheitert --
und die Meldung ("Zeitlimit") sagt nichts über die Ursache. Ein
absichtlich abweichender Port bleibt erhalten. #}
{{ select_field('ldap_security', 'Transport', specs['ldap_security'].choices,
selected=values.ldap_security, disabled=bool(license_hint),
@@ -68,21 +68,21 @@ niemals hier gespeichert.', 'info') }}
disabled=bool(license_hint)) }}
</div>
<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)) }}
{{ 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)) }}
{{ 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),
hint_text=specs['ldap_allow_insecure'].hint) }}
</div>
<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">
{{ field('bind_username', 'Benutzer', value=bind_username, autocomplete='off',
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',
disabled=not current_user.has('settings_directory.secrets')) }}
</div>
@@ -125,7 +125,7 @@ niemals hier gespeichert.', 'info') }}
action="{{ url_for('hostsettings.directory_mapping_delete', mapping_id=entry.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<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">
{{ icon('trash', size=14) }}</button>
</form>
@@ -5,30 +5,30 @@
{% block page_sub %}Backend: {{ backend }} &middot; Sicherheitsnetz {{ revert_seconds }} s{% endblock %}
{% 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
zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
'Automatischer Rueckfall') }}
zurückgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
'Automatischer Rückfall') }}
{% if pending %}
<section class="card card--danger">
<div class="card__header">
<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>
</div>
<div class="card__body">
<form method="post" action="{{ url_for('hostsettings.network_confirm') }}" data-no-guard>
<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>
</div>
</section>
{% endif %}
{% 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 %}
<section class="card">
@@ -54,7 +54,7 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
</table>
</div>
{% 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 %}
</section>
@@ -62,11 +62,11 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
<section class="card">
<div class="card__header">
<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">
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
unberuehrt.
unberührt.
</div>
</div>
</div>
@@ -88,18 +88,18 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
</select>
</label>
{{ 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('dns', 'DNS-Server', placeholder='192.168.1.1, 9.9.9.9') }}
</div>
{{ alert('Das Gateway muss im angegebenen Netz liegen -- sonst waere der Host nach der
Aenderung nicht mehr erreichbar. Das wird vor dem Schreiben geprueft.', 'info') }}
{{ alert('Das Gateway muss im angegebenen Netz liegen -- sonst wäre der Host nach der
Änderung nicht mehr erreichbar. Das wird vor dem Schreiben geprüft.', 'info') }}
</div>
<div class="card__footer">
<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-title="Netzwerk aendern" data-confirm-ok="Uebernehmen">
Uebernehmen
data-confirm="Netzwerkeinstellungen jetzt übernehmen? Die Verbindung kann kurz abbrechen. Ohne Bestätigung wird nach {{ revert_seconds }} Sekunden zurückgerollt."
data-confirm-title="Netzwerk ändern" data-confirm-ok="Übernehmen">
Übernehmen
</button>
</div>
</form>
@@ -10,14 +10,14 @@
{% block content %}
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
{% 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') }}
{% endif %}
{{ alert('Die Site-Datei wird bei jeder Uebernahme vollstaendig neu geschrieben.
Alles, was ein Administrator sonst per Hand hineinschreiben wuerde, ist hier Einstellung. Der
Vorgaenger 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',
{{ alert('Die Site-Datei wird bei jeder Übernahme vollständig neu geschrieben.
Alles, was ein Administrator sonst per Hand hineinschreiben würde, ist hier Einstellung. Der
Vorgänger schrieb die Datei nur, wenn sie noch nicht existierte -- ein einmal falscher Pfad
blieb dadurch über Jahre bestehen und lieferte alle statischen Dateien als 404.', 'info',
'Warum die Datei immer neu geschrieben wird') }}
<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">
{{ field('web_domain', 'Domain', value=config.domain,
placeholder='tesm.firma.de', disabled=bool(license_hint),
hint_text='Leer = Zugriff ueber IP-Adresse.') }}
{{ field('web_acme_email', 'E-Mail fuer Let\'s Encrypt', value=acme_email, type='email',
hint_text='Leer = Zugriff über IP-Adresse.') }}
{{ field('web_acme_email', 'E-Mail für Let\'s Encrypt', value=acme_email, type='email',
disabled=bool(license_hint)) }}
{{ field('web_cert_path', 'Zertifikat (Pfad)', value=config.cert_path,
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') }}
{{ field('web_acme_webroot', 'ACME-Webroot', value=config.acme_webroot,
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)) }}
{{ checkbox('web_redirect_http', 'HTTP (Port 80) auf HTTPS umleiten',
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.') }}
{{ checkbox('web_hsts_enabled', 'HSTS-Header senden',
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">
<div class="flex-1">
<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>
{{ icon('chevron-down', size=16) }}
</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') %}
<dl class="kv fs-sm">
<dt>Inhaber</dt><dd class="break-all">{{ certificate.subject }}</dd>
<dt>Aussteller</dt><dd class="break-all">{{ certificate.issuer }}</dd>
<dt>Gueltig bis</dt><dd>{{ certificate.not_valid_after | dt }}</dd>
<dt>Aussteller</dt><dd class="break-all">{{ certificate.issür }}</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>
</dl>
{% 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"
{% if license_hint or not certbot_available or not config.domain %}disabled{% endif %}
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-style="primary">
{{ 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>
{% elif not config.domain %}
<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.
</p>
{% endif %}
<hr>
<p class="muted fs-sm">
Kein oeffentlicher Name? Dann ein eigenes Zertifikat erzeugen. Browser
zeigen dafuer eine Warnung, die Verbindung ist aber verschluesselt --
Kein öffentlicher Name? Dann ein eigenes Zertifikat erzeugen. Browser
zeigen dafür eine Warnung, die Verbindung ist aber verschlüsselt --
und das Sitzungscookie darf endlich <span class="mono">Secure</span> tragen.
</p>
{% if local_names %}
@@ -133,7 +133,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
Wird ausgestellt auf:
<span class="mono">{{ (config.domain or local_names[0]) }}</span>
{% if local_names|length > 1 %}
(zusaetzlich {{ local_names[1:] | join(', ') }})
(zusätzlich {{ local_names[1:] | join(', ') }})
{% endif %}
</p>
{% endif %}
@@ -156,7 +156,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
<section class="card card--accent">
<div class="card__header">
<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>
</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() }}">
<button class="btn btn--primary btn--block" type="submit"
{% if license_hint or not sysops_available %}disabled{% endif %}
data-busy-label="Uebernehme ..."
data-confirm="Konfiguration schreiben, pruefen und nginx neu laden? Eine fehlerhafte Konfiguration wird vorher abgewiesen."
data-confirm-title="nginx uebernehmen" data-confirm-ok="Uebernehmen"
data-busy-label="Übernehme ..."
data-confirm="Konfiguration schreiben, prüfen und nginx neu laden? Eine fehlerhafte Konfiguration wird vorher abgewiesen."
data-confirm-title="nginx übernehmen" data-confirm-ok="Übernehmen"
data-confirm-style="primary">
Konfiguration uebernehmen
Konfiguration übernehmen
</button>
</form>
</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.
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
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>
<html lang="de">
@@ -23,11 +23,11 @@
<body data-csrf="{{ csrf_token() }}">
<div class="auth-page">
{% if auth_tagline %}
{# Wortmarke mit Funktionszeile als tatsaechlicher Seitenhintergrund --
uebernommen vom Vorgaenger. Bewusst inline und mit Themen-Tokens statt
zweier Dateien fuer hell und dunkel: bei der Einstellung "System" und
beim Umschalten von Hand waere die versteckte Variante die falsche.
``aria-hidden``, weil der Name schon in der Ueberschrift steht. #}
{# Wortmarke mit Funktionszeile als tatsächlicher Seitenhintergrund --
übernommen vom Vorgänger. Bewusst inline und mit Themen-Tokens statt
zweier Dateien für hell und dunkel: bei der Einstellung "System" und
beim Umschalten von Hand wäre die versteckte Variante die falsche.
``aria-hidden``, weil der Name schon in der Überschrift steht. #}
<div class="auth-backdrop" aria-hidden="true">
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(150,15)">
@@ -71,7 +71,7 @@
{% 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>
<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 %}
{% 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 %}
{{ alert('Ansehen ist immer moeglich. Wiederherstellen und endgueltiges Loeschen erfordern eine
gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Seite entfernt.',
{{ alert('Ansehen ist immer möglich. Wiederherstellen und endgültiges Löschen erfordern eine
gültige, aktivierte Lizenz. Abgelaufene Einträge werden beim Aufruf dieser Seite entfernt.',
'info') }}
{% 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="kind" value="{{ section.key }}">
<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-title="Endgueltig loeschen" data-confirm-ok="Endgueltig loeschen">
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="Endgültig löschen" data-confirm-ok="Endgültig löschen">
Bereich leeren
</button>
</form>
@@ -33,7 +33,7 @@ gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Sei
{% if section.entries %}
<div class="table-wrap">
<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>
{% for entry in section.entries %}
<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) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="{{ entry.label }} endgueltig loeschen?"
data-confirm-title="Endgueltig loeschen" data-confirm-ok="Loeschen">
Endgueltig
data-confirm="{{ entry.label }} endgültig löschen?"
data-confirm-title="Endgültig löschen" data-confirm-ok="Löschen">
Endgültig
</button>
</form>
{% endif %}
@@ -20,7 +20,7 @@
<span class="table-toolbar__spacer"></span>
{% if directory_enabled and current_user.has('users.create') %}
<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>
{% endif %}
{% if current_user.is_admin %}
@@ -90,9 +90,9 @@
style="display:inline" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Konto {{ user.username }} wirklich loeschen?{% if user.auth_source == 'ldap' %} Verzeichniskonten werden sofort endgueltig entfernt.{% else %} Es landet im Papierkorb.{% endif %}"
data-confirm-title="Konto loeschen" data-confirm-ok="Loeschen"
aria-label="Loeschen">{{ icon('trash', size=15) }}</button>
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 löschen" data-confirm-ok="Löschen"
aria-label="Löschen">{{ icon('trash', size=15) }}</button>
</form>
{% endif %}
</td>
@@ -106,8 +106,8 @@
{% endif %}
</section>
{{ alert('Rechte werden ausschliesslich ueber Gruppen vergeben. Das Admin-Kennzeichen ist davon
unabhaengig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes
{{ alert('Rechte werden ausschliesslich über Gruppen vergeben. Das Admin-Kennzeichen ist davon
unabhängig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes
Administratorkonto muss immer bestehen bleiben.', 'info') }}
{% endblock %}
@@ -137,7 +137,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
</div>
</fieldset>
{% 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 %}
{{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }}
</div>
@@ -149,7 +149,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
{% endcall %}
{% 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') %}
<form method="post" action="{{ url_for('admin.directory_adopt') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -169,7 +169,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
</div>
</fieldset>
{{ alert('Name und E-Mail werden bei der ersten echten Anmeldung aus dem Verzeichnis
uebernommen und ueberschrieben.', 'info') }}
übernommen und überschrieben.', 'info') }}
</div>
<div class="modal__footer">
<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('new_password', 'Neues Passwort setzen (optional)', type='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 %}
<fieldset class="fieldset">
<legend>Gruppen</legend>
+146 -1
View File
@@ -8,12 +8,13 @@ nichts, was die CSP aufweichen muesste.
from __future__ import annotations
import math
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from flask import Flask, url_for
from markupsafe import Markup
from markupsafe import Markup, escape
ICONS: dict[str, str] = {
"grid": '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
@@ -126,6 +127,37 @@ def format_date(value: Any) -> str:
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:
try:
total = int(seconds)
@@ -154,6 +186,32 @@ def yes_no(value: Any) -> str:
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:
"""Parst einen JSON-String aus der Datenbank -- fuer Modul-/Rechtelisten.
@@ -206,6 +264,89 @@ def asset_url(app: Flask) -> Any:
return build
# --------------------------------------------------------------------------- #
# Auditeintraege: Dateinamen anklickbar machen
# --------------------------------------------------------------------------- #
#: Name einer Mitschrift, wie ``terminal.py`` sie bildet: Zeitstempel, Benutzer,
#: Ziel. Der Ausdruck laesst bewusst keinen Schraegstrich zu -- der Name kommt
#: aus einem Auditeintrag, und ein Eintrag ist Text, kein Pfad.
MITSCHRIFT = re.compile(r"\b(\d{8}-\d{6}-[A-Za-z0-9._-]{1,140}\.log)\b")
#: Unterverzeichnisse unter dem Protokollverzeichnis, in denen nachgesehen wird.
#: Der leere Eintrag steht fuer das Verzeichnis selbst.
PROTOKOLL_ORTE = ("terminal", "")
def _mitschrift_ziel(name: str) -> str | None:
"""Der Name relativ zum Protokollverzeichnis -- oder ``None``.
Ein Verweis auf eine Datei, die es nicht mehr gibt, ist schlechter als
keiner: er sieht nach einem Angebot aus und endet in 404. Protokolle werden
aufgeraeumt, Auditeintraege bleiben -- der Fall ist die Regel, nicht die
Ausnahme.
"""
from .extension import core
try:
wurzel = core().config.log_dir
except Exception: # noqa: BLE001 - ausserhalb eines App-Kontexts
return None
for ort in PROTOKOLL_ORTE:
relativ = f"{ort}/{name}" if ort else name
try:
if (wurzel / relativ).is_file():
return relativ
except OSError: # pragma: no cover - Verzeichnis nicht lesbar
continue
return None
def audit_detail(text: Any) -> Markup:
"""Auditeintrag als Text -- Dateinamen darin als Verweis.
Der Eintrag zur Konsole nennt seine Mitschrift beim Namen. Bisher stand
dort nur die Zeichenkette, und wer sehen wollte, was passiert ist, musste
sie von Hand in die Protokollansicht uebertragen. Es ist derselbe Weg wie
zuvor, nur einen Klick weit.
Verlinkt wird ausschliesslich, wenn die Datei existiert **und** der
Betrachter Protokolldateien lesen darf -- sonst bliebe ein Verweis, der in
403 endet. Der Rest des Eintrags wird maskiert; ein Auditeintrag enthaelt
Fremdeingaben (Geraetenamen etwa) und darf niemals als HTML gelten.
"""
from .auth.models import current_user
roh = "" if text is None else str(text)
if not roh or not MITSCHRIFT.search(roh):
return Markup(escape(roh))
try:
erlaubt = bool(current_user.is_authenticated and current_user.has("logs_history.view"))
except Exception: # noqa: BLE001 - ausserhalb einer Anfrage
erlaubt = False
if not erlaubt:
return Markup(escape(roh))
def verweis(name: str) -> str:
ziel = _mitschrift_ziel(name)
if ziel is None:
return str(escape(name))
try:
url = url_for("logs.history_file", name=ziel)
except Exception: # noqa: BLE001 - Route fehlt in dieser Anwendung
return str(escape(name))
return f'<a href="{escape(url)}" title="Mitschrift ansehen">{escape(name)}</a>'
teile: list[str] = []
ende = 0
for treffer in MITSCHRIFT.finditer(roh):
teile.append(str(escape(roh[ende : treffer.start()])))
teile.append(verweis(treffer.group(1)))
ende = treffer.end()
teile.append(str(escape(roh[ende:])))
return Markup("".join(teile))
def init_app(app: Flask) -> None:
app.jinja_env.globals.update(
icon=icon,
@@ -230,9 +371,13 @@ def init_app(app: Flask) -> None:
dt=format_datetime,
date=format_date,
duration=format_duration,
millis=format_millis,
number=format_number,
yes_no=yes_no,
from_json=from_json,
issuer_source=issuer_source,
issuer_source_hint=issuer_source_hint,
audit_detail=audit_detail,
)
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True
@@ -24,6 +24,7 @@ from .. import audit
from ..auth.models import current_user, permission_required
from ..auth.service import client_ip
from ..extension import core, db, transaction
from ..security.sessions import fragment
bp = Blueprint("logs", __name__, url_prefix="/protokolle")
@@ -53,6 +54,7 @@ def live():
@bp.get("/laufend/inhalt")
@permission_required("logs_live.view")
@fragment
def live_fragment():
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
needle = (request.args.get("q") or "").strip()
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-licensing-server"
version = "2.0.0"
version = "2.0.4"
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
requires-python = ">=3.11"
dependencies = ["tesm-licensing==2.0.0", "cryptography>=41"]
dependencies = ["tesm-licensing==2.0.4", "cryptography>=41"]
[tool.setuptools.packages.find]
where = ["src"]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-licensing"
version = "2.0.0"
version = "2.0.4"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = ["cryptography>=42"]
@@ -62,7 +62,7 @@ from .protocol import (
verify_master_response,
)
__version__ = "2.0.0"
__version__ = "2.0.2"
__all__ = [
"ACTIONS",
Binary file not shown.