Compare commits

8 Commits
Author SHA1 Message Date
alientimandClaude Opus 5 6ac2ed0a62 Nur noch, was zum Installieren und Betreiben des Lizenzservers noetig ist
Entfernt: Tests, Architekturdokumentation und die Anwendung TESM. Beides liegt
im Entwicklungsrepository alientim/TESM-DEV.

Neu: packages/tesm-licensing-server -- die Serverhaelfte des Lizenzprotokolls
(issue_license, resign_license, build_master_response, verify_client_request,
Schluesselerzeugung). Sie war bisher Teil von tesm-licensing und lag damit auch
in jeder Client-Installation. Die gemeinsame Haelfte bleibt in tesm-licensing
und wird von beiden Seiten benutzt.

install.sh installiert diese Serverhaelfte ausschliesslich fuer
--app tesm-license und bricht ab, wenn sie fehlt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 18:37:12 +02:00
alientimandClaude Opus 5 f7805a2180 TESM-Lizenzserver 2.0.0 -- Neubau
Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert
im Zweig SONNET5 erhalten.

Aufbau: apps/tesm-license (Anwendung), packages/tesm-core (gemeinsamer Kern),
packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd,
privilegierter Helfer), docs, tests. Die verwaltete Anwendung liegt in ihrem
eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit,
damit sich jedes allein installieren laesst.

Die wichtigsten Unterschiede zum Vorgaenger:

* Keine doppelte licensing.py -- ein Paket, das beide Anwendungen
  installieren, statt zweier Dateien, die byte-identisch bleiben sollen.
* Der Webprozess laeuft unprivilegiert; alles, was Root braucht, geht ueber
  einen einzigen Helfer mit Positivlisten fuer jedes Argument.
* CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen.
* Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet.
* Keine Lizenz ohne master_endpoint: eine Ausstellung ohne Endpunkt wird
  abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann.
* Offline-Aktivierung in beide Richtungen; die Lizenz bleibt als
  "Aktivierung offen" markiert, bis sie zurueckkommt.
* Getrennte Signaturkontexte je Nachrichtenart, Nonce gegen Wiedereinspielung,
  seq gegen das Zurueckrollen auf eine aeltere Lizenz.
* Kein Hostname im Maschinen-Fingerabdruck.
* Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 18:01:23 +02:00
alientimandClaude Sonnet 5 c130c85b17 Sub-Lizenzserver melden ihren Kunden-/Ticket-/Lizenzstand an den Master
Ein Sub-Lizenzserver (role='license_server') meldet jetzt seinen
kompletten eigenen Kunden-/Ticket-/Lizenzstand an den Master, der ihn
als Ueberblick auf einer neuen Kunden-Detailseite anzeigt: unter dem
Kunden, der die Lizenzserver-Lizenz haelt, erscheinen dessen eigene
Kunden mit ihren Tickets/Lizenzen -- genau wie gewuenscht.

Transport: huckepack auf dem bereits bestehenden 6-Stunden-Heartbeat
(licensing.py: neues optionales 'report'-Feld in
build_client_request(), signiert wie jedes andere Feld -- ein
Angreifer ohne den privaten Lizenzschluessel kann keinen gefaelschten
Bericht unterschieben). Zusaetzlich sofortiger Best-Effort-Report per
Hintergrund-Thread nach jeder lokalen Ticket-/Kunden-/Lizenz-Aenderung
(_report_to_master_async()), damit der Master nicht bis zu 6h auf
Aktualitaet warten muss. Funktioniert automatisch auch ueber den
Offline-Code-Weg (/licenses/manual-code), da beide Transportwege
denselben _process_heartbeat()-Code nutzen.

- Drei neue, rein informative Tabellen (reported_sub_customers/
  _tickets/_licenses), verankert an source_license_id (die Lizenz,
  die den Sub-Lizenzserver identifiziert). Ein Bericht ist immer ein
  VOLLER Schnappschuss -- bei jedem Empfang werden alle bisherigen
  Zeilen dieser source_license_id ersetzt (kein Diffing noetig, ein
  beim Sub-Lizenzserver geloeschtes Ticket verschwindet dadurch
  automatisch korrekt auch hier).
- _build_own_issuance_report(): baut den Schnappschuss auf der
  Sub-Lizenzserver-Seite.
- _ingest_sub_report(): uebernimmt ihn auf der Master-Seite, NUR wenn
  row['role']=='license_server' fuer die absendende Lizenz (Verteid-
  igung in der Tiefe -- row['role'] steht in der Master-DB fest, nicht
  im Client-Request, ein gewoehnlicher Kunde kann sich also nicht als
  Lizenzserver ausgeben).
- Neue Kunden-Detailseite (/customers/<id>, customer_detail.html):
  Stammdaten, eigene Tickets bei uns, und -- falls dieser Kunde eine
  aktive role='license_server'-Lizenz haelt -- die gemeldeten eigenen
  Kunden mit Tickets/Lizenzen (Ampelfarbe/Resttage ueber dieselbe
  _license_dict_for_display()-Funktion wie ueberall sonst). Kunden-
  liste verlinkt jetzt auf die Detailseite, zeigt 'Lizenzserver'-Badge.

Getestet: licensing.py isoliert (report signiert+verifiziert,
Manipulation bricht die Signatur, unveraendertes Verhalten ohne
report-Argument), _ingest_sub_report() isoliert (Erstbericht korrekt
uebernommen, Zweitbericht ersetzt korrekt inkl. verschwindender
Tickets, Isolation zwischen mehreren source_license_id sauber).
ZUSAETZLICH ein vollstaendiger Zwei-Prozess-Integrationstest ueber
echtes HTTP (zwei unabhaengige, native laufende Instanzen: 'Master' +
'Sub-Lizenzserver' auf unterschiedlichen Ports): Ticket mit
role=license_server angelegt, Lizenz ausgestellt, auf der zweiten
Instanz hochgeladen und ONLINE aktiviert, dort ein eigener Endkunde
samt Ticket/Lizenz angelegt, automatischer Report ausgeloest --
Master-Kundendetailseite zeigt den gemeldeten Endkunden mit korrektem
Ticket-Typ/Laufzeit/Status und aktuellem Heartbeat-Zeitstempel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:45:17 +02:00
alientimandClaude Sonnet 5 ed2ed00a4e Sub-Lizenzserver: Lizenzen fuer Lizenzserver ausstellbar (role='license_server')
Der Master kann jetzt ueber den normalen Kunde->Ticket->Lizenz-Flow
Lizenzen ausstellen, die eine ANDERE Lizenzserver-Instanz voll
funktionsfaehig machen (Kunden/Tickets/Lizenzen fuer eigene Kunden
ausstellen, Benutzer/Gruppen/Einstellungen verwalten) -- mit genau
einer strukturellen Einschraenkung: der so lizenzierte Sub-Lizenzserver
kann selbst KEINE weiteren Lizenzserver-Lizenzen ausstellen. Die
eigene Lizenz DIESES Masters bleibt weiterhin ausschliesslich per
create_master_license.py erzeugbar, nie ueber die Ticket-UI.

- licensing.py: neues, signiertes role-Feld (master/license_server/
  customer), siehe separates TESM-Release fuer die synchronisierte
  Kopie (harte Invariante: beide Dateien bleiben identisch).
- create_master_license.py: setzt role='master' beim Selbstausstellen.
- Neue STRUKTURELLE Sperre can_issue_license_server_licenses(): True
  NUR wenn die eigene, aktive Lizenz dieser Instanz role=='master' ist
  -- haengt an der Instanz-Rolle, nicht an einer vergebbaren Permission,
  daher von lokalen Admins eines Sub-Lizenzservers nicht aufhebbar.
  Kombiniert mit current_user.is_admin an jeder Aufrufstelle (nur echte
  Admins duerfen es je nutzen, wie gewuenscht -- kein delegierbares
  Recht).
- license_tickets.role-Spalte (Migration, Default 'customer').
  license_tickets.role bestimmt, welche Rolle eine daraus ausgestellte
  Lizenz traegt -- nach Anlage unveraendert wie customer_id.
- ticket_new(): neue, nur fuer can_issue_license_server_licenses()+Admin
  sichtbare Option 'Dies ist eine Lizenzserver-Lizenz'. Jeder andere
  Versuch (manipuliertes Formular, Sub-Lizenzserver, Nicht-Admin) wird
  serverseitig still auf 'customer' zurueckgestuft. Module werden fuer
  role!='customer' erzwungen auf [] (TESM-Client-Konzept, fuer einen
  Lizenzserver bedeutungslos).
- Self-Service (unauthentifiziert, nur ueber die Ticket-ID) ist fuer
  role!='customer'-Tickets komplett gesperrt (404) -- Lizenzserver-
  Lizenzen sind ausschliesslich admin-verwaltet, kein Kundenweg. Trial-
  Ausstellung ('30 Tage, alle Module') ebenfalls nur fuer role=customer
  moeglich (ValueError sonst, sauber abgefangen statt 500er).
- license_send_email(): ueberspringt Self-Service-/Kundenportal-Hinweise
  fuer role='license_server'-Lizenzen (waeren ohnehin blockiert).
- UI: 'Lizenzserver'-Badge auf Tickets-/Lizenzen-Liste und Ticket-
  Detailseite.

Getestet: licensing.py isoliert (role signiert+verifiziert, Manipulation
bricht die Signatur, Altformat ohne role faellt auf 'customer' zurueck),
can_issue_license_server_licenses()-Logik (6 Kombinationen), Schema-
Migration (Alt-DB ohne role-Spalte, idempotent), Ende-zu-Ende Ticket->
Lizenz-Ausstellung gegen echte create_db.py-DB. ZUSAETZLICH gegen einen
echten, lokal laufenden Flask-Server (native Windows-Python, WSL-
Netzwerksandbox in dieser Umgebung unzuverlaessig) voll durchgespielt:
Checkbox erscheint nur fuer role=master, Ticket+Lizenz-Ausstellung mit
role=license_server funktioniert Ende-zu-Ende (signierte Datei, Module
leer, Self-Service liefert 404), UND der Sicherheitstest: nach Wechsel
der eigenen Lizenz auf role=license_server verschwindet die Checkbox
UND ein direkter manipulierter POST mit role=license_server wird
serverseitig zuverlaessig auf 'customer' zurueckgestuft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:08:19 +02:00
alientimandClaude Sonnet 5 286a3464f1 Eigenes Recht fuer Tickets (tickets.view/.create/.edit)
Tickets liefen im Rechtebaum komplett ueber licenses.view/.create/.edit
mit, obwohl Tickets ein eigener, gleichberechtigter Navigationspunkt
neben Kunden und Lizenzen ist -- analog zu TESM, wo Clients/Switche/
Zugangsdaten trotz enger fachlicher Verwandtschaft jeweils eigene
Rechte haben. Ergaenzt die im vorigen Release (v1.2.4) behobene
Lizenz-Rechte-Luecke um diese zweite, gleich gelagerte Luecke.

- Neues Rechtepaar tickets.view/tickets.create/tickets.edit
  (PERMISSIONS/PERMISSION_LABELS), Property can_manage_tickets.
- Nav-Sichtbarkeit sowie tickets_list()/ticket_new()/ticket_edit()/
  ticket_delete() auf das neue Recht umgestellt.
- ticket_detail(): GET-Zugriff jetzt ueber can_manage_tickets. POST
  jetzt PRO SUB-AKTION geprueft statt eines einzigen Gates:
  'toggle_block' (Sperren/Entsperren) -> tickets.edit (neu, Ticket-
  Domain); 'issue_license' (erstellt eine LIZENZ) bleibt bewusst
  unveraendert an licenses.edit gebunden, damit sich 'Tickets
  verwalten' und 'Lizenzen ausstellen' unabhaengig voneinander
  vergeben lassen, ohne dass irgendjemand automatisch die eine
  Faehigkeit durch die andere verliert.
- tickets.html/ticket_detail.html: can_edit-Variable entsprechend in
  can_edit_ticket/can_issue_license aufgeteilt.
- Migration (_migrated_tickets_permission_v1, einmalig geguarded):
  jede Gruppe mit licenses.view/.create/.edit bekommt automatisch das
  gespiegelte tickets.view/.create/.edit dazu -- niemand verliert beim
  Update stillschweigend eine Faehigkeit, die vorher implizit ueber
  licenses.* mitlief.
- Getestet gegen isolierte Test-DB: Backfill korrekt (Gruppe mit voller
  Lizenz-Berechtigung bekommt volle Ticket-Berechtigung, Gruppe mit nur
  .view bekommt nur tickets.view), idempotent, keine Duplikate.
  PERMISSIONS/PERMISSION_LABELS/NAV_ITEMS erneut vollstaendig
  konsistent (keine Nav-Keys mehr ohne Rechte-Entsprechung), app.py
  syntaktisch fehlerfrei kompiliert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 21:33:33 +02:00
alientimandClaude Sonnet 5 619673ac76 Eigenes Recht fuer die Lizenzseite (settings_license.view/.edit)
Die Lizenzseite (Einstellungen -> Lizenz) hatte im Rechtebaum keinen
eigenen Eintrag -- sie lief komplett ueber settings_system.view/.edit
mit, genau wie im TESM-Client (siehe dortiger Fix). Damit konnte
'Lizenz verwalten' nicht unabhaengig von 'Systemeinstellungen
verwalten' vergeben/eingeschraenkt werden.

- Neues Rechtepaar settings_license.view/settings_license.edit
  (PERMISSIONS/PERMISSION_LABELS), Property can_view_settings_license.
- Nav-Sichtbarkeit und alle /settings/license*-Routen auf das neue
  Recht umgestellt (view-Route: can_view_settings_license; alle
  mutierenden Routen -upload/-activate(/confirm)/-deactivate(/confirm):
  settings_license.edit). Das 'auch ohne aktive Lizenz sichtbar
  bleiben'-Verhalten bleibt unveraendert (andere Pruefung, license_active()).
- settings_license.html: can_edit auf settings_license.edit umgestellt.
- Migration (_migrated_settings_license_permission_v1, einmalig
  geguarded): jede Gruppe mit settings_system.view/.edit bekommt
  automatisch settings_license.view/.edit dazu -- niemand verliert beim
  Update stillschweigend Zugriff, den er vorher implizit hatte.
- Getestet gegen isolierte Test-DB (create_db.py-Frischinstallation):
  Migration korrekt (inkl. Gruppe mit nur .view, die korrekt KEIN .edit
  bekommt), idempotent bei zweitem Lauf, keine Duplikate. PERMISSIONS/
  PERMISSION_LABELS/NAV_ITEMS strukturell konsistent geprueft, app.py
  syntaktisch fehlerfrei kompiliert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 21:11:02 +02:00
alientimandClaude Sonnet 5 20abadb0e7 Dashboard erfordert Login, Magic-Links nutzen konfigurierten Endpunkt statt IP (v1.2.3)
## Dashboard nur noch intern

'/' (das Kachel-Dashboard) verlangt jetzt @login_required -- ein anonymer
Besuch landet zuerst auf der Login-Seite (ueber Flask-Logins bereits
konfigurierten login_manager.login_view), statt innerhalb der Dashboard-
Huelle einen 'bitte anmelden'-Platzhalter zu zeigen. Anders als bei TESM
selbst (dessen Geraete-Dashboard bewusst auch ohne Login als Status-Board
sichtbar bleibt) ist der Lizenzserver ein reines internes Verwaltungs-
werkzeug.

## Magic-Links zeigen den konfigurierten Lizenzserver-Endpunkt, nicht die IP

Self-Service-Link, Kundenportal-Anfrage/-Zugang und der in 'Lizenz per
E-Mail senden' eingebettete Link wurden bisher per url_for(...,
_external=True) gebaut -- das nutzt den Host-Header der GERADE laufenden
Anfrage, zeigt also z.B. die interne IP, ueber die zufaellig der Admin
gerade eingeloggt ist, statt der unter Einstellungen -> Lizenz ->
Anbieter & Lizenzserver gepflegten oeffentlichen Domain. Neue Hilfs-
funktion _customer_facing_url() baut diese Links jetzt stattdessen aus
dem admin-gepflegten Lizenzserver-Endpunkt (_get_master_endpoint(),
derselbe Wert, der auch in jede Lizenzdatei eingebettet wird) -- faellt
auf die alte, request-basierte externe URL zurueck, wenn der Endpunkt
noch nicht konfiguriert ist (frische Installation).

Verifiziert gegen eine echte Kopie der Live-Datenbank: mit konfiguriertem
Endpunkt zeigt der Self-Service-Link jetzt https://tesml.int.eertmoed.net/...
statt der IP, unabhaengig davon, ueber welchen Host der Admin gerade
zugreift; ohne konfigurierten Endpunkt bleibt der bisherige Fallback
erhalten.

## Ausserdem live behoben (kein Code-Fix, reine Konfiguration)

/etc/nginx/sites-available/tesm-license auf dem Master zeigte fuer
/static/ faelschlich auf /srv/tesm/static/ (ein laengst nicht mehr
existierendes Verzeichnis -- Ueberbleibsel aus der Zeit vor der
Repo-Trennung) statt auf /srv/tesm-license/static/, wodurch CSS/JS/
Bilder ueberall 404eten (nackte, ungestylte Seiten). install.sh schreibt
die nginx-Konfiguration bewusst nur bei einer FRISCHEN Installation
(um manuelle Anpassungen wie TLS-Zertifikate bei Updates nicht zu
ueberschreiben) -- das im Repo hinterlegte Template selbst war bereits
korrekt, nur die schon laenger bestehende Datei auf dem Master war es
nicht. Direkt auf dem Master korrigiert und nginx neu geladen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:34:39 +02:00
alientimandClaude Sonnet 5 2d920f581f Eigenstaendige Lizenzen-Seite, entschachtelte Ticket-Ansicht, zeitlich begrenzter Kundenportal-Zugang (v1.2.2)
Reaktion auf Feedback: 'Tickets und Lizenzen je Kunden war sehr sehr
unintuitiv dargestellt... Kunde -> Ticket -> Lizenz aber alles auf extra
Seiten, im Menü einzeln anwählbar ohne diese riesen Verschachtelung.'

## Lizenzen als eigene, gleichberechtigte Ebene (neu: /licenses)

Bisher war eine Lizenz nur ueber Kunde -> gefilterte Tickets -> Ticket-
Detail -> eingebettete Historie-Tabelle erreichbar. Jetzt: Kunden,
Tickets UND Lizenzen sind drei eigene, direkt in der Navigation
erreichbare Seiten (Reihenfolge im Menü spiegelt bewusst die fachliche
Hierarchie). Kunden- und Ticket-Seite verlinken mit Filter (?customer_id=/
?ticket_id=) dorthin, statt Lizenzdaten selbst einzubetten.

Ticket-Detailseite dadurch massiv entschlackt: statt einer vollen
Lizenz-Historie-Tabelle nur noch eine kompakte 'Aktuelle Lizenz'-Zeile
mit Link auf die (ggf. gefilterte) Lizenzen-Seite fuer die volle Historie.

## Kundenportal: kein dauerhafter Link mehr, sondern zeitlich begrenzter Zugang

Vorheriger Ansatz (v1.2.0): ein dauerhafter, unbefristeter Token pro
Kunde, von Admins verteilt. Auf Nachfrage ('wie bekommt der Kunde seinen
Magic-Link, wenn er ihn verloren hat?') kam eine oeffentliche
'Zugang anfordern'-Seite dazu (E-Mail eingeben -> Link zugeschickt) --
und direkt im Anschluss die berechtigte Sicherheitsfrage, ob so ein
oeffentlich auf blossen Zuruf ausloesbarer Link nicht zeitlich begrenzt
sein sollte. Ergebnis: der dauerhafte Link wurde komplett entfernt.

Neuer, einziger Weg ins Kundenportal:
1. /portal/request -- Kunde gibt seine E-Mail ein (oeffentlich, keine
   Anmeldung noetig). Immer dieselbe generische Erfolgsmeldung,
   unabhaengig davon ob die Adresse bekannt ist (keine Enumeration).
2. Bei bekannter Adresse: ein frischer, einmalig nutzbarer Zugangs-Token
   (portal_access_tokens-Tabelle) wird per E-Mail verschickt, gueltig nur
   PORTAL_ACCESS_LINK_MINUTES (30) ab Anforderung.
3. /portal/access/<token> prueft Gueltigkeit + Einmalnutzung, markiert
   den Token als benutzt und setzt eine Session, die ihrerseits nach
   PORTAL_SESSION_MINUTES (60) automatisch ablaeuft.
4. /portal/me zeigt das eigentliche Portal (alle Tickets/Lizenzen,
   Kontaktdaten pflegen) -- ausschliesslich session-basiert erreichbar.

license_customers.portal_token bleibt in der DB bestehen (keine riskante
Schema-Ruecknahme), wird fuer die Zugriffsentscheidung aber nicht mehr
herangezogen -- alle Stellen, die vorher den dauerhaften Portal-Link
eingebettet haben (Ticket-Seite, Lizenz-per-E-Mail, Self-Service-Seite),
verweisen jetzt stattdessen auf /portal/request.

Verifiziert: bestehende Funktionstests erneut komplett durchlaufen (neue
Lizenzen-Seite, entschachtelte Ticket-Seite, entfernter dauerhafter
Zugang liefert 404), plus 11 neue Tests fuer den kompletten Magic-Link-
Fluss (Token-Erzeugung, Einmalnutzung, Ablehnen bei Wiederverwendung/
unbekanntem/abgelaufenem Token, Session bleibt nach Erstnutzung gueltig,
/portal/me ohne Session leitet zur Anfrage-Seite um) -- alle bestanden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:03:55 +02:00
201 changed files with 27106 additions and 13622 deletions
+9
View File
@@ -0,0 +1,9 @@
# Auf dem Zielsystem laufen Shell- und Python-Dateien -- CRLF wuerde sie
# unbrauchbar machen ("bad interpreter"). Darum hart auf LF festgelegt.
* text=auto eol=lf
*.sh text eol=lf
*.py text eol=lf
*.html text eol=lf
*.css text eol=lf
*.js text eol=lf
deploy/tesm-helper text eol=lf
+22 -15
View File
@@ -1,17 +1,24 @@
# Virtuelle Umgebung (Linux — das Projekt ist ausschließlich für Linux ausgelegt) # Instanzdaten und Geheimnisse gehoeren nie ins Repo.
/srv/tesm-license/venv/ instance/
*.db
*.db-wal
*.db-shm
data.keys
secret.key
license.json
license_key.json
master_signing_key.json
known_hosts
# Python
__pycache__/ __pycache__/
*.pyc *.py[cod]
.venv/
*.egg-info/
build/
dist/
# Logfiles # Werkzeuge
*.log .pytest_cache/
.ruff_cache/
# Secrets — niemals committen .mypy_cache/
/srv/tesm-license/sqlite.db
/srv/tesm-license/fernet.key
/srv/tesm-license/secret.key
/srv/tesm-license/license.json
/srv/tesm-license/master_signing_key.json
# Laufzeit-Uploads — werden bei Bedarf automatisch angelegt
/srv/tesm-license/static/uploads/
+115 -52
View File
@@ -1,74 +1,137 @@
# TESM-Lizenzserver # TESM-Lizenzserver
Master-Lizenzserver für [TESM (TimEShepManager)](https://gitea.int.eertmoed.net/alientim/tesm) — Ausgabe und Verwaltung der Lizenzen fuer TESM: Kunde -> Ticket -> Lizenz,
verwaltet Kunden und deren Lizenzen, statt Netzwerkgeräte. Ursprünglich als Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand ueber
Teil des TESM-Hauptrepos gebaut (gleiche Codebasis: Login/Benutzer/Gruppen, MS Graph und eine Hierarchie aus Unter-Lizenzservern.
LDAP/AD, NGINX- und Systemeinstellungen, Live-Log/Verlauf/Auditlog,
Im-/Export-Grundgerüst, das komplette Rechtesystem) und in dieses eigene
Repo ausgelagert.
**Das Projekt ist ausschließlich für Linux ausgelegt** (Zielsystem: eine **Dieses Repository enthaelt genau das, was zum Installieren und Betreiben des
Linux-VM, identisches Deployment-Muster wie TESM selbst). Lizenzservers gebraucht wird** -- nichts darueber hinaus:
## Kernfunktionen ```
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
deploy/ install.sh, update.sh, release.sh, systemd, privilegierter Helfer
docs/ Betrieb und Sicherheit
```
- **Kunden-/Lizenzübersicht** als Dashboard (Typ, Module, Ablaufdatum, Die verwaltete Anwendung liegt hier **nicht** mit drin.
Aktivierungsstatus, letzter Heartbeat)
- **Lizenz-Ausstellung**: Trial/Standard/Custom/Enterprise, frei wählbare | Wo sonst noch | |
Gültigkeitsdauer, bei Custom einzeln wählbare Module |---|---|
- **Ed25519-signierte Lizenzdateien** (siehe `srv/tesm-license/licensing.py`) | [alientim/tesm](https://gitea.int.eertmoed.net/alientim/tesm) | TESM selbst |
— dieselbe Datei existiert byte-identisch im TESM-Repo, da Client | [alientim/TESM-DEV](https://gitea.int.eertmoed.net/alientim/TESM-DEV) | Entwicklung: beide Anwendungen, Tests, Architekturdoku |
(TESM) und Master (dieses Repo) exakt dasselbe Signier-/
Verifikationsprotokoll sprechen müssen ## Zweige
- **Online-Aktivierung/-Deaktivierung/-Heartbeat** über eine schlanke
JSON-API (`/api/activate`, `/api/deactivate`, `/api/heartbeat`, | Zweig | Inhalt |
unauthentifiziert per Design — die Signatur der Anfrage selbst ist der |---|---|
Berechtigungsnachweis) | `main` | dieser Neubau (Version 2.0.0) |
- **Offline-Fallback**: dieselbe Aktivierungs-/Deaktivierungs-/Heartbeat- | `SONNET5` | der Stand davor, unveraendert aufbewahrt |
Logik auch als manuell kopierbarer Code für Kunden ohne Netzwerkzugriff
auf den Lizenzserver ---
- **E-Mail-Versand** ausgestellter Lizenzen per Microsoft Graph
(Client-Credentials-Flow, keine zusätzliche Abhängigkeit) inkl.
Einrichtungsanleitung und Verbindungstest in der GUI
- Eigene Bootstrap-Lizenz des Masters selbst (`create_master_license.py`,
rein lokal, kein externer Super-Master nötig)
## Installation ## Installation
```bash ```bash
sudo ./install.sh sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.de
sudo -u tesm-license tesm-license-admin init-key
sudo -u tesm-license tesm-license-admin bootstrap
sudo -u tesm-license tesm-license-admin create-admin
``` ```
Richtet System-Pakete, die Flask-App (systemd: `tesm-license.service`) **`init-key` erzeugt den Signaturschluessel dieses Servers. Sichern Sie ihn
sowie nginx als Reverse-Proxy unter `/srv/tesm-license` ein. Erkennt sofort.** Ohne ihn laesst sich keine ausgestellte Lizenz mehr erneuern, und
selbstständig, ob dort bereits eine Installation existiert, und Kunden muessten einen Ausstellerwechsel bestaetigen.
aktualisiert sie entsprechend (In-Place bei unverändertem
Datenbank-Schema, sonst mit automatischem Backup + Health-Check +
Rückroll bei Fehlschlag — siehe Kommentarkopf in `install.sh`).
Nach der Erstinstallation: 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
`--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.
### Aktualisieren
```bash ```bash
sudo /srv/tesm-license/venv/bin/python3 /srv/tesm-license/create_admin.py sudo bash deploy/update.sh --app tesm-license
sudo /srv/tesm-license/venv/bin/python3 /srv/tesm-license/create_master_license.py
``` ```
## Update (von einem bereits installierten System aus) Vor jedem Update entsteht ein vollstaendiges Backup, danach laeuft ein
Health-Check mit automatischem Rollback.
---
## Betrieb von der Kommandozeile
```bash ```bash
sudo ./update.sh sudo -u tesm-license tesm-license-admin status
# oder für eine bestimmte Version statt "latest": sudo -u tesm-license tesm-license-admin init-key # Signaturschluessel
TESM_RELEASE_TAG=v1.0.0 sudo -E ./update.sh 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
``` ```
Lädt das aktuelle Gitea-Release herunter und übergibt an `install.sh`. `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.
## Zusammenspiel mit TESM ---
Eine TESM-Instanz aktiviert sich gegen genau einen Lizenzserver ## Release bauen
(`master_endpoint`, in der Lizenzdatei hinterlegt). Ein Protokoll, zwei
Transportwege: online automatisch per HTTPS/JSON, offline als manuell ```bash
auszutauschender Code — beide Seiten nutzen dieselben Funktionen aus git tag -a v2.0.0 -m "TESM-Lizenzserver 2.0.0" && git push origin v2.0.0
`licensing.py`. Details zum kryptografischen Format und Ablauf siehe die bash deploy/release.sh v2.0.0
Docstrings in `srv/tesm-license/licensing.py` bzw. dem identischen Modul ```
im [TESM-Repo](https://gitea.int.eertmoed.net/alientim/tesm).
Heraus kommt `tesm-license-v2.0.0.tar.gz` samt SHA-256. Der Name kommt aus dem
Repository -- ein Lizenzserver-Release, das `tesm-...` heisst, verwechselt man
genau einmal.
---
## Lizenzprotokoll in Kurzform
* Ed25519, getrennte Signaturkontexte je Nachrichtenart
(`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
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.
* Keine Lizenz ohne `master_endpoint`: eine Ausstellung ohne Endpunkt wird
abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann.
---
## Dokumentation
| | |
|---|---|
| [docs/BETRIEB.md](docs/BETRIEB.md) | Installation, Update, HTTPS, Sicherung, Fehlersuche |
| [docs/SICHERHEIT.md](docs/SICHERHEIT.md) | Anmeldung, Sitzungen, CSRF, Geheimnisse, Rechtetrennung |
Aufbau und Entwurfsentscheidungen stehen in `docs/ARCHITEKTUR.md` im
Entwicklungsrepository, ebenso die Tests.
---
## Was diese Fassung anders macht
* **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.
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
+27
View File
@@ -0,0 +1,27 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tesm-license"
version = "2.0.0"
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
requires-python = ">=3.11"
dependencies = [
"tesm-core>=2.0.0",
"tesm-licensing>=2.0.0",
"PyYAML>=6.0",
"ldap3>=2.9",
]
[project.optional-dependencies]
server = ["gunicorn>=23.0"]
[project.scripts]
tesm-license-admin = "tesm_license.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
tesm_license = ["templates/**/*.html", "static/**/*"]
@@ -0,0 +1,194 @@
"""TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung.
Kein Fork von TESM: beide Anwendungen bauen auf demselben Paket ``tesm_core``
auf. Der Vorgaenger kopierte die gesamte Codebasis und entfernte danach, was er
nicht brauchte -- mit der Folge, dass jede Korrektur zweimal gemacht werden
musste (und ``licensing.py`` byte-identisch gehalten werden *musste*).
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from flask import Flask
from tesm_core.app_factory import create_base_app
from tesm_core.config import load_core_config
from tesm_core.extension import core
from tesm_core.views import trash as trash_views
from .navigation import NAVIGATION
from .permissions import PERMISSIONS, ROLE_PRESETS
from .schema import LICENSE_MIGRATIONS
from .settings_spec import LICENSE_SERVER_SETTINGS
__version__ = "2.0.0"
APP_KEY = "tesm-license"
APP_NAME = "TESM-Lizenzserver"
APP_SHORT = "TL"
APP_EDITION = "Lizenzverwaltung"
ENV_PREFIX = "TESM_LICENSE_"
logger = logging.getLogger(__name__)
def master_key_path(config: Any) -> Path:
return Path(config.data_dir) / "master_signing_key.json"
def create_app(**overrides: Any) -> Flask:
config = load_core_config(
app_key=APP_KEY,
app_name=APP_NAME,
env_prefix=ENV_PREFIX,
default_bind="127.0.0.1:5001",
)
app = create_base_app(
config=config,
permissions=PERMISSIONS,
nav=NAVIGATION,
role_presets=ROLE_PRESETS,
app_migrations={"tesm_license": LICENSE_MIGRATIONS},
settings_specs=LICENSE_SERVER_SETTINGS,
import_name="tesm_license",
app_short=APP_SHORT,
app_edition=APP_EDITION,
app_version=__version__,
enable_license=True,
)
_register_blueprints(app)
_register_registry(app)
_startup_tasks(app)
if overrides:
app.config.update(overrides)
return app
def _register_blueprints(app: Flask) -> None:
from .blueprints import (
api,
backup,
customers,
dashboard,
licenses,
own_license,
portal,
selfservice,
settings,
tickets,
)
app.register_blueprint(dashboard.bp)
app.register_blueprint(customers.bp)
app.register_blueprint(tickets.bp)
app.register_blueprint(licenses.bp)
app.register_blueprint(api.bp)
app.register_blueprint(portal.bp)
app.register_blueprint(selfservice.bp)
app.register_blueprint(own_license.bp)
app.register_blueprint(settings.bp)
app.register_blueprint(backup.bp)
# Die Uebersicht ist die Startseite. Anders als bei TESM verlangt sie eine
# Anmeldung: das hier ist eine interne Verwaltungsanwendung, kein
# Informationsboard fuer das Netz.
app.add_url_rule("/", "index", dashboard.index_view)
def _register_registry(app: Flask) -> None:
from tesm_core.system import hostnet # noqa: F401 - haelt das Modul geladen
from .services import issuance, mail, reporting
from .services.monitorlog import LiveLog
extension = core(app)
# Beide Anwendungen koennen auf demselben Host laufen -- eigener Port je Dienst.
extension.registry["upstream"] = extension.config.bind
extension.registry["static_root"] = str(extension.config.base_dir / "static")
extension.registry["websocket_path"] = ""
extension.registry["master_key_path"] = master_key_path(extension.config)
extension.registry["live_log"] = LiveLog(extension.config.log_dir / "live.log")
extension.registry["status_pill_builder"] = _status_pills
extension.registry["reencrypt_secrets"] = _reencrypt_secrets
# Als Sub-Lizenzserver meldet diese Instanz ihren Bestand an ihren Master.
if extension.license is not None:
extension.license.set_report_builder(reporting.build_own_report)
def _reencrypt_secrets(conn: Any, keystore: Any) -> int:
"""Verschluesselt gespeicherte Geheimnisse unter dem neuen Datenschluessel neu."""
from tesm_core.auth.directory import AAD_LDAP_BIND, SERVICE_PURPOSE as LDAP_PURPOSE
from tesm_core.db import Database
from tesm_core.keystore import DecryptionError
from .services.mail import AAD_GRAPH_SECRET, SERVICE_PURPOSE as MAIL_PURPOSE
migrated = 0
for purpose, aad in ((LDAP_PURPOSE, AAD_LDAP_BIND), (MAIL_PURPOSE, AAD_GRAPH_SECRET)):
token = Database.value(
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (purpose,)
)
if not token or not keystore.needs_reencrypt(str(token)):
continue
try:
plain = keystore.decrypt(str(token), aad=aad)
except DecryptionError:
continue
conn.execute(
"UPDATE service_accounts SET password_encrypted=? WHERE purpose=?",
(keystore.encrypt(plain, aad=aad), purpose),
)
migrated += 1
return migrated
def _status_pills(user: Any) -> list[dict[str, str]]:
if not user.is_authenticated or not user.has_any(("licenses.view",)):
return []
from tesm_core.extension import db
from .services import issuance
summary = issuance.dashboard_summary(db())
critical = len(summary["buckets"]["red"])
if not critical:
return []
return [
{
"label": f"{critical} Lizenz(en) kritisch",
"css_class": "status-pill--danger",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": "Weniger als 30 Tage Restlaufzeit oder bereits abgelaufen.",
}
]
def _startup_tasks(app: Flask) -> None:
extension = core(app)
if not extension.config.web_process:
logger.info("Startaufgaben uebersprungen -- kein Webprozess.")
return
from .services import activation
with extension.database.session() as conn, extension.database.transaction(conn):
removed = activation.prune_nonces(conn)
if removed:
logger.info("%s abgelaufene Protokoll-Nonces entfernt", removed)
# Auch der Lizenzserver hat eine eigene Lizenz (Rolle master oder
# license_server) und meldet sich bei einem Master, falls es einen gibt.
if extension.license is not None:
import socket
extension.license.start_heartbeat(hostname=socket.gethostname())
app = None # wird von wsgi.py gesetzt
@@ -0,0 +1 @@
"""Blueprints des Lizenzservers."""
@@ -0,0 +1,115 @@
"""Maschinen-API: Aktivierung, Deaktivierung, Heartbeat.
Diese Endpunkte werden nicht ueber Cookies autorisiert, sondern ueber die
Ed25519-Signatur der Anfrage -- darum sind sie vom CSRF-Schutz ausgenommen. Das
ist die einzige Ausnahme in der ganzen Anwendung, und sie ist es nur, weil hier
gar kein Cookie im Spiel ist.
Zusaetzlich greift eine Ratenbegrenzung pro Absenderadresse: ohne sie waere der
Endpunkt ein kostenloser Weg, Argon2- und Ed25519-Rechenzeit zu verbrennen.
"""
from __future__ import annotations
import logging
from flask import Blueprint, jsonify, request
from tesm_core.extension import core, db, transaction
from tesm_core.security import csrf, ratelimit
from ..services import activation
from .common import signing_key
logger = logging.getLogger(__name__)
bp = Blueprint("api", __name__, url_prefix="/api/v1/licenses")
RATE_BUCKET = "api:license"
RATE_LIMIT = 60
RATE_WINDOW_MINUTES = 5
MAX_BODY_BYTES = 512 * 1024
def _client_ip() -> str:
from tesm_core.auth.service import client_ip
return client_ip() or "unbekannt"
def _rate_limited() -> bool:
conn = db()
verdict = ratelimit.check(
conn,
RATE_BUCKET,
_client_ip(),
limit=RATE_LIMIT,
window_minutes=RATE_WINDOW_MINUTES,
)
with transaction():
ratelimit.record(conn, RATE_BUCKET, _client_ip())
return verdict.blocked
def _handle(action: str):
if _rate_limited():
return (
jsonify({"error": "rate_limited", "message": "Zu viele Anfragen."}),
429,
{"Retry-After": str(RATE_WINDOW_MINUTES * 60)},
)
if request.content_length and request.content_length > MAX_BODY_BYTES:
return jsonify({"error": "too_large", "message": "Anfrage zu gross."}), 413
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return jsonify({"error": "bad_request", "message": "JSON-Objekt erwartet."}), 400
processor = activation.PROCESSORS[action]
key = signing_key()
conn = db()
with transaction():
outcome = processor(conn, payload, master_private_key=key.private_key)
live = core().registry.get("live_log")
if live is not None:
target = str(payload.get("license_id") or "unbekannt")[:12]
if outcome.ok:
live.info("api", f"{action} von {target} -- {outcome.response.get('status')}")
else:
live.warning(
"api", f"{action} von {target} abgewiesen: {outcome.response.get('error')}"
)
return jsonify(outcome.response), outcome.status_code
@bp.post("/activate")
@csrf.exempt
def activate():
return _handle("activate")
@bp.post("/deactivate")
@csrf.exempt
def deactivate():
return _handle("deactivate")
@bp.post("/heartbeat")
@csrf.exempt
def heartbeat():
return _handle("heartbeat")
@bp.get("/hinweis")
def info():
"""Kurze, oeffentliche Selbstbeschreibung -- ohne verwertbare Details."""
import tesm_licensing as lic
return jsonify(
{
"service": "tesm-license",
"protocol": lic.FORMAT_VERSION,
"actions": list(lic.ACTIONS),
}
)
@@ -0,0 +1,201 @@
"""Sicherung und Uebertragung des Lizenzservers."""
from __future__ import annotations
from datetime import datetime
from flask import Blueprint, Response, flash, redirect, render_template, request, session, url_for
from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required, reauth_required
from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction
from ..services import transfer
from .common import log_event
bp = Blueprint("backup", __name__, url_prefix="/sicherung")
MAX_IMPORT_BYTES = 64 * 1024 * 1024
PREVIEW_SESSION_KEY = "_import_token"
def _allowed_categories() -> list[transfer.Category]:
allowed: list[transfer.Category] = []
for category in transfer.CATEGORIES:
if category.admin_only and not current_user.is_admin:
continue
if category.sensitive and not current_user.has("backup.secrets"):
continue
allowed.append(category)
return allowed
@bp.get("/")
@permission_required("backup.view", "backup.export", "backup.execute", mode="any")
def overview():
conn = db()
preview = None
token = session.get(PREVIEW_SESSION_KEY, "")
if token:
try:
payload = transfer.load_preview(conn, token, actor=current_user.username)
preview = {"token": token, "summary": transfer.summarize(payload)}
except transfer.TransferError:
session.pop(PREVIEW_SESSION_KEY, None)
return render_template(
"license/backup.html",
categories=_allowed_categories(),
all_categories=transfer.CATEGORIES,
preview=preview,
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
preview_ttl=transfer.PREVIEW_TTL_MINUTES,
can_secrets=current_user.has("backup.secrets"),
signing_key_path=str(core().registry["master_key_path"]),
)
@bp.post("/export")
@permission_required("backup.export")
@reauth_required
def export():
conn = db()
allowed = {category.key for category in _allowed_categories()}
chosen = [key for key in request.form.getlist("categories") if key in allowed]
if not chosen:
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
return redirect(url_for("backup.overview"))
passphrase = request.form.get("passphrase") or ""
if passphrase != (request.form.get("passphrase_repeat") or ""):
flash("Die beiden Passphrasen stimmen nicht ueberein.", "danger")
return redirect(url_for("backup.overview"))
try:
envelope = transfer.seal(transfer.build_export(conn, categories=chosen), passphrase)
except transfer.TransferError as exc:
flash(str(exc), "danger")
return redirect(url_for("backup.overview"))
log_event(
"backup.exported",
detail=f"Kategorien: {', '.join(chosen)}",
severity="warning",
scope="sicherung",
)
stamp = datetime.now().strftime("%Y%m%d-%H%M")
return Response(
envelope.encode("utf-8"),
mimetype="application/json",
headers={
"Content-Disposition": f'attachment; filename="tesm-lizenzserver-{stamp}.json"',
"Cache-Control": "no-store",
},
)
@bp.post("/import/vorschau")
@permission_required("backup.execute")
@reauth_required
def import_preview():
conn = db()
upload = request.files.get("backup_file")
if upload is None or not upload.filename:
flash("Keine Datei ausgewaehlt.", "danger")
return redirect(url_for("backup.overview"))
raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
if len(raw) > MAX_IMPORT_BYTES:
flash("Die Datei ist zu gross.", "danger")
return redirect(url_for("backup.overview"))
try:
payload, header = transfer.unseal(raw, request.form.get("passphrase") or "")
except transfer.TransferError as exc:
flash(str(exc), "danger")
return redirect(url_for("backup.overview"))
with transaction():
token = transfer.store_preview(conn, payload, actor=current_user.username)
session[PREVIEW_SESSION_KEY] = token
log_event(
"backup.import_previewed",
detail=f"Erstellt {header.get('created_at')}",
severity="notice",
scope="sicherung",
)
flash(
f"Datei geoeffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
"Uebernahme zu bestaetigen.",
"success",
)
return redirect(url_for("backup.overview"))
@bp.post("/import/uebernehmen")
@permission_required("backup.execute")
@reauth_required
def import_apply():
extension = core()
conn = db()
token = session.get(PREVIEW_SESSION_KEY, "")
if not token:
flash("Es liegt keine geoeffnete Sicherung vor.", "danger")
return redirect(url_for("backup.overview"))
allowed = {category.key for category in _allowed_categories()}
chosen = [key for key in request.form.getlist("categories") if key in allowed]
if not chosen:
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
return redirect(url_for("backup.overview"))
try:
payload = transfer.load_preview(conn, token, actor=current_user.username)
except transfer.TransferError as exc:
session.pop(PREVIEW_SESSION_KEY, None)
flash(str(exc), "danger")
return redirect(url_for("backup.overview"))
with transaction():
report = transfer.apply_import(
conn,
payload,
categories=chosen,
set_setting=lambda key, value: extension.settings.set(
conn, key, value, actor=current_user.username
),
)
transfer.drop_preview(conn, token)
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="backup.imported",
detail="; ".join(
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
f"{counts['skipped']} uebersprungen"
for key, counts in report.items()
),
ip=client_ip(),
severity="warning",
)
session.pop(PREVIEW_SESSION_KEY, None)
lines = [
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
f"{counts['updated']} aktualisiert"
+ (f", {counts['skipped']} uebersprungen" if counts["skipped"] else "")
for key, counts in report.items()
if key in transfer.CATEGORY_BY_KEY
]
flash("Uebernahme abgeschlossen. " + " | ".join(lines), "success")
return redirect(url_for("backup.overview"))
@bp.post("/import/verwerfen")
@permission_required("backup.execute")
def import_discard():
token = session.pop(PREVIEW_SESSION_KEY, "")
if token:
with transaction() as conn:
transfer.drop_preview(conn, token)
flash("Zwischenstand verworfen.", "info")
return redirect(url_for("backup.overview"))
@@ -0,0 +1,135 @@
"""Gemeinsame Helfer der Lizenzserver-Blueprints."""
from __future__ import annotations
import json
from typing import Any
from urllib.parse import urljoin
from flask import url_for
from tesm_core import audit
from tesm_core.auth.models import current_user
from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction
import tesm_licensing as lic
from ..services import master_key
def log_event(
action: str,
*,
target: str = "",
detail: str = "",
severity: str = "info",
scope: str = "lizenz",
) -> None:
"""Schreibt ins Aenderungsprotokoll **und** ins laufende Protokoll."""
extension = core()
with transaction() as conn:
audit.log(
conn,
actor=current_user.username or "system",
actor_id=getattr(current_user, "id", None) or None,
action=action,
target=target,
detail=detail,
ip=client_ip(),
severity=severity,
)
live = extension.registry.get("live_log")
if live is not None:
text = f"{action} {target}".strip() + (f" -- {detail}" if detail else "")
if severity in {"warning", "critical"}:
live.warning(scope, text)
else:
live.info(scope, text)
def signing_key() -> master_key.MasterKey:
"""Signaturschluessel dieser Installation (erzeugt ihn beim ersten Aufruf)."""
return master_key.ensure(core().registry["master_key_path"])
def vendor_info() -> dict[str, Any]:
"""Anbieterdaten, wie sie in jede Lizenz eingebettet werden."""
values = core().settings.get_many(
db(),
[
"vendor_name",
"vendor_email",
"vendor_phone",
"vendor_address",
"vendor_logo_base64",
],
)
return {
"name": str(values["vendor_name"] or ""),
"email": str(values["vendor_email"] or ""),
"phone": str(values["vendor_phone"] or ""),
"address": str(values["vendor_address"] or ""),
"logo_base64": str(values["vendor_logo_base64"] or ""),
}
def master_endpoint() -> str:
return str(core().settings.get(db(), "master_endpoint") or "").rstrip("/")
def customer_facing_url(endpoint: str, **values: Any) -> str:
"""Baut Links, die ein Kunde tatsaechlich oeffnen soll.
``url_for(..., _external=True)`` nimmt den Host-Header der aktuellen
Anfrage. Bei einem intern angemeldeten Administrator ist das oft eine interne
IP -- fuer den Browser oder das Mailprogramm eines Kunden unbrauchbar. Darum
kommt die Basis aus der Einstellung ``master_endpoint``; nur wenn die leer
ist (frische Installation), faellt es auf das Host-Header-Verhalten zurueck.
"""
base = master_endpoint()
if not base:
return url_for(endpoint, _external=True, **values)
return urljoin(base + "/", url_for(endpoint, **values).lstrip("/"))
def can_issue_license_server_licenses() -> bool:
"""Darf diese Instanz Lizenzen fuer weitere Lizenzserver ausstellen?
Das haengt an der Rolle der **eigenen** Lizenzdatei, nicht an einem
Gruppenrecht. Ein Gruppenrecht koennte sich ein lokaler Administrator eines
Sub-Lizenzservers selbst einraeumen; eine von woanders signierte Lizenzdatei
nicht. Zusaetzlich verlangt es echte Administratorrechte -- das Ausstellen
einer Lizenzserver-Lizenz erzeugt eine neue Vertrauensgrenze und ist damit
keine delegierbare Alltagsaufgabe.
"""
manager = core().license
if manager is None:
return bool(getattr(current_user, "is_admin", False))
return bool(
getattr(current_user, "is_admin", False)
and manager.active()
and manager.state.role == "master"
)
def module_labels() -> dict[str, str]:
return dict(lic.MODULE_LABELS)
def type_labels() -> dict[str, str]:
return dict(lic.LICENSE_TYPE_LABELS)
def role_labels() -> dict[str, str]:
return dict(lic.ROLE_LABELS)
def license_filename(license_id: str, customer_name: str) -> str:
safe = "".join(
char if char.isalnum() or char in "-_" else "-" for char in (customer_name or "kunde")
).strip("-")
return f"tesm-lizenz-{safe.lower() or 'kunde'}-{license_id[:8]}.json"
def bundle_bytes(bundle: dict[str, Any]) -> bytes:
return (json.dumps(bundle, indent=2, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")
@@ -0,0 +1,126 @@
"""Kunden: Liste, Detailueberblick, Pflege."""
from __future__ import annotations
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from tesm_core.auth.models import current_user, permission_required
from tesm_core.extension import db, transaction
from ..services import issuance, reporting
from .common import customer_facing_url, log_event
bp = Blueprint("customers", __name__, url_prefix="/kunden")
FIELDS = (
"name",
"contact_email",
"contact_phone",
"contact_person",
"street",
"house_number",
"postal_code",
"city",
"notes",
)
def _form() -> dict[str, str]:
return {key: request.form.get(key, "") for key in FIELDS}
@bp.get("/")
@permission_required("customers.view")
def overview():
return render_template("license/customers.html", customers=issuance.list_customers(db()))
@bp.get("/<int:customer_id>")
@permission_required("customers.view")
def detail(customer_id: int):
conn = db()
customer = issuance.get_customer(conn, customer_id)
if customer is None:
abort(404)
tickets = issuance.list_tickets(conn, customer_id=customer_id)
licenses = [
{**row, **issuance.display_state(row)}
for row in issuance.list_licenses(conn, customer_id=customer_id)
]
reported = (
reporting.reported_for_customer(conn, customer_id)
if current_user.has("reports.view")
else {"customers": [], "source_license_id": ""}
)
return render_template(
"license/customer_detail.html",
customer=customer,
tickets=tickets,
licenses=licenses,
reported=reported,
portal_url=customer_facing_url("portal.request_link"),
)
@bp.post("/neu")
@permission_required("customers.create")
def create():
try:
with transaction() as conn:
customer_id = issuance.create_customer(
conn, actor=current_user.username, **_form()
)
log_event(
"customer.created",
target=request.form.get("name", ""),
severity="notice",
scope="kunde",
)
flash("Kunde angelegt.", "success")
return redirect(url_for("customers.detail", customer_id=customer_id))
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("customers.overview"))
@bp.post("/<int:customer_id>")
@permission_required("customers.edit")
def update(customer_id: int):
try:
with transaction() as conn:
changes = issuance.update_customer(conn, customer_id, **_form())
if changes:
log_event(
"customer.updated",
target=request.form.get("name", str(customer_id)),
detail=", ".join(changes),
severity="notice",
scope="kunde",
)
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.",
"success" if changes else "info")
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("customers.detail", customer_id=customer_id))
@bp.post("/<int:customer_id>/loeschen")
@permission_required("customers.delete")
def delete(customer_id: int):
conn = db()
customer = issuance.get_customer(conn, customer_id)
if customer is None:
abort(404)
try:
with transaction():
issuance.delete_customer(conn, customer_id)
log_event(
"customer.deleted",
target=str(customer["name"]),
severity="warning",
scope="kunde",
)
flash("Kunde geloescht.", "success")
return redirect(url_for("customers.overview"))
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("customers.detail", customer_id=customer_id))
@@ -0,0 +1,40 @@
"""Uebersicht -- die Startseite des Lizenzservers.
Anders als TESMs Geraete-Dashboard verlangt sie eine Anmeldung: das hier ist
eine interne Verwaltungsanwendung. Die Anmeldeseite ist der einzige sinnvolle
erste Einstiegspunkt.
"""
from __future__ import annotations
from flask import Blueprint, render_template
from tesm_core.auth.models import current_user, login_required
from tesm_core.extension import core, db
from ..services import issuance, reporting
bp = Blueprint("dashboard", __name__)
@bp.get("/uebersicht")
@login_required
def index():
conn = db()
extension = core()
if not current_user.has("licenses.view"):
# Leere Huelle statt 403: die Startseite soll fuer jeden erreichbar
# bleiben, auch fuer ein Konto ohne Lizenzrechte.
return render_template("license/dashboard.html", summary=None, sources=[])
summary = issuance.dashboard_summary(conn)
return render_template(
"license/dashboard.html",
summary=summary,
sources=reporting.reporting_sources(conn) if current_user.has("reports.view") else [],
own_role=(extension.license.state.role if extension.license else "master"),
type_labels=dict(),
)
def index_view():
return index()
@@ -0,0 +1,453 @@
"""Lizenzen: ausstellen, herunterladen, versenden, widerrufen, Offline-Codes."""
from __future__ import annotations
import json
from flask import (
Blueprint,
Response,
abort,
flash,
redirect,
render_template,
request,
url_for,
)
from tesm_core.auth.models import (
admin_required,
current_user,
permission_required,
reauth_required,
)
from tesm_core.extension import core, db, transaction
import tesm_licensing as lic
from ..services import activation, issuance, mail, reporting
from .common import (
bundle_bytes,
customer_facing_url,
license_filename,
log_event,
master_endpoint,
module_labels,
role_labels,
signing_key,
type_labels,
vendor_info,
)
bp = Blueprint("licenses", __name__, url_prefix="/lizenzen")
@bp.get("/")
@permission_required("licenses.view")
def overview():
conn = db()
rows = [
{**row, **issuance.display_state(row)}
for row in issuance.list_licenses(
conn,
customer_id=request.args.get("kunde", type=int),
ticket_id=request.args.get("ticket"),
status=request.args.get("status"),
)
]
return render_template(
"license/licenses.html",
licenses=rows,
type_labels=type_labels(),
role_labels=role_labels(),
states=issuance.LICENSE_STATES,
active_filter=request.args.get("status", ""),
)
@bp.get("/<license_id>")
@permission_required("licenses.view")
def detail(license_id: str):
conn = db()
row = issuance.get_license(conn, license_id)
if row is None:
abort(404)
from tesm_core.db import Database
document = json.loads(str(row["license_document"]))
return render_template(
"license/license_detail.html",
license=row,
state=issuance.display_state(row),
document=document,
document_json=json.dumps(document, indent=2, ensure_ascii=False, sort_keys=True),
deliveries=Database.all(
conn,
"SELECT * FROM license_deliveries WHERE license_id=? ORDER BY delivered_at DESC",
(license_id,),
),
modules=json.loads(str(row["modules"] or "[]")),
module_labels=module_labels(),
type_labels=type_labels(),
role_labels=role_labels(),
mail_ready=mail.is_enabled(conn),
all_modules=lic.ALL_MODULES,
)
# --------------------------------------------------------------------------- #
# Ausstellung
# --------------------------------------------------------------------------- #
@bp.post("/aus-ticket/<ticket_id>")
@permission_required("licenses.create")
def issue(ticket_id: str):
conn = db()
as_trial = request.form.get("as_trial") == "1"
key = signing_key()
try:
with transaction():
result = issuance.issue_from_ticket(
conn,
ticket_id,
master_private_key=key.private_key,
master_public_key=key.public_key,
master_endpoint=master_endpoint(),
vendor=vendor_info(),
actor=current_user.username,
keystore=core().keystore,
as_trial=as_trial,
)
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
if result.already_existed:
flash(
"Zu diesem Ticket war bereits eine Lizenz offen -- sie wird angezeigt statt eine "
"zweite auszustellen.",
"info",
)
else:
log_event(
"license.issued",
target=result.license_id[:12],
detail=("Trial-Ausstellung" if as_trial else "regulaer"),
severity="notice",
)
reporting.report_soon(core())
flash("Lizenz ausgestellt.", "success")
return redirect(url_for("licenses.detail", license_id=result.license_id))
@bp.get("/<license_id>/datei")
@permission_required("licenses.export")
@reauth_required
def download(license_id: str):
"""Laedt das vollstaendige Auslieferungs-Bundle herunter.
Es enthaelt den privaten Client-Schluessel der Lizenz -- damit ist es ein
Geheimnis. Deshalb: eigenes Recht, frische Passwortbestaetigung, Eintrag im
Zustellprotokoll und im Aenderungsprotokoll.
"""
conn = db()
row = issuance.get_license(conn, license_id)
if row is None:
abort(404)
try:
bundle = issuance.delivery_bundle(conn, license_id, keystore=core().keystore)
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
with transaction():
issuance.record_delivery(
conn,
license_id=license_id,
channel="download",
recipient=current_user.username,
actor=current_user.username,
detail="Vollstaendiges Bundle" if "client_key" in bundle else "Nur Lizenzdokument",
)
log_event("license.downloaded", target=license_id[:12], severity="notice")
return Response(
bundle_bytes(bundle),
mimetype="application/json",
headers={
"Content-Disposition": (
f'attachment; filename="{license_filename(license_id, str(row["customer_name"]))}"'
),
"Cache-Control": "no-store",
},
)
@bp.post("/<license_id>/neu-signieren")
@permission_required("licenses.edit")
@reauth_required
def resign(license_id: str):
"""Signiert eine neue Revision -- z. B. nach einer Modulerweiterung.
Der Client bekommt sie beim naechsten Heartbeat automatisch, ohne erneute
Aktivierung.
"""
conn = db()
row = issuance.get_license(conn, license_id)
if row is None:
abort(404)
modules = sorted(set(request.form.getlist("modules")) & set(lic.ALL_MODULES))
changes: dict[str, object] = {}
if str(row["role"]) == "customer":
changes["modules"] = modules
if request.form.get("extend_days"):
try:
days = int(request.form["extend_days"])
except ValueError:
flash("Die Verlaengerung muss eine Zahl sein.", "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
if days > 0:
document = json.loads(str(row["license_document"]))
base = lic.parse_iso(str(document["expires_at"]))
from datetime import timedelta
changes["expires_at"] = lic.iso(base + timedelta(days=days))
if not changes:
flash("Es wurde keine Aenderung angegeben.", "info")
return redirect(url_for("licenses.detail", license_id=license_id))
key = signing_key()
try:
with transaction():
updated = issuance.resign_license(
conn, license_id, master_private_key=key.private_key, changes=changes
)
except (issuance.IssuanceError, ValueError) as exc:
flash(str(exc), "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
log_event(
"license.resigned",
target=license_id[:12],
detail=f"Revision {updated['seq']}: " + ", ".join(sorted(changes)),
severity="notice",
)
reporting.report_soon(core())
flash(
f"Neue Revision {updated['seq']} signiert. Der Client erhaelt sie beim naechsten "
"Heartbeat.",
"success",
)
return redirect(url_for("licenses.detail", license_id=license_id))
@bp.post("/<license_id>/widerrufen")
@permission_required("licenses.edit")
def revoke(license_id: str):
conn = db()
try:
with transaction():
issuance.revoke_license(conn, license_id, reason=request.form.get("reason", ""))
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
log_event(
"license.revoked",
target=license_id[:12],
detail=request.form.get("reason", ""),
severity="warning",
)
reporting.report_soon(core())
flash(
"Lizenz widerrufen. Das Ticket bleibt offen -- eine neue Lizenz kann sofort "
"ausgestellt werden.",
"success",
)
return redirect(url_for("licenses.detail", license_id=license_id))
@bp.post("/<license_id>/loeschen")
@permission_required("licenses.delete")
def delete(license_id: str):
conn = db()
try:
with transaction():
issuance.delete_license(conn, license_id)
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
log_event("license.deleted", target=license_id[:12], severity="warning")
reporting.report_soon(core())
flash("Lizenz geloescht.", "success")
return redirect(url_for("licenses.overview"))
# --------------------------------------------------------------------------- #
# Versand
# --------------------------------------------------------------------------- #
@bp.post("/<license_id>/versenden")
@permission_required("licenses.export")
def send_mail(license_id: str):
conn = db()
row = issuance.get_license(conn, license_id)
if row is None:
abort(404)
recipient = (request.form.get("recipient") or str(row["contact_email"] or "")).strip()
if not recipient:
flash("Es ist keine Empfaengeradresse hinterlegt.", "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
try:
bundle = issuance.delivery_bundle(conn, license_id, keystore=core().keystore)
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
vendor = vendor_info()
is_customer = str(row["role"]) == "customer"
subject = (
"Ihre TESM-Lizenz" if is_customer else "Ihre TESM-Lizenzserver-Lizenz"
)
lines = [
f"Guten Tag{(' ' + str(row['contact_person'])) if row['contact_person'] else ''},",
"",
"im Anhang finden Sie Ihre Lizenzdatei.",
"",
f"Lizenz-ID: {license_id}",
f"Typ: {type_labels().get(str(row['type']), str(row['type']))}",
f"Gueltig bis: {row['expires_at']}"
+ (" (unbefristet)" if row["lifetime"] else ""),
]
if is_customer and row["ticket_id"]:
# Self-Service- und Portallinks nur fuer Kundenlizenzen: fuer andere
# Rollen sind diese Wege ohnehin gesperrt.
lines += [
"",
"Ihre Lizenz verwalten (herunterladen, bei einem Systemwechsel freigeben):",
customer_facing_url("selfservice.overview", ticket_id=str(row["ticket_id"])),
"",
"Ihre Stammdaten pflegen:",
customer_facing_url("portal.request_link"),
]
lines += ["", "Freundliche Gruesse", vendor.get("name") or "Ihr Lizenzgeber"]
if vendor.get("phone"):
lines.append(f"Telefon: {vendor['phone']}")
if vendor.get("email"):
lines.append(f"E-Mail: {vendor['email']}")
try:
mail.send(
conn,
to=recipient,
subject=subject,
body="\n".join(lines),
attachment=mail.Attachment(
name=license_filename(license_id, str(row["customer_name"])),
content=bundle_bytes(bundle),
),
)
except mail.MailError as exc:
flash(f"Versand fehlgeschlagen: {exc}", "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
with transaction():
issuance.record_delivery(
conn,
license_id=license_id,
channel="email",
recipient=recipient,
actor=current_user.username,
)
log_event("license.sent", target=license_id[:12], detail=recipient, severity="notice")
flash(f"Lizenz an {recipient} versendet.", "success")
return redirect(url_for("licenses.detail", license_id=license_id))
# --------------------------------------------------------------------------- #
# Offline-Codes
# --------------------------------------------------------------------------- #
@bp.route("/offline-code", methods=["GET", "POST"])
@permission_required("licenses.execute")
def manual_code():
"""Verarbeitet einen Offline-Code des Kunden.
Bewusst dieselben Verarbeitungsfunktionen wie die JSON-API: identische
Signatur-, Replay- und Fingerprint-Pruefung, nur ein anderer Transportweg.
"""
response_code = ""
request_summary: dict[str, object] = {}
message = ""
ok = False
if request.method == "POST":
conn = db()
raw = request.form.get("code", "")
try:
payload = lic.decode_code(raw)
except ValueError as exc:
flash(f"Der Code ist unlesbar: {exc}", "danger")
return render_template("license/manual_code.html", response_code="", summary={})
request_summary = {
"license_id": payload.get("license_id"),
"action": payload.get("action"),
"fingerprint": str(payload.get("fingerprint") or "")[:24] + "...",
"hostname": payload.get("hostname"),
"timestamp": payload.get("timestamp"),
}
key = signing_key()
with transaction():
outcome = activation.process(conn, payload, master_private_key=key.private_key)
if outcome.ok:
response_code = lic.encode_code(outcome.response)
ok = True
message = "Antwortcode erzeugt. Bitte an den Kunden zuruecksenden."
log_event(
f"license.offline_{payload.get('action')}",
target=str(payload.get("license_id") or "")[:12],
detail="Offline-Code verarbeitet",
severity="notice",
)
else:
message = str(outcome.response.get("message") or "Die Anfrage wurde abgewiesen.")
flash(message, "danger")
return render_template(
"license/manual_code.html",
response_code=response_code,
summary=request_summary,
message=message,
ok=ok,
)
# --------------------------------------------------------------------------- #
# Sub-Lizenzserver
# --------------------------------------------------------------------------- #
@bp.get("/sub-lizenzserver")
@permission_required("reports.view")
def reports():
conn = db()
sources = reporting.reporting_sources(conn)
return render_template(
"license/reports.html",
sources=sources,
heartbeat_hours=lic.HEARTBEAT_INTERVAL_SECONDS // 3600,
)
@bp.get("/sub-lizenzserver/<license_id>")
@permission_required("reports.view")
def report_detail(license_id: str):
conn = db()
row = issuance.get_license(conn, license_id)
if row is None or str(row["role"]) != "license_server":
abort(404)
reported = reporting.reported_for_customer(conn, int(row["customer_id"]))
return render_template(
"license/report_detail.html", license=row, reported=reported
)
@@ -0,0 +1,183 @@
"""Die eigene Lizenz dieses Lizenzservers.
Auch ein Lizenzserver braucht eine Lizenz -- sie entscheidet ueber seine Rolle:
* ``master``: Wurzelinstanz. Nur sie darf Lizenzen fuer weitere Lizenzserver
ausstellen. Diese Rolle entsteht ausschliesslich lokal ueber
``tesm-license-admin bootstrap`` -- es gibt keine hoehere Instanz, die sie
bestaetigen koennte, und die Ticket-Oberflaeche bietet sie an keiner Stelle an.
* ``license_server``: von einem Master ausgestellter Sub-Lizenzserver. Er darf
alles, was ein Lizenzserver koennen soll -- **ausser** selbst weitere
Lizenzserver-Lizenzen auszustellen. Diese Grenze haengt an der signierten
Rolle der Lizenzdatei, nicht an einem Gruppenrecht.
Die Seite bleibt immer erreichbar, auch ohne gueltige Lizenz -- sonst liesse
sich keine neue einspielen.
"""
from __future__ import annotations
import socket
from flask import Blueprint, flash, redirect, render_template, request, url_for
from tesm_core.auth.models import (
admin_required,
current_user,
permission_required,
reauth_required,
)
from tesm_core.db import Database
from tesm_core.extension import core, db, transaction
from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError
import tesm_licensing as lic
from ..services import master_key
from .common import can_issue_license_server_licenses, log_event, signing_key
bp = Blueprint("own_license", __name__, url_prefix="/eigene-lizenz")
MAX_UPLOAD_BYTES = 256 * 1024
@bp.get("/")
@permission_required("settings_license.view")
def overview():
extension = core()
conn = db()
manager = extension.license
state = manager.reload(conn) if manager else None
pending = manager._pending(conn) if manager else {} # noqa: SLF001 - eigenes Paket
return render_template(
"license/own_license.html",
state=state,
pending=pending,
features=LICENSED_FEATURES,
role_labels=dict(lic.ROLE_LABELS),
type_labels=dict(lic.LICENSE_TYPE_LABELS),
fingerprint=lic.system_fingerprint(),
hostname=socket.gethostname(),
trusted_keys=Database.all(
conn, "SELECT * FROM trusted_issuer_keys ORDER BY added_at DESC"
),
signing_key_health=master_key.health(extension.registry["master_key_path"]),
may_issue_license_server=can_issue_license_server_licenses(),
grace_days=lic.GRACE_PERIOD_DAYS,
)
@bp.post("/einspielen")
@permission_required("settings_license.edit")
def upload():
extension = core()
manager = extension.license
if manager is None:
flash("Diese Installation nutzt keine Lizenzierung.", "info")
return redirect(url_for("own_license.overview"))
upload_file = request.files.get("license_file")
if upload_file is None or not upload_file.filename:
flash("Keine Datei ausgewaehlt.", "danger")
return redirect(url_for("own_license.overview"))
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
if len(raw) > MAX_UPLOAD_BYTES:
flash("Die Datei ist zu gross fuer eine Lizenzdatei.", "danger")
return redirect(url_for("own_license.overview"))
try:
with transaction() as conn:
state = manager.import_bundle(
conn,
raw,
actor=current_user.username,
allow_new_issuer=request.form.get("allow_new_issuer") == "1"
and current_user.is_admin,
)
except LicenseError as exc:
flash(str(exc), "danger")
return redirect(url_for("own_license.overview"))
log_event(
"own_license.imported",
target=(state.status.license_id[:12] if state.status else ""),
detail=f"Rolle {state.role}",
severity="warning",
scope="eigene",
)
flash("Lizenz eingespielt. Bitte jetzt aktivieren.", "success")
return redirect(url_for("own_license.overview"))
def _handshake(action: str):
manager = core().license
if manager is None:
return redirect(url_for("own_license.overview"))
try:
with transaction() as conn:
result = manager.handshake(
conn, action, actor=current_user.username, hostname=socket.gethostname()
)
except LicenseError as exc:
flash(str(exc), "danger")
return redirect(url_for("own_license.overview"))
flash(
result["message"],
"success" if result["ok"] else ("warning" if result["transport"] == "offline" else "danger"),
)
return redirect(url_for("own_license.overview"))
@bp.post("/aktivieren")
@permission_required("settings_license.edit")
def activate():
return _handshake("activate")
@bp.post("/deaktivieren")
@permission_required("settings_license.edit")
@reauth_required
def deactivate():
return _handshake("deactivate")
@bp.post("/heartbeat")
@permission_required("settings_license.edit")
def heartbeat():
return _handshake("heartbeat")
@bp.post("/offline-antwort")
@permission_required("settings_license.edit")
def offline_response():
manager = core().license
if manager is None:
return redirect(url_for("own_license.overview"))
try:
with transaction() as conn:
result = manager.submit_offline_response(
conn, request.form.get("code", ""), actor=current_user.username
)
except LicenseError as exc:
flash(str(exc), "danger")
return redirect(url_for("own_license.overview"))
flash(result["message"], "success" if result["ok"] else "danger")
return redirect(url_for("own_license.overview"))
@bp.get("/signaturschluessel")
@permission_required("settings_license.view")
def signing_key_info():
"""Zeigt den oeffentlichen Signaturschluessel -- zum Pinnen beim Kunden.
Der oeffentliche Teil ist kein Geheimnis; er reist ohnehin in jeder Lizenz
mit. Ihn hier anzuzeigen erlaubt es, ihn unabhaengig zu vergleichen, bevor
ein Kunde ihn als vertrauenswuerdig hinterlegt.
"""
key = signing_key()
return render_template(
"license/signing_key.html",
key_id=key.key_id,
public_key=key.public_key,
created_at=key.created_at,
health=master_key.health(core().registry["master_key_path"]),
)
@@ -0,0 +1,278 @@
"""Kundenportal -- zeitlich begrenzter Zugang ohne Konto.
Zweistufig, bewusst:
1. ``/portal`` nimmt eine E-Mail-Adresse an und verschickt einen **einmal**
verwendbaren Zugangslink mit kurzer Gueltigkeit.
2. Der Link erzeugt eine Sitzung mit eigener, unabhaengiger Gueltigkeit.
Der Vorgaenger hatte zuerst einen permanenten, nie ablaufenden Link pro Kunde
(``/portal/<token>``). Das wurde als Schwachstelle erkannt und ersetzt -- hier
existiert die permanente Variante gar nicht.
Anti-Enumeration: die Antwort ist immer dieselbe, unabhaengig davon, ob die
Adresse bekannt ist. Ein Fehler beim Mailversand wird dem Anfragenden gegenueber
verschwiegen, aber serverseitig protokolliert -- sonst waere eine kaputte
Mailkonfiguration unsichtbar.
"""
from __future__ import annotations
import logging
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
from tesm_core import audit
from tesm_core.auth.service import client_ip
from tesm_core.db import Database
from tesm_core.extension import core, db, transaction
from tesm_core.security import ratelimit
from ..services import issuance, mail
from .common import customer_facing_url, vendor_info
logger = logging.getLogger(__name__)
bp = Blueprint("portal", __name__, url_prefix="/portal")
SESSION_CUSTOMER = "portal_customer_id"
SESSION_EXPIRES = "portal_expires_at"
RATE_BUCKET = "portal:request"
RATE_LIMIT = 5
RATE_WINDOW_MINUTES = 15
GENERIC_ANSWER = (
"Falls diese E-Mail-Adresse bei uns hinterlegt ist, wurde soeben eine Nachricht mit "
"einem {minutes} Minuten gueltigen Zugangslink verschickt."
)
EDITABLE_FIELDS = (
"contact_person",
"contact_phone",
"contact_email",
"street",
"house_number",
"postal_code",
"city",
)
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(value: datetime) -> str:
return value.isoformat(timespec="seconds")
def _parse(value: Any) -> datetime | None:
"""Liest einen Zeitstempel aus der Datenbank.
Bewusst geparst statt als Zeichenkette verglichen: ein Wert, der ueber
``datetime('now')`` von SQLite selbst geschrieben wurde, hat ein Leerzeichen
statt ``T`` und keine Zeitzone -- ein lexikografischer Vergleich mit unserem
ISO-Format waere dann falsch, und zwar zugunsten von "abgelaufen".
"""
if not value:
return None
text = str(value).strip().replace(" ", "T")
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
def _enabled() -> bool:
return bool(core().settings.get(db(), "portal_enabled"))
@bp.route("/", methods=["GET", "POST"])
def request_link():
extension = core()
conn = db()
if not _enabled():
return render_template("license/portal_disabled.html"), 404
minutes = int(extension.settings.get(conn, "portal_link_minutes") or 30)
if request.method == "POST":
email = (request.form.get("email") or "").strip()
verdict = ratelimit.check(
conn,
RATE_BUCKET,
client_ip() or "unbekannt",
limit=RATE_LIMIT,
window_minutes=RATE_WINDOW_MINUTES,
)
with transaction():
ratelimit.record(conn, RATE_BUCKET, client_ip() or "unbekannt")
if verdict.blocked:
# Auch hier keine abweichende Aussage -- nur eine Bremse.
flash(GENERIC_ANSWER.format(minutes=minutes), "info")
return redirect(url_for("portal.request_link"))
customer = issuance.find_customer_by_email(conn, email)
if customer is not None:
token = secrets.token_urlsafe(32)
with transaction():
conn.execute(
"INSERT INTO portal_access_tokens (token, customer_id, created_at, "
"expires_at, created_ip) VALUES (?,?,?,?,?)",
(
token,
int(customer["id"]),
_iso(_now()),
_iso(_now() + timedelta(minutes=minutes)),
client_ip() or "",
),
)
audit.log(
conn,
actor="portal",
action="portal.link_requested",
target=str(customer["name"]),
ip=client_ip(),
severity="notice",
)
access_url = customer_facing_url("portal.access", token=token)
vendor = vendor_info()
try:
mail.send(
conn,
to=str(customer["contact_email"]),
subject="Ihr Zugang zum Kundenportal",
body="\n".join(
[
f"Guten Tag{(' ' + str(customer['contact_person'])) if customer['contact_person'] else ''},",
"",
"mit dem folgenden Link erreichen Sie Ihre Daten im Kundenportal.",
f"Der Link ist {minutes} Minuten gueltig und laesst sich nur einmal verwenden:",
"",
access_url,
"",
"Wenn Sie diesen Zugang nicht angefragt haben, ignorieren Sie die Nachricht.",
"",
"Freundliche Gruesse",
vendor.get("name") or "Ihr Lizenzgeber",
]
),
)
except mail.MailError as exc:
# Bewusst nicht nach aussen tragen (Anti-Enumeration), aber
# serverseitig sichtbar machen.
logger.error("Portal-Mail an %s fehlgeschlagen: %s", email, exc)
flash(GENERIC_ANSWER.format(minutes=minutes), "info")
return redirect(url_for("portal.request_link"))
return render_template("license/portal_request.html", minutes=minutes)
@bp.get("/zugang/<token>")
def access(token: str):
extension = core()
conn = db()
if not _enabled():
return render_template("license/portal_disabled.html"), 404
row = Database.one(
conn, "SELECT * FROM portal_access_tokens WHERE token=?", (token,)
)
expires = _parse(row["expires_at"]) if row is not None else None
valid = row is not None and row["used_at"] is None and expires is not None and _now() < expires
if not valid:
flash("Dieser Link ist ungueltig oder abgelaufen.", "danger")
return redirect(url_for("portal.request_link"))
minutes = int(extension.settings.get(conn, "portal_session_minutes") or 60)
with transaction():
# Einmalverwendung erzwingen: auch innerhalb der Gueltigkeit scheitert
# ein zweiter Aufruf.
conn.execute(
"UPDATE portal_access_tokens SET used_at=? WHERE token=? AND used_at IS NULL",
(_iso(_now()), token),
)
audit.log(
conn,
actor="portal",
action="portal.session_started",
target=str(row["customer_id"]),
ip=client_ip(),
severity="notice",
)
session[SESSION_CUSTOMER] = int(row["customer_id"])
session[SESSION_EXPIRES] = _iso(_now() + timedelta(minutes=minutes))
return redirect(url_for("portal.me"))
def _current_customer() -> dict[str, Any] | None:
customer_id = session.get(SESSION_CUSTOMER)
expires = _parse(session.get(SESSION_EXPIRES))
if not customer_id or expires is None or _now() >= expires:
session.pop(SESSION_CUSTOMER, None)
session.pop(SESSION_EXPIRES, None)
return None
return issuance.get_customer(db(), int(customer_id))
@bp.route("/meine-daten", methods=["GET", "POST"])
def me():
conn = db()
if not _enabled():
return render_template("license/portal_disabled.html"), 404
customer = _current_customer()
if customer is None:
flash("Ihre Sitzung ist abgelaufen. Bitte fordern Sie einen neuen Zugangslink an.", "info")
return redirect(url_for("portal.request_link"))
if request.method == "POST":
fields = {key: request.form.get(key, "") for key in EDITABLE_FIELDS}
try:
with transaction():
changes = issuance.update_customer(
conn, int(customer["id"]), name=str(customer["name"]), **fields
)
if changes:
audit.log(
conn,
actor="portal",
action="portal.customer_updated",
target=str(customer["name"]),
detail=", ".join(changes),
ip=client_ip(),
severity="notice",
)
flash("Ihre Angaben wurden gespeichert." if changes else "Keine Aenderungen.", "success")
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("portal.me"))
tickets = issuance.list_tickets(conn, customer_id=int(customer["id"]))
licenses = {
str(row["ticket_id"]): {**row, **issuance.display_state(row)}
for row in issuance.list_licenses(conn, customer_id=int(customer["id"]))
if row["status"] in {"issued", "active"}
}
self_service_enabled = bool(core().settings.get(conn, "self_service_enabled"))
return render_template(
"license/portal_me.html",
customer=customer,
tickets=tickets,
licenses=licenses,
expires_at=session.get(SESSION_EXPIRES),
self_service_enabled=self_service_enabled,
vendor=vendor_info(),
)
@bp.post("/abmelden")
def logout():
session.pop(SESSION_CUSTOMER, None)
session.pop(SESSION_EXPIRES, None)
flash("Sie wurden abgemeldet.", "info")
return redirect(url_for("portal.request_link"))
@@ -0,0 +1,304 @@
"""Self-Service je Ticket -- ohne Anmeldung, allein ueber den Ticket-Link.
Die Berechtigung ist der Besitz der Ticket-ID: 192 Bit Zufall
(``secrets.token_urlsafe(24)``), zugleich Primaerschluessel. Damit kann ein Kunde
seine Lizenz herunterladen, bei einem Systemwechsel freigeben und neu ausstellen
lassen -- ohne Support-Anruf.
Fuer Tickets mit einer anderen Rolle als ``customer`` antworten **alle** Routen
mit 404, nicht mit "keine Berechtigung": aus der Fehlermeldung soll sich nicht
ablesen lassen, dass eine geratene ID zu einem Lizenzserver-Ticket gehoert.
"""
from __future__ import annotations
from flask import Blueprint, Response, abort, flash, redirect, render_template, request, url_for
from tesm_core import audit
from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction
from tesm_core.security import ratelimit
import tesm_licensing as lic
from ..services import activation, issuance, reporting
from .common import (
bundle_bytes,
customer_facing_url,
license_filename,
master_endpoint,
signing_key,
type_labels,
vendor_info,
)
bp = Blueprint("selfservice", __name__, url_prefix="/lizenz")
RATE_BUCKET = "selfservice"
RATE_LIMIT = 20
RATE_WINDOW_MINUTES = 10
def _ticket_or_404(ticket_id: str) -> dict:
conn = db()
if not core().settings.get(conn, "self_service_enabled"):
abort(404)
ticket = issuance.get_ticket(conn, ticket_id)
if ticket is None or str(ticket["role"]) != "customer":
abort(404)
return ticket
def _rate_guard() -> None:
conn = db()
verdict = ratelimit.check(
conn,
RATE_BUCKET,
client_ip() or "unbekannt",
limit=RATE_LIMIT,
window_minutes=RATE_WINDOW_MINUTES,
)
with transaction():
ratelimit.record(conn, RATE_BUCKET, client_ip() or "unbekannt")
if verdict.blocked:
abort(429, description="Zu viele Anfragen. Bitte spaeter erneut versuchen.")
def _needs_offline(conn, ticket_id: str): # type: ignore[no-untyped-def]
"""Die Lizenz, fuer die der Offline-Weg gerade einen Zweck hat -- sonst ``None``.
Zwei Faelle, und nur diese:
* **Noch nicht aktiviert.** Der Kunde muss aktivieren.
* **Neue Revision ausstehend.** Der Anbieter hat nachsigniert (andere
Module, laengere Laufzeit); eine Installation ohne Verbindung holt sie
sonst nie ab.
Ist die Lizenz aktiv und auf dem aktuellen Stand, gibt es nichts zu tun --
fuer einen Systemwechsel gibt es die Freigabe.
"""
for row in issuance.list_licenses(conn, ticket_id=ticket_id):
status = str(row["status"])
if status == "issued":
return row
if status == "active" and issuance.revision_pending(row):
return row
return None
def _log(action: str, ticket: dict, detail: str = "", severity: str = "notice") -> None:
extension = core()
with transaction() as conn:
audit.log(
conn,
actor="self-service",
action=action,
target=str(ticket["customer_name"]),
detail=detail,
ip=client_ip(),
severity=severity,
)
live = extension.registry.get("live_log")
if live is not None:
live.info("selfservice", f"{action} {ticket['customer_name']} {detail}".strip())
@bp.get("/<ticket_id>")
def overview(ticket_id: str):
_rate_guard()
ticket = _ticket_or_404(ticket_id)
conn = db()
licenses = [
{**row, **issuance.display_state(row)}
for row in issuance.list_licenses(conn, ticket_id=ticket_id)
]
current = next((row for row in licenses if row["status"] in {"issued", "active"}), None)
return render_template(
"license/selfservice.html",
offline_needed=_needs_offline(db(), ticket_id),
ticket=ticket,
current=current,
history=[row for row in licenses if row is not current],
type_labels=type_labels(),
vendor=vendor_info(),
portal_url=url_for("portal.request_link"),
allow_revoke=bool(core().settings.get(conn, "allow_customer_revoke")),
blocked=str(ticket["status"]) == "blocked",
)
@bp.post("/<ticket_id>/ausstellen")
def issue(ticket_id: str):
_rate_guard()
ticket = _ticket_or_404(ticket_id)
conn = db()
if str(ticket["status"]) == "blocked":
flash(
"Dieses Ticket ist gesperrt. Bitte wenden Sie sich an Ihren Ansprechpartner.",
"danger",
)
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
if issuance.ticket_has_open_license(conn, ticket_id):
flash(
"Es existiert bereits eine gueltige Lizenz. Geben Sie diese zuerst frei, wenn Sie "
"auf ein anderes System wechseln moechten.",
"warning",
)
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
key = signing_key()
try:
with transaction():
result = issuance.issue_from_ticket(
conn,
ticket_id,
master_private_key=key.private_key,
master_public_key=key.public_key,
master_endpoint=master_endpoint(),
vendor=vendor_info(),
actor="self-service",
keystore=core().keystore,
)
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
_log("license.issued_self_service", ticket, result.license_id[:12])
reporting.report_soon(core())
flash(
"Ihre Lizenz wurde erstellt. Laden Sie die Datei herunter und spielen Sie sie in Ihrer "
"TESM-Installation ein.",
"success",
)
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
@bp.get("/<ticket_id>/datei/<license_id>")
def download(ticket_id: str, license_id: str):
_rate_guard()
ticket = _ticket_or_404(ticket_id)
conn = db()
row = issuance.get_license(conn, license_id)
if row is None or str(row["ticket_id"]) != ticket_id:
abort(404)
if str(row["status"]) == "revoked":
abort(410, description="Diese Lizenz wurde widerrufen.")
try:
bundle = issuance.delivery_bundle(conn, license_id, keystore=core().keystore)
except issuance.IssuanceError as exc:
abort(409, description=str(exc))
with transaction():
issuance.record_delivery(
conn,
license_id=license_id,
channel="self-service",
recipient=str(ticket["contact_email"] or ""),
actor="self-service",
)
_log("license.downloaded_self_service", ticket, license_id[:12], severity="info")
return Response(
bundle_bytes(bundle),
mimetype="application/json",
headers={
"Content-Disposition": (
f'attachment; filename="{license_filename(license_id, str(ticket["customer_name"]))}"'
),
"Cache-Control": "no-store",
},
)
@bp.route("/<ticket_id>/offline", methods=["GET", "POST"])
def offline(ticket_id: str):
"""Loest einen Aktivierungscode ein, wenn TESM den Server nicht erreicht."""
ticket = _ticket_or_404(ticket_id)
conn = db()
pending = _needs_offline(conn, ticket_id)
if pending is None:
# Ohne noch nicht aktivierte Lizenz gibt es hier nichts zu tun.
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
response_code = ""
summary: dict[str, object] = {}
if request.method == "POST":
_rate_guard()
raw = request.form.get("code", "")
try:
payload = lic.decode_code(raw)
except ValueError as exc:
flash(f"Der Code ist unlesbar: {exc}", "danger")
return redirect(url_for("selfservice.offline", ticket_id=ticket_id))
# Nur Lizenzen dieses Vorgangs -- ein fremder Code gehoert nicht hierher.
row = issuance.get_license(conn, str(payload.get("license_id") or ""))
if row is None or str(row["ticket_id"]) != ticket_id:
_log(
"selfservice.offline_rejected",
ticket,
"Code gehoert nicht zu diesem Vorgang",
severity="warning",
)
flash(
"Dieser Code gehoert nicht zu Ihrem Vorgang. Bitte pruefen Sie, ob Sie ihn "
"vollstaendig kopiert haben.",
"danger",
)
return redirect(url_for("selfservice.offline", ticket_id=ticket_id))
key = signing_key()
with transaction():
outcome = activation.process(conn, payload, master_private_key=key.private_key)
summary = {"action": payload.get("action"), "hostname": payload.get("hostname")}
if outcome.ok:
response_code = lic.encode_code(outcome.response)
_log(
f"selfservice.offline_{payload.get('action')}",
ticket,
f"Lizenz {str(payload.get('license_id'))[:12]}",
)
else:
message = str(outcome.response.get("message") or "Die Anfrage wurde abgewiesen.")
_log("selfservice.offline_rejected", ticket, message, severity="warning")
flash(message, "danger")
return render_template(
"license/selfservice_offline.html",
ticket=ticket,
license=pending,
response_code=response_code,
summary=summary,
vendor=vendor_info(),
)
@bp.post("/<ticket_id>/freigeben/<license_id>")
def revoke(ticket_id: str, license_id: str):
"""Gibt die aktuelle Lizenz frei -- fuer einen Systemwechsel.
Fachlich ein Widerruf: die Datei gilt nicht mehr, das Ticket bleibt offen,
eine neue Lizenz kann sofort ausgestellt werden.
"""
_rate_guard()
ticket = _ticket_or_404(ticket_id)
conn = db()
if not core().settings.get(conn, "allow_customer_revoke"):
abort(404)
row = issuance.get_license(conn, license_id)
if row is None or str(row["ticket_id"]) != ticket_id:
abort(404)
try:
with transaction():
issuance.revoke_license(conn, license_id, reason="Freigabe durch den Kunden")
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
_log("license.revoked_self_service", ticket, license_id[:12], severity="warning")
reporting.report_soon(core())
flash(
"Die Lizenz wurde freigegeben. Sie koennen jetzt eine neue Lizenz fuer Ihr neues System "
"ausstellen.",
"success",
)
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
@@ -0,0 +1,305 @@
"""Einstellungen des Lizenzservers: System, Anbieterdaten, E-Mail-Versand.
Netzwerk, Webserver/TLS und Verzeichnisdienst kommen aus dem Kern.
"""
from __future__ import annotations
import base64
from typing import Any
from flask import Blueprint, flash, redirect, render_template, request, url_for
from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required
from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction
from ..services import mail
from .common import log_event, master_endpoint, vendor_info
bp = Blueprint("settings", __name__, url_prefix="/einstellungen")
MAX_LOGO_BYTES = 200 * 1024
LOGO_MIME = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
}
LOGO_MAGIC = {
b"\x89PNG\r\n\x1a\n": "image/png",
b"\xff\xd8\xff": "image/jpeg",
b"<svg": "image/svg+xml",
b"<?xml": "image/svg+xml",
}
@bp.get("/system")
@permission_required("settings_system.view")
def system():
extension = core()
conn = db()
sysops = extension.sysops
return render_template(
"license/settings_system.html",
values=extension.settings.get_many(
conn,
[
"portal_enabled",
"portal_link_minutes",
"portal_session_minutes",
"self_service_enabled",
"allow_customer_revoke",
"default_valid_days",
"trash_retention_days",
"log_rotate_interval",
"log_rotate_keep",
],
),
specs={spec.key: spec for spec in extension.settings.specs()},
hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""),
timezone=(
sysops.read_command(
["timedatectl", "show", "--property=Timezone", "--value"]
).stdout.strip()
if sysops
else ""
),
timezones=_timezone_choices(),
nav_items=extension.nav.items,
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
sysops_available=bool(sysops and sysops.available),
)
def _timezone_choices() -> list[str]:
try:
from zoneinfo import available_timezones
return sorted(available_timezones())
except Exception: # noqa: BLE001
return ["Europe/Berlin", "UTC"]
@bp.post("/system")
@permission_required("settings_system.edit")
def system_save():
extension = core()
conn = db()
values: dict[str, Any] = {}
for key in (
"portal_enabled",
"portal_link_minutes",
"portal_session_minutes",
"self_service_enabled",
"allow_customer_revoke",
"default_valid_days",
"trash_retention_days",
"log_rotate_interval",
"log_rotate_keep",
):
spec = extension.settings.spec(key)
if spec.kind == "bool":
values[key] = request.form.get(key) == "1"
elif key in request.form:
values[key] = request.form.get(key)
with transaction():
changed = extension.settings.set_many(conn, values, actor=current_user.username)
if changed:
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="settings.system_saved",
detail=", ".join(changed),
ip=client_ip(),
severity="notice",
)
flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.",
"success" if changed else "info")
return redirect(url_for("settings.system"))
@bp.post("/system/hostname")
@permission_required("settings_system.execute")
def set_hostname():
extension = core()
sysops = extension.sysops
hostname = (request.form.get("hostname") or "").strip()
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
flash("Der Hostname ist ungueltig.", "danger")
return redirect(url_for("settings.system"))
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
return redirect(url_for("settings.system"))
result = sysops.set_hostname(hostname)
if result.ok:
log_event("settings.hostname_changed", target=hostname, severity="notice", scope="system")
flash(f"Hostname auf {hostname} gesetzt.", "success")
else:
flash(f"Hostname konnte nicht gesetzt werden: {result.message}", "danger")
return redirect(url_for("settings.system"))
@bp.post("/system/zeitzone")
@permission_required("settings_system.execute")
def set_timezone():
import time
sysops = core().sysops
timezone = (request.form.get("timezone") or "").strip()
if timezone not in _timezone_choices():
flash("Unbekannte Zeitzone.", "danger")
return redirect(url_for("settings.system"))
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
return redirect(url_for("settings.system"))
result = sysops.set_timezone(timezone)
if result.ok:
try:
time.tzset() # type: ignore[attr-defined]
except AttributeError: # pragma: no cover - Windows
pass
log_event("settings.timezone_changed", target=timezone, severity="notice", scope="system")
flash(f"Zeitzone auf {timezone} gesetzt.", "success")
else:
flash(f"Zeitzone konnte nicht gesetzt werden: {result.message}", "danger")
return redirect(url_for("settings.system"))
# --------------------------------------------------------------------------- #
# Anbieterdaten
# --------------------------------------------------------------------------- #
@bp.get("/anbieter")
@permission_required("settings_vendor.view")
def vendor():
extension = core()
conn = db()
return render_template(
"license/settings_vendor.html",
values=extension.settings.get_many(
conn,
[
"vendor_name",
"vendor_email",
"vendor_phone",
"vendor_address",
"vendor_logo_base64",
"master_endpoint",
],
),
max_logo_kb=MAX_LOGO_BYTES // 1024,
)
@bp.post("/anbieter")
@permission_required("settings_vendor.edit")
def vendor_save():
extension = core()
conn = db()
values = {
key: (request.form.get(key) or "").strip()
for key in ("vendor_name", "vendor_email", "vendor_phone", "vendor_address")
}
endpoint = (request.form.get("master_endpoint") or "").strip().rstrip("/")
if endpoint and not endpoint.startswith(("http://", "https://")):
flash("Der Endpunkt muss mit http:// oder https:// beginnen.", "danger")
return redirect(url_for("settings.vendor"))
values["master_endpoint"] = endpoint
logo_problem = ""
upload = request.files.get("vendor_logo")
if upload is not None and upload.filename:
from pathlib import Path
suffix = Path(upload.filename).suffix.lower()
head = upload.stream.read(16)
upload.stream.seek(0)
data = upload.stream.read(MAX_LOGO_BYTES + 1)
if suffix not in LOGO_MIME:
logo_problem = "Erlaubt sind PNG, JPG und SVG."
elif len(data) > MAX_LOGO_BYTES:
logo_problem = f"Das Logo ist zu gross (maximal {MAX_LOGO_BYTES // 1024} KB)."
elif not any(head.startswith(magic) for magic in LOGO_MAGIC):
logo_problem = "Die Datei ist kein Bild."
else:
mime = LOGO_MIME[suffix]
values["vendor_logo_base64"] = (
f"data:{mime};base64," + base64.b64encode(data).decode("ascii")
)
with transaction():
changed = extension.settings.set_many(conn, values, actor=current_user.username)
if logo_problem:
# Ein zu grosses Logo verwirft nur das Logo -- die uebrigen Angaben
# werden trotzdem gespeichert.
flash(logo_problem, "danger")
log_event(
"settings.vendor_saved", detail=", ".join(changed), severity="notice", scope="anbieter"
)
flash("Anbieterdaten gespeichert." if changed else "Keine Aenderungen.",
"success" if changed else "info")
return redirect(url_for("settings.vendor"))
@bp.post("/anbieter/logo-entfernen")
@permission_required("settings_vendor.edit")
def vendor_logo_remove():
with transaction() as conn:
core().settings.set(conn, "vendor_logo_base64", "", actor=current_user.username)
flash("Logo entfernt.", "success")
return redirect(url_for("settings.vendor"))
# --------------------------------------------------------------------------- #
# E-Mail
# --------------------------------------------------------------------------- #
@bp.get("/email")
@permission_required("settings_mail.view")
def mail_settings():
extension = core()
conn = db()
return render_template(
"license/settings_mail.html",
values=extension.settings.get_many(conn, [spec.key for spec in mail.SETTINGS]),
specs={spec.key: spec for spec in mail.SETTINGS},
has_secret=mail.has_secret(conn),
ready=mail.is_enabled(conn),
can_secrets=current_user.has("settings_mail.secrets"),
)
@bp.post("/email")
@permission_required("settings_mail.edit")
def mail_save():
extension = core()
conn = db()
values: dict[str, Any] = {}
for spec in mail.SETTINGS:
if spec.kind == "bool":
values[spec.key] = request.form.get(spec.key) == "1"
elif spec.key in request.form:
values[spec.key] = (request.form.get(spec.key) or "").strip()
with transaction():
changed = extension.settings.set_many(conn, values, actor=current_user.username)
if current_user.has("settings_mail.secrets") and request.form.get("client_secret"):
mail.store_secret(conn, request.form.get("client_secret"))
changed.append("client_secret")
log_event("settings.mail_saved", detail=", ".join(changed), severity="notice", scope="mail")
flash("E-Mail-Einstellungen gespeichert." if changed else "Keine Aenderungen.",
"success" if changed else "info")
return redirect(url_for("settings.mail_settings"))
@bp.post("/email/test")
@permission_required("settings_mail.execute")
def mail_test():
result = mail.test_connection(db())
flash(result["message"], "success" if result["ok"] else "danger")
return redirect(url_for("settings.mail_settings"))
@@ -0,0 +1,238 @@
"""Tickets: der dauerhafte Anspruch eines Kunden auf eine Lizenz."""
from __future__ import annotations
import json
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from tesm_core.auth.models import current_user, permission_required
from tesm_core.extension import core, db, transaction
import tesm_licensing as lic
from ..services import issuance, reporting
from .common import (
can_issue_license_server_licenses,
customer_facing_url,
log_event,
master_endpoint,
module_labels,
role_labels,
type_labels,
)
bp = Blueprint("tickets", __name__, url_prefix="/tickets")
@bp.get("/")
@permission_required("tickets.view")
def overview():
conn = db()
tickets = issuance.list_tickets(conn)
licenses = {
str(row["ticket_id"]): {**row, **issuance.display_state(row)}
for row in issuance.list_licenses(conn)
if row["status"] in {"issued", "active"}
}
return render_template(
"license/tickets.html",
endpoint_missing=_endpoint_missing(),
tickets=tickets,
open_licenses=licenses,
type_labels=type_labels(),
role_labels=role_labels(),
module_labels=module_labels(),
)
@bp.get("/neu")
@permission_required("tickets.create")
def new():
conn = db()
extension = core()
return render_template(
"license/ticket_new.html",
endpoint_missing=_endpoint_missing(),
customers=issuance.list_customers(conn),
modules=lic.ALL_MODULES,
module_labels=module_labels(),
types=lic.LICENSE_TYPES,
type_labels=type_labels(),
default_valid_days=int(extension.settings.get(conn, "default_valid_days") or 365),
may_create_license_server=can_issue_license_server_licenses(),
preselected_customer=request.args.get("kunde", type=int),
)
@bp.post("/neu")
@permission_required("tickets.create")
def create():
conn = db()
# Rolle: jeder Versuch ausserhalb der strukturellen Erlaubnis wird still auf
# "customer" zurueckgestuft -- nicht nur in der Oberflaeche verborgen.
role = request.form.get("role", "customer")
if role != "customer" and not can_issue_license_server_licenses():
role = "customer"
try:
with transaction():
ticket_id = issuance.create_ticket(
conn,
customer_id=int(request.form.get("customer_id") or 0),
license_type=request.form.get("type", "standard"),
modules=request.form.getlist("modules"),
valid_days=int(request.form.get("valid_days") or 365),
lifetime=request.form.get("lifetime") == "1",
role=role,
notes=request.form.get("notes", ""),
actor=current_user.username,
)
log_event(
"ticket.created",
target=ticket_id[:12],
detail=f"Typ {request.form.get('type')}, Rolle {role}",
severity="notice",
scope="ticket",
)
flash("Ticket angelegt.", "success")
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
except (issuance.IssuanceError, ValueError) as exc:
flash(str(exc), "danger")
return redirect(url_for("tickets.new"))
def _endpoint_missing() -> str:
"""Meldung, falls ohne Endpunkt gearbeitet wird -- sonst leer."""
if master_endpoint():
return ""
return (
"Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
"Solange er fehlt, koennen keine Lizenzen ausgestellt werden: der Endpunkt wird "
"in die Lizenz signiert und laesst sich spaeter nicht nachtragen."
)
@bp.get("/<ticket_id>")
@permission_required("tickets.view")
def detail(ticket_id: str):
conn = db()
ticket = issuance.get_ticket(conn, ticket_id)
if ticket is None:
abort(404)
licenses = [
{**row, **issuance.display_state(row)}
for row in issuance.list_licenses(conn, ticket_id=ticket_id)
]
extension = core()
self_service_enabled = bool(extension.settings.get(conn, "self_service_enabled"))
return render_template(
"license/ticket_detail.html",
endpoint_missing=_endpoint_missing(),
ticket=ticket,
ticket_modules=json.loads(str(ticket["modules"] or "[]")),
licenses=licenses,
has_open_license=issuance.ticket_has_open_license(conn, ticket_id),
modules=lic.ALL_MODULES,
module_labels=module_labels(),
types=lic.LICENSE_TYPES,
type_labels=type_labels(),
role_labels=role_labels(),
trial_days=lic.TRIAL_DEFAULT_DAYS,
# Self-Service gibt es nur fuer Kundentickets -- fuer andere Rollen ist
# der Weg vollstaendig gesperrt.
self_service_url=(
customer_facing_url("selfservice.overview", ticket_id=ticket_id)
if self_service_enabled and str(ticket["role"]) == "customer"
else ""
),
portal_url=customer_facing_url("portal.request_link"),
)
@bp.post("/<ticket_id>")
@permission_required("tickets.edit")
def update(ticket_id: str):
conn = db()
if issuance.get_ticket(conn, ticket_id) is None:
abort(404)
try:
with transaction():
changes = issuance.update_ticket(
conn,
ticket_id,
license_type=request.form.get("type", "standard"),
modules=request.form.getlist("modules"),
valid_days=int(request.form.get("valid_days") or 365),
lifetime=request.form.get("lifetime") == "1",
notes=request.form.get("notes", ""),
)
if changes:
log_event(
"ticket.updated",
target=ticket_id[:12],
detail="; ".join(changes),
severity="notice",
scope="ticket",
)
reporting.report_soon(core())
flash(
"Gespeichert. Die Aenderung wirkt auf die naechste Ausstellung."
if changes
else "Keine Aenderungen.",
"success" if changes else "info",
)
except (issuance.IssuanceError, ValueError) as exc:
flash(str(exc), "danger")
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
@bp.post("/<ticket_id>/sperre")
@permission_required("tickets.edit")
def toggle_block(ticket_id: str):
conn = db()
ticket = issuance.get_ticket(conn, ticket_id)
if ticket is None:
abort(404)
new_status = "open" if str(ticket["status"]) == "blocked" else "blocked"
with transaction():
issuance.set_ticket_status(conn, ticket_id, new_status)
log_event(
"ticket.blocked" if new_status == "blocked" else "ticket.unblocked",
target=ticket_id[:12],
detail=str(ticket["customer_name"]),
severity="warning" if new_status == "blocked" else "notice",
scope="ticket",
)
reporting.report_soon(core())
flash(
"Ticket gesperrt -- es lassen sich keine neuen Lizenzen mehr ausstellen."
if new_status == "blocked"
else "Ticket entsperrt.",
"success",
)
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
@bp.post("/<ticket_id>/loeschen")
@permission_required("tickets.delete")
def delete(ticket_id: str):
conn = db()
ticket = issuance.get_ticket(conn, ticket_id)
if ticket is None:
abort(404)
try:
with transaction():
issuance.delete_ticket(conn, ticket_id)
log_event(
"ticket.deleted",
target=ticket_id[:12],
detail=str(ticket["customer_name"]),
severity="warning",
scope="ticket",
)
reporting.report_soon(core())
flash("Ticket und seine Lizenzhistorie geloescht.", "success")
return redirect(url_for("tickets.overview"))
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
+470
View File
@@ -0,0 +1,470 @@
"""Kommandozeilenwerkzeuge des Lizenzservers.
Enthaelt insbesondere ``bootstrap`` -- die eigene Lizenz der Wurzelinstanz.
Warum das ein lokales Skript ist und keine Funktion in der Oberflaeche: die
Master-Lizenz ist die einzige Lizenz, fuer die es **keine hoehere Instanz gibt**,
die sie bestaetigen koennte. Ein Aktivierungs-Handshake gegen sich selbst waere
Theater. Das Skript signiert die Datei mit dem eigenen Schluessel und setzt den
Aktivierungszustand direkt -- und nur so kann eine Lizenzdatei je die Rolle
``master`` tragen. Die Ticket-Oberflaeche bietet diese Rolle an keiner Stelle an.
"""
from __future__ import annotations
import argparse
import getpass
import json
import sys
from pathlib import Path
from typing import Any
import tesm_licensing as lic
import tesm_licensing_server as licsrv
from tesm_core import audit
from tesm_core.cli_web import add_web_setup, run_web_setup
from tesm_core.db import Database
from tesm_core.extension import core
from tesm_core.rbac import service as rbac
from tesm_core.security import passwords
from . import create_app, master_key_path
from .services import legacy_import, master_key
def _app_and_core() -> tuple[Any, Any]:
app = create_app()
return app, core(app)
def cmd_init_key(args: argparse.Namespace) -> int:
app, extension = _app_and_core()
path = master_key_path(extension.config)
if path.exists() and not args.force:
key = master_key.load(path)
print(f"Signaturschluessel existiert bereits: {key.key_id} ({path})")
print("Ein Austausch macht ALLE bereits ausgestellten Lizenzen unpruefbar.")
return 0
if path.exists() and args.force:
print(
"ABBRUCH: --force auf einen bestehenden Signaturschluessel wird nicht unterstuetzt.\n"
"Ein neuer Schluessel entwertet jede ausgestellte Lizenz. Bitte die Datei bewusst\n"
"von Hand beiseite legen, wenn das wirklich gewollt ist.",
file=sys.stderr,
)
return 2
key = master_key.ensure(path)
print(f"Signaturschluessel erzeugt: {key.key_id}")
print(f"Datei: {path}")
print("Bitte SOFORT sichern -- ohne sie ist keine ausgestellte Lizenz mehr pruefbar.")
print(f"Oeffentlicher Schluessel (zum Pinnen beim Kunden):\n{key.public_key}")
return 0
def cmd_bootstrap(args: argparse.Namespace) -> int:
"""Stellt die eigene Lizenz dieser Wurzelinstanz aus."""
app, extension = _app_and_core()
key = master_key.ensure(master_key_path(extension.config))
with app.app_context(), extension.database.session() as conn:
vendor = {
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
"email": str(extension.settings.get(conn, "vendor_email") or ""),
"phone": str(extension.settings.get(conn, "vendor_phone") or ""),
"address": str(extension.settings.get(conn, "vendor_address") or ""),
"logo_base64": "",
}
bundle, _ = licsrv.issue_license(
customer={"name": args.name, "contact_email": vendor["email"]},
license_type="enterprise",
modules=[],
valid_days=int(args.days),
master_private_key_b64=key.private_key,
master_public_key_b64=key.public_key,
# Bewusst leer: es gibt keinen uebergeordneten Endpunkt. Damit
# laeuft der Handshake fuer diese Lizenz nie ins Netz.
master_endpoint="",
vendor=vendor,
role="master",
)
document, client_key = lic.split_bundle(bundle)
config = extension.config
config.license_path.parent.mkdir(parents=True, exist_ok=True)
config.license_path.write_text(
json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
encoding="utf-8",
)
try:
config.license_path.chmod(0o640)
except OSError:
pass
from tesm_core.keystore import write_private_file
write_private_file(
config.license_key_path,
json.dumps({"license_id": document["license_id"], "client_key": client_key}) + "\n",
)
with extension.database.transaction(conn):
conn.execute(
"INSERT INTO trusted_issuer_keys (key_id, public_key, label, source, added_at, "
"added_by) VALUES (?,?,?,'bootstrap',datetime('now'),'cli') "
"ON CONFLICT(key_id) DO NOTHING",
(key.key_id, key.public_key, "Eigener Signaturschluessel"),
)
# Es gibt keine hoehere Instanz, die diese Lizenz aktivieren koennte.
extension.settings.set(conn, "license_activation_status", "active", actor="cli")
extension.settings.set(conn, "license_pending_request", "", actor="cli")
audit.log(
conn,
actor="cli",
action="own_license.bootstrapped",
target=document["license_id"],
detail=f"Rolle master, {args.days} Tage, Schluessel {key.key_id}",
severity="warning",
)
state = extension.license.reload(conn) if extension.license else None
print(f"Master-Lizenz ausgestellt: {document['license_id']}")
print(f"Gueltig bis: {document['expires_at']}")
print(f"Datei: {config.license_path}")
if state is not None:
print(f"Zustand: gueltig={state.valid}, aktiviert={state.activated}, Rolle={state.role}")
return 0
def cmd_create_admin(args: argparse.Namespace) -> int:
app, extension = _app_and_core()
username = args.username or input("Benutzername: ").strip()
if not username:
print("Kein Benutzername angegeben.", file=sys.stderr)
return 2
password = args.password or getpass.getpass("Passwort: ")
if not args.password and password != getpass.getpass("Passwort wiederholen: "):
print("Die Passwoerter stimmen nicht ueberein.", file=sys.stderr)
return 2
problems = extension.password_policy.check(password, username=username)
if problems and not args.force:
for problem in problems:
print(f"- {problem}", file=sys.stderr)
return 2
with app.app_context(), extension.database.session() as conn:
existing = rbac.find_user_by_username(conn, username, include_deleted=True)
with extension.database.transaction(conn):
if existing is not None:
conn.execute(
"UPDATE users SET password_hash=?, is_admin=1, is_locked=0, deleted_at=NULL, "
"must_change_password=0, updated_at=datetime('now') WHERE id=?",
(passwords.hash_password(password), existing["id"]),
)
action = "aktualisiert"
else:
conn.execute(
"INSERT INTO users (username, password_hash, is_admin, auth_source, "
"created_at, updated_at, password_changed_at) "
"VALUES (?,?,1,'local',datetime('now'),datetime('now'),datetime('now'))",
(username, passwords.hash_password(password)),
)
action = "angelegt"
audit.log(
conn,
actor="cli",
action="user.admin_created",
target=username,
severity="warning",
)
print(f"Administrator {username!r} {action}.")
return 0
def cmd_status(args: argparse.Namespace) -> int:
app, extension = _app_and_core()
with app.app_context(), extension.database.session() as conn:
chain = audit.verify_chain(conn, limit=5000)
counts = {
"kunden": Database.value(conn, "SELECT COUNT(*) FROM license_customers", default=0),
"tickets": Database.value(conn, "SELECT COUNT(*) FROM license_tickets", default=0),
"lizenzen": Database.value(conn, "SELECT COUNT(*) FROM licenses", default=0),
"aktiv": Database.value(
conn, "SELECT COUNT(*) FROM licenses WHERE status='active'", default=0
),
"administratoren": rbac.count_active_admins(conn),
}
state = extension.license.reload(conn) if extension.license else None
key_health = master_key.health(master_key_path(extension.config))
payload = {
"datenbank": str(extension.config.db_path),
"bestand": counts,
"audit_kette": chain,
"signaturschluessel": key_health,
"eigene_lizenz": (
{
"vorhanden": state.present,
"gueltig": state.valid,
"aktiviert": state.activated,
"rolle": state.role,
"fehler": state.error,
}
if state
else None
),
}
if args.json:
print(json.dumps(payload, indent=2, ensure_ascii=False, default=str))
return 0
print(f"Datenbank : {payload['datenbank']}")
for key, value in counts.items():
print(f"{key.capitalize():<17}: {value}")
print(
"Audit-Kette : "
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei {chain['broken_at']}")
)
print(
"Signaturschluessel: "
+ (str(key_health.get("key_id")) if key_health.get("present") else "FEHLT")
)
for problem in key_health.get("problems", []):
print(f" ! {problem}", file=sys.stderr)
if state:
print(
"Eigene Lizenz : "
+ ("aktiv" if state.valid and state.activated else "nicht aktiv")
+ f", Rolle {state.role}"
)
if not counts["administratoren"]:
print("\nWARNUNG: kein entsperrtes Administratorkonto vorhanden.", file=sys.stderr)
return 1
return 0
def cmd_housekeeping(_args: argparse.Namespace) -> int:
app, extension = _app_and_core()
from tesm_core.security import ratelimit
from tesm_core.security import sessions as session_store
from .services import activation
with app.app_context(), extension.database.session() as conn:
with extension.database.transaction(conn):
sessions = session_store.prune(conn)
events = ratelimit.prune(conn)
nonces = activation.prune_nonces(conn)
archived = audit.archive_old_rows(conn, extension.audit_archive_dir)
expired_tokens = conn.execute(
"DELETE FROM portal_access_tokens WHERE expires_at < datetime('now', '-7 days')"
).rowcount
print(
f"{sessions} Sitzungen, {events} Ratenereignisse, {nonces} Nonces, "
f"{expired_tokens or 0} Portal-Token entfernt, {len(archived)} Protokolltage archiviert."
)
return 0
def cmd_web_setup(args: argparse.Namespace) -> int:
app, extension = _app_and_core()
return run_web_setup(app, extension, args)
def cmd_migrate_legacy(args: argparse.Namespace) -> int:
"""Uebernimmt Signaturschluessel, Kunden, Tickets und Lizenzen der Vorgaengerfassung."""
root = Path(args.source)
app, extension = _app_and_core()
try:
legacy = legacy_import.open_legacy(root)
except legacy_import.LegacyImportError as exc:
print(str(exc), file=sys.stderr)
return 2
key_path = master_key_path(extension.config)
report = legacy_import.Report()
try:
# -- Schluessel ---------------------------------------------------- #
# Der alte Schluessel liegt als 0600 root. Gelesen wird er nur, wenn er
# wirklich gebraucht wird -- naemlich beim Uebernehmen. Fuer den blossen
# Vergleich genuegt die oeffentliche Haelfte, und fehlt sie, ist das
# kein Grund abzubrechen: der Vergleich ist eine Auskunft, keine
# Voraussetzung.
issuer_changes = False
legacy_public = ""
try:
_, legacy_public = legacy_import.read_legacy_key(root)
except legacy_import.LegacyImportError as exc:
if args.adopt_key:
raise
print(f"Hinweis: alter Signaturschluessel nicht lesbar ({exc}).")
if key_path.is_file():
existing = master_key.load(key_path)
report.key_id = existing.key_id
if legacy_public and existing.public_key == legacy_public:
print(f"Signaturschluessel stimmt bereits ueberein ({existing.key_id}).")
elif not legacy_public:
issuer_changes = True
print(
f"Es wird mit dem vorhandenen Signaturschluessel {existing.key_id} "
"gearbeitet."
)
else:
# Nicht ueberschreiben: jede mit dem vorhandenen Schluessel
# ausgestellte Lizenz waere danach unpruefbar.
issuer_changes = True
print(
f"Diese Installation hat einen eigenen Signaturschluessel "
f"({existing.key_id}); der alte war {lic.key_id(legacy_public)}. "
"Es wird mit dem vorhandenen weitergearbeitet."
)
elif args.adopt_key:
legacy_private, legacy_public = legacy_import.read_legacy_key(root)
report.key_id = legacy_import.adopt_key(legacy_private, legacy_public, key_path)
report.key_adopted = True
print(f"Signaturschluessel uebernommen: {report.key_id}")
else:
print(
"Diese Installation hat noch keinen Signaturschluessel. Mit --adopt-key "
"wird der alte uebernommen -- dann bleibt die Anbieteridentitaet erhalten "
"und Kunden muessen keinen Ausstellerwechsel bestaetigen.",
file=sys.stderr,
)
return 2
key = master_key.load(key_path)
with app.app_context(), extension.database.session() as conn:
vendor = {
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
"email": str(extension.settings.get(conn, "vendor_email") or ""),
"phone": str(extension.settings.get(conn, "vendor_phone") or ""),
"address": str(extension.settings.get(conn, "vendor_address") or ""),
"logo_base64": "",
}
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.",
file=sys.stderr,
)
return 2
with extension.database.transaction(conn):
result = legacy_import.import_all(
legacy,
conn,
keystore=extension.keystore,
master_private_key=key.private_key,
master_public_key=key.public_key,
master_endpoint=endpoint,
vendor=vendor,
actor="migration",
reissue=not args.dry_run,
)
result.key_adopted = report.key_adopted
result.key_id = report.key_id
if issuer_changes:
result.warnings.append(
"Die Lizenzen sind mit einem anderen Schluessel signiert als "
"frueher. Kunden, die den alten Aussteller gepinnt haben, muessen "
"beim Einspielen einmal 'Wechsel des Ausstellers ausdruecklich "
"zulassen' bestaetigen."
)
if args.dry_run:
# Nichts festschreiben -- der Lauf war nur zur Ansicht.
raise _DryRun(result)
except _DryRun as preview:
print()
print("== Probelauf, nichts gespeichert ==")
print(preview.report.as_text())
return 0
except legacy_import.LegacyImportError as exc:
print(str(exc), file=sys.stderr)
return 2
finally:
legacy.close()
print()
print(result.as_text())
return 0
class _DryRun(Exception):
"""Bricht die Transaktion eines Probelaufs ab -- ohne Fehler zu sein."""
def __init__(self, report: Any) -> None:
super().__init__("Probelauf")
self.report = report
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="tesm-license-admin", description="Verwaltungswerkzeuge des TESM-Lizenzservers"
)
sub = parser.add_subparsers(dest="command", required=True)
init_key = sub.add_parser("init-key", help="Signaturschluessel erzeugen (einmalig)")
init_key.add_argument("--force", action="store_true")
init_key.set_defaults(func=cmd_init_key)
bootstrap = sub.add_parser(
"bootstrap", help="Eigene Master-Lizenz dieser Wurzelinstanz ausstellen"
)
bootstrap.add_argument("--days", type=int, default=3650)
bootstrap.add_argument("--name", default="Lizenzserver (Eigenbetrieb)")
bootstrap.set_defaults(func=cmd_bootstrap)
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zuruecksetzen")
admin.add_argument("--username")
admin.add_argument("--password")
admin.add_argument("--force", action="store_true")
admin.set_defaults(func=cmd_create_admin)
status = sub.add_parser("status", help="Zustand ausgeben")
status.add_argument("--json", action="store_true")
status.set_defaults(func=cmd_status)
house = sub.add_parser("housekeeping", help="Laufzeitdaten aufraeumen")
house.set_defaults(func=cmd_housekeeping)
migrate = sub.add_parser(
"migrate-legacy",
help="Lizenzserver der Vorgaengerfassung uebernehmen",
description=(
"Uebernimmt Signaturschluessel, Kunden, Tickets und Lizenzen aus einer alten "
"Installation. Die alte Installation wird nur gelesen. Jede Lizenz wird im "
"neuen Format neu signiert -- die Kunden brauchen danach eine neue "
"Lizenzdatei, weil die alte Signatur nicht mehr gilt."
),
)
migrate.add_argument(
"--source", default="/srv/tesm-license", help="Verzeichnis der alten Installation"
)
migrate.add_argument(
"--adopt-key",
action="store_true",
help="Den alten Signaturschluessel uebernehmen. Nur moeglich, solange diese "
"Installation noch keinen eigenen hat -- ein vorhandener wird nie ueberschrieben.",
)
migrate.add_argument(
"--dry-run",
action="store_true",
help="Nur anzeigen, was passieren wuerde -- nichts schreiben",
)
migrate.set_defaults(func=cmd_migrate_legacy)
add_web_setup(sub, cmd_web_setup)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
return int(args.func(args))
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
@@ -0,0 +1,188 @@
"""Navigationsbaum des Lizenzservers.
Kunde, Ticket und Lizenz sind drei **flache** Top-Level-Punkte, jeder einzeln
ueber die Navigation erreichbar. Ein Zwischenstand des Vorgaengerprojekts hatte
Tickets und Lizenzen tief in die Kundenseite verschachtelt -- das war
nachweislich unbrauchbar. Die Kundendetailseite existiert trotzdem, aber als
reiner Lese-Ueberblick.
Settings und Protokolle bleiben zweistufige Gruppen -- das ist keine Ausnahme
von der Flachheit, sondern die Standardkonvention fuer mehrseitige Bereiche.
"""
from __future__ import annotations
from typing import Any
from tesm_core.extension import core
from tesm_core.nav import NavItem, NavTree
def _is_master(_user: Any) -> bool:
"""Nur eine Master-Instanz sieht die Berichte der Sub-Lizenzserver."""
manager = core().license
if manager is None:
return True
return manager.state.role == "master"
NAVIGATION = NavTree(
[
NavItem(key="index", label="Uebersicht", icon="grid", endpoint="dashboard.index"),
NavItem(
key="customers",
label="Kunden",
icon="building",
endpoint="customers.overview",
permissions=("customers.view",),
),
NavItem(
key="tickets",
label="Tickets",
icon="ticket",
endpoint="tickets.overview",
permissions=("tickets.view",),
),
NavItem(
key="licenses",
label="Lizenzen",
icon="shield",
endpoint="licenses.overview",
permissions=("licenses.view",),
),
NavItem(
key="offline_code",
label="Offline-Aktivierung",
icon="transfer",
endpoint="licenses.manual_code",
permissions=("licenses.execute",),
),
NavItem(
key="reports",
label="Sub-Lizenzserver",
icon="share",
endpoint="licenses.reports",
permissions=("reports.view",),
condition=_is_master,
),
NavItem(
key="observability",
label="Protokolle",
icon="terminal",
children=(
NavItem(
key="logs_live",
label="Laufend",
icon="terminal",
endpoint="logs.live",
permissions=("logs_live.view",),
),
NavItem(
key="logs_history",
label="Verlauf",
icon="clock",
endpoint="logs.history",
permissions=("logs_history.view", "logs_history.export"),
),
NavItem(
key="logs_audit",
label="Aenderungen",
icon="history",
endpoint="logs.audit_view",
permissions=("logs_audit.view", "logs_audit.export"),
),
),
),
NavItem(
key="administration",
label="Verwaltung",
icon="sliders",
children=(
NavItem(
key="users",
label="Benutzer",
icon="users",
endpoint="admin.users",
permissions=("users.view",),
),
NavItem(
key="groups",
label="Gruppen und Rechte",
icon="shield-users",
endpoint="admin.groups",
permissions=("groups.view",),
),
NavItem(
key="settings_vendor",
label="Anbieterdaten",
icon="building",
endpoint="settings.vendor",
permissions=("settings_vendor.view",),
),
NavItem(
key="settings_mail",
label="E-Mail-Versand",
icon="mail",
endpoint="settings.mail_settings",
permissions=("settings_mail.view",),
),
NavItem(
key="settings_system",
label="System",
icon="sliders",
endpoint="settings.system",
permissions=("settings_system.view",),
),
NavItem(
key="settings_network",
label="Netzwerk",
icon="network",
endpoint="hostsettings.network",
permissions=("settings_network.view",),
),
NavItem(
key="settings_web",
label="Webserver und TLS",
icon="server",
endpoint="hostsettings.web",
permissions=("settings_web.view",),
),
NavItem(
key="settings_directory",
label="Verzeichnisdienst",
icon="users",
endpoint="hostsettings.directory_settings",
permissions=("settings_directory.view",),
),
NavItem(
key="settings_license",
label="Eigene Lizenz",
icon="key",
endpoint="own_license.overview",
permissions=("settings_license.view",),
),
NavItem(
key="backup",
label="Sicherung",
icon="transfer",
endpoint="backup.overview",
permissions=("backup.view", "backup.export", "backup.execute"),
),
NavItem(
key="trash",
label="Papierkorb",
icon="trash",
endpoint="trash.overview",
permissions=("trash.view",),
),
NavItem(
key="diagnostics",
label="Diagnose",
icon="info",
endpoint="diagnostics.overview",
permissions=("diagnostics.view",),
),
),
),
]
)
@@ -0,0 +1,183 @@
"""Rechtematrix des Lizenzservers.
Dieselbe Systematik wie in TESM: festes Aktionsvokabular, Loeschen als eigenes
Recht, jede Seite mit eigenem Rechtepaar.
Zwei Nuancen, die der Vorgaenger nachtraeglich korrigieren musste und die hier
von Anfang an so gebaut sind:
* **Tickets und Lizenzen sind getrennte Rechte.** "Ticket sperren" und "Lizenz
ausstellen" sind verschiedene Vorgaenge und lassen sich unabhaengig vergeben.
* **Die eigene Lizenzseite hat ihr eigenes Recht** (``settings_license``) und
laeuft nicht ueber ``settings_system`` mit.
Und eine Grenze, die ausdruecklich **kein** Recht ist: das Ausstellen von
Lizenzserver-Lizenzen. Es haengt an der Rolle der eigenen Lizenzdatei
(``master``) und am Admin-Kennzeichen -- nicht an einer vergebbaren Permission.
Ein lokaler Administrator eines Sub-Lizenzservers koennte sich ein Recht selbst
einraeumen; eine von woanders signierte Lizenzdatei nicht.
"""
from __future__ import annotations
from tesm_core.rbac.common import administration_area, observability_area
from tesm_core.rbac.model import Action, Area, PermissionTree, Resource, RolePreset
A = Action
CUSTOMERS = Resource(
key="customers",
label="Kunden",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
hint="Stammdaten der Kundeninstanzen",
action_hints={A.DELETE: "Nur moeglich, solange kein Ticket am Kunden haengt."},
)
TICKETS = Resource(
key="tickets",
label="Tickets",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
hint="Dauerhafte Anspruechte auf eine Lizenz",
action_hints={
A.EDIT: "Parameter aendern und Ticket sperren/entsperren. Wirkt erst auf die "
"naechste Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.",
A.DELETE: "Nur moeglich, wenn keine offene Lizenz mehr existiert.",
},
)
LICENSES = Resource(
key="licenses",
label="Lizenzen",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE, A.EXPORT),
hint="Konkrete, signierte Lizenzdateien",
action_hints={
A.CREATE: "Aus einem Ticket eine Lizenz ausstellen.",
A.EDIT: "Lizenz widerrufen oder eine neue Revision signieren.",
A.EXECUTE: "Offline-Aktivierungscodes verarbeiten.",
A.EXPORT: "Lizenzdatei herunterladen oder per E-Mail versenden.",
},
)
PORTAL = Resource(
key="portal",
label="Kundenportal",
actions=(A.VIEW, A.EXECUTE),
hint="Zugangslinks fuer Kunden",
action_hints={A.EXECUTE: "Zugangslink erzeugen und versenden."},
)
REPORTS = Resource(
key="reports",
label="Berichte der Sub-Lizenzserver",
actions=(A.VIEW,),
hint="Von untergeordneten Lizenzservern gemeldete Kunden, Tickets und Lizenzen",
)
MAIL = Resource(
key="settings_mail",
label="E-Mail-Versand",
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
hint="Microsoft Graph (App-only)",
action_hints={
A.EXECUTE: "Verbindungstest ausfuehren.",
A.SECRETS: "Client-Secret setzen.",
},
)
VENDOR = Resource(
key="settings_vendor",
label="Anbieterdaten",
actions=(A.VIEW, A.EDIT),
hint="Name, Kontakt, Logo und oeffentlicher Endpunkt -- reisen in jeder Lizenz mit",
)
LICENSING_AREA = Area(
key="licensing",
label="Lizenzierung",
icon="key",
hint="Kunden, Tickets, Lizenzen. Ohne diesen Zugang bleibt der gesamte Bereich verborgen.",
resources=(CUSTOMERS, TICKETS, LICENSES, PORTAL, REPORTS),
)
PERMISSIONS = PermissionTree(
[
LICENSING_AREA,
observability_area(),
administration_area(MAIL, VENDOR),
]
)
ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset(
key="viewer",
name="Benutzer",
description="Standardgruppe: nur das laufende Protokoll lesen.",
grants=(("logs_live", (A.VIEW,)),),
is_default=True,
),
RolePreset(
key="sales",
name="Vertrieb",
description="Kunden und Tickets pflegen, Lizenzen ausstellen und versenden.",
grants=(
("customers", "*"),
("tickets", "*"),
("licenses", (A.VIEW, A.CREATE, A.EDIT, A.EXPORT)),
("portal", "*"),
("logs_live", (A.VIEW,)),
("logs_audit", (A.VIEW,)),
),
),
RolePreset(
key="support",
name="Support",
description="Lesen und Offline-Aktivierungen verarbeiten.",
grants=(
("customers", (A.VIEW,)),
("tickets", (A.VIEW,)),
("licenses", (A.VIEW, A.EXECUTE)),
("reports", (A.VIEW,)),
("logs_live", (A.VIEW,)),
),
),
RolePreset(
key="auditor",
name="Revision",
description="Nur lesen -- inklusive Aenderungsprotokoll und Diagnose.",
grants=(
("customers", (A.VIEW,)),
("tickets", (A.VIEW,)),
("licenses", (A.VIEW,)),
("reports", (A.VIEW,)),
("users", (A.VIEW,)),
("groups", (A.VIEW,)),
("logs_live", (A.VIEW,)),
("logs_history", (A.VIEW, A.EXPORT)),
("logs_audit", (A.VIEW, A.EXPORT)),
("diagnostics", (A.VIEW,)),
),
),
RolePreset(
key="administration",
name="Administration",
description="Benutzer, Gruppen und Einstellungen -- ohne Admin-Kennzeichen.",
grants=(
("users", "*"),
("groups", "*"),
("settings_system", "*"),
("settings_license", "*"),
("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)),
("settings_web", "*"),
("settings_network", "*"),
("settings_mail", (A.VIEW, A.EDIT, A.EXECUTE)),
("settings_vendor", "*"),
("backup", (A.VIEW, A.EXECUTE, A.EXPORT)),
("trash", "*"),
("diagnostics", (A.VIEW,)),
("logs_audit", (A.VIEW,)),
),
),
)
__all__ = ["PERMISSIONS", "ROLE_PRESETS"]
@@ -0,0 +1,216 @@
"""Migrationen der Komponente ``tesm_license``.
Das Datenmodell folgt der fachlichen Hierarchie **Kunde -> Ticket -> Lizenz**:
* Ein **Kunde** ist eine Firma mit Stammdaten.
* Ein **Ticket** ist der dauerhafte Anspruch dieses Kunden auf eine Lizenz mit
bestimmten Parametern (Typ, Module, Laufzeit).
* Eine **Lizenz** ist die konkrete, signierte Datei, die aus einem Ticket
ausgestellt wurde.
Der Vorgaenger hatte in einem frueheren Stand nur "Lizenz" in "Ticket"
umbenannt, ohne die Trennung tatsaechlich zu bauen -- das wird hier nicht
wiederholt.
Aenderungen gegenueber dem Vorgaenger:
* **Kein ``portal_token``.** Der permanente, unbegrenzt gueltige Magic-Link war
eine erkannte Schwachstelle und wurde durch einen zweistufigen Flow ersetzt
(Anfragelink 30 Minuten, einmalig -> Sitzung 60 Minuten). Spalte und Index des
alten Verfahrens entstehen hier gar nicht erst.
* **Nonce-Speicher** (``license_nonces``): jede Aktivierungs-/Heartbeat-Anfrage
darf genau einmal verarbeitet werden. Der Vorgaenger pruefte Nonces nicht --
eine abgefangene Anfrage war unbegrenzt wiederverwendbar.
* **Zustellprotokoll** (``license_deliveries``): wer wann welche Lizenzdatei
bekommen hat.
"""
from __future__ import annotations
from tesm_core.migrations import Migration
V1 = """
CREATE TABLE IF NOT EXISTS license_customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL COLLATE NOCASE,
contact_email TEXT NOT NULL DEFAULT '',
contact_phone TEXT NOT NULL DEFAULT '',
contact_person TEXT NOT NULL DEFAULT '',
-- Adresse ausdruecklich in Einzelfeldern, damit sie z. B. fuer Rechnungen
-- weiterverwendbar bleibt.
street TEXT NOT NULL DEFAULT '',
house_number TEXT NOT NULL DEFAULT '',
postal_code TEXT NOT NULL DEFAULT '',
city TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_customers_email ON license_customers(contact_email);
CREATE TABLE IF NOT EXISTS license_tickets (
-- Die ID ist zugleich der Schluessel des Self-Service-Links: 192 Bit
-- Zufall, nicht ratbar.
id TEXT PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES license_customers(id) ON DELETE RESTRICT,
type TEXT NOT NULL,
modules TEXT NOT NULL DEFAULT '[]',
valid_days INTEGER NOT NULL DEFAULT 365,
lifetime INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open',
role TEXT NOT NULL DEFAULT 'customer',
notes TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tickets_customer ON license_tickets(customer_id);
CREATE INDEX IF NOT EXISTS idx_tickets_status ON license_tickets(status);
CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
license_id TEXT NOT NULL UNIQUE,
ticket_id TEXT REFERENCES license_tickets(id) ON DELETE CASCADE,
customer_id INTEGER NOT NULL REFERENCES license_customers(id) ON DELETE RESTRICT,
type TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'customer',
modules TEXT NOT NULL DEFAULT '[]',
lifetime INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL DEFAULT 1,
issued_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
license_pubkey TEXT NOT NULL,
license_document TEXT NOT NULL,
-- Der private Client-Schluessel dieser Lizenz, AES-256-GCM-verschluesselt und
-- an die Lizenz-ID gebunden. Er muss erneut ausliefer-bar sein (verlorene
-- Mail, neuer Ansprechpartner), darf aber nie im Klartext in der Datenbank
-- liegen. Die Herausgabe verlangt Recht, frische Passwortbestaetigung und
-- landet im Aenderungsprotokoll.
client_key_encrypted TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'issued',
fingerprint TEXT,
hostname TEXT,
activated_at TEXT,
deactivated_at TEXT,
last_heartbeat_at TEXT,
revoked_at TEXT,
revoke_reason TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_licenses_customer ON licenses(customer_id);
CREATE INDEX IF NOT EXISTS idx_licenses_status ON licenses(status);
CREATE INDEX IF NOT EXISTS idx_licenses_fingerprint ON licenses(fingerprint);
-- Hoechstens eine offene Lizenz pro Ticket -- auf Datenbankebene, nicht nur im
-- Python-Code. Damit ist der Doppelklick-Fall (zwei Tabs, zwei Anfragen) sauber
-- abgefangen: der verlierende INSERT scheitert und der Aufrufer gibt die
-- bereits erzeugte Lizenz zurueck.
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_license_per_ticket
ON licenses(ticket_id) WHERE status IN ('issued','active');
-- Verbrauchte Nonces des Aktivierungsprotokolls. Verhindert Replay.
CREATE TABLE IF NOT EXISTS license_nonces (
nonce TEXT PRIMARY KEY,
license_id TEXT NOT NULL,
action TEXT NOT NULL,
seen_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_nonces_seen ON license_nonces(seen_at);
-- Wer hat wann welche Lizenzdatei erhalten.
CREATE TABLE IF NOT EXISTS license_deliveries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
license_id TEXT NOT NULL,
channel TEXT NOT NULL,
recipient TEXT NOT NULL DEFAULT '',
delivered_at TEXT NOT NULL,
delivered_by TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_deliveries_license ON license_deliveries(license_id);
-- Zeitlich begrenzte Zugangslinks des Kundenportals: einmalig verwendbar.
CREATE TABLE IF NOT EXISTS portal_access_tokens (
token TEXT PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES license_customers(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
created_ip TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_portal_tokens_customer ON portal_access_tokens(customer_id);
-- Von Sub-Lizenzservern gemeldeter Bestand. Rein informativ; dieser Master
-- verwaltet ihn nicht selbst. Jeder Bericht ersetzt alle Zeilen seiner Quelle
-- vollstaendig (Schnappschuss statt Delta) -- so verschwindet ein beim
-- Sub-Lizenzserver geloeschtes Ticket automatisch auch hier.
CREATE TABLE IF NOT EXISTS reported_customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_license_id TEXT NOT NULL,
remote_ref TEXT NOT NULL,
name TEXT NOT NULL,
contact_email TEXT NOT NULL DEFAULT '',
contact_person TEXT NOT NULL DEFAULT '',
city TEXT NOT NULL DEFAULT '',
last_reported_at TEXT NOT NULL,
UNIQUE(source_license_id, remote_ref)
);
CREATE INDEX IF NOT EXISTS idx_reported_customers_src ON reported_customers(source_license_id);
CREATE TABLE IF NOT EXISTS reported_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_license_id TEXT NOT NULL,
remote_ref TEXT NOT NULL,
customer_ref TEXT NOT NULL,
type TEXT NOT NULL,
modules TEXT NOT NULL DEFAULT '[]',
valid_days INTEGER,
lifetime INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open',
role TEXT NOT NULL DEFAULT 'customer',
created_at TEXT,
last_reported_at TEXT NOT NULL,
UNIQUE(source_license_id, remote_ref)
);
CREATE INDEX IF NOT EXISTS idx_reported_tickets_src ON reported_tickets(source_license_id);
CREATE TABLE IF NOT EXISTS reported_licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_license_id TEXT NOT NULL,
remote_ref TEXT NOT NULL,
ticket_ref TEXT,
type TEXT NOT NULL,
modules TEXT NOT NULL DEFAULT '[]',
lifetime INTEGER NOT NULL DEFAULT 0,
issued_at TEXT,
expires_at TEXT,
status TEXT NOT NULL,
fingerprint TEXT,
hostname TEXT,
activated_at TEXT,
last_heartbeat_at TEXT,
last_reported_at TEXT NOT NULL,
UNIQUE(source_license_id, remote_ref)
);
CREATE INDEX IF NOT EXISTS idx_reported_licenses_src ON reported_licenses(source_license_id);
"""
# Version 2 -----------------------------------------------------------------
#
# Wann wurde zuletzt eine neue Revision signiert? Aus diesem Zeitstempel und
# ``last_heartbeat_at`` ergibt sich, ob der Client sie schon hat. Ein eigener
# Status waere hier falsch: eine Lizenz kann aktiv sein *und* eine ausstehende
# Revision haben.
V2 = """
ALTER TABLE licenses ADD COLUMN revision_at TEXT;
CREATE INDEX IF NOT EXISTS idx_licenses_revision ON licenses(revision_at);
"""
LICENSE_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1, allow_integrity_error=True),
Migration(version=2, name="revision-timestamp", sql=V2),
)
@@ -0,0 +1 @@
"""Fachdienste des Lizenzservers -- Ausstellung, Aktivierung, Mail, Berichte."""
@@ -0,0 +1,345 @@
"""Aktivierung, Deaktivierung, Heartbeat -- die Serverseite des Protokolls.
Ein Satz Verarbeitungsfunktionen, zwei Transportwege: die JSON-API
(``/api/v1/licenses/*``) und der Offline-Code (Copy-Paste). Beide landen hier --
es gibt keinen zweiten, abweichenden Pfad, an dem eine Pruefung fehlen koennte.
Durchgesetzte Regeln:
* Signatur der Anfrage gegen den in der Datenbank gespeicherten
Lizenz-Public-Key -- nicht gegen etwas aus der Anfrage selbst.
* **Replay-Schutz**: jede Nonce genau einmal, plus Frischefenster auf dem
Zeitstempel. Der Vorgaenger pruefte beides nicht.
* **Bindung an die Maschine ueber den Fingerprint**, nie ueber den Hostnamen.
Der Hostname ist ein rein informatives Feld; er wird per ``COALESCE`` nur
ueberschrieben, wenn tatsaechlich ein Wert kommt.
* **Eine aktive Lizenz pro Maschine**: aktiviert jemand eine neue Lizenz auf
einem Fingerprint, der schon eine andere aktive traegt, wird die alte
automatisch deaktiviert. Pro Fingerprint, nicht pro Kunde -- ein Kunde darf
mehrere Maschinen gleichzeitig lizenziert haben.
"""
from __future__ import annotations
import json
import logging
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
import tesm_licensing as lic
import tesm_licensing_server as licsrv
from tesm_core import audit
from tesm_core.db import Database
logger = logging.getLogger(__name__)
NONCE_RETENTION_DAYS = 30
@dataclass(frozen=True, slots=True)
class Outcome:
"""Ergebnis einer Verarbeitung -- HTTP-Status inklusive."""
status_code: int
response: dict[str, Any]
@property
def ok(self) -> bool:
return self.status_code == 200
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _error(status_code: int, code: str, message: str) -> Outcome:
"""Fehlerantworten sind bewusst **unsigniert** und knapp.
Sie enthalten nichts, was einem Angreifer beim Raten hilft -- insbesondere
keine Auskunft darueber, ob eine Lizenz-ID existiert, sobald die Signatur
nicht passt.
"""
return Outcome(status_code, {"error": code, "message": message})
def _signed_response(
conn: sqlite3.Connection,
request: dict[str, Any],
*,
master_private_key: str,
status: str,
detail: str = "",
license_update: dict[str, Any] | None = None,
) -> Outcome:
response = licsrv.build_master_response(
str(request["action"]),
license_id=str(request["license_id"]),
fingerprint=str(request["fingerprint"]),
request_nonce=str(request["nonce"]),
master_private_key=master_private_key,
status=status,
detail=detail,
license_update=license_update,
)
return Outcome(200, response)
def _remember_nonce(conn: sqlite3.Connection, request: dict[str, Any]) -> None:
conn.execute(
"INSERT OR IGNORE INTO license_nonces (nonce, license_id, action, seen_at) "
"VALUES (?,?,?,?)",
(
str(request["nonce"]),
str(request["license_id"]),
str(request["action"]),
_now(),
),
)
def prune_nonces(conn: sqlite3.Connection) -> int:
horizon = (datetime.now(timezone.utc) - timedelta(days=NONCE_RETENTION_DAYS)).isoformat(
timespec="seconds"
)
cursor = conn.execute("DELETE FROM license_nonces WHERE seen_at < ?", (horizon,))
return cursor.rowcount or 0
def _verify(
conn: sqlite3.Connection, request: dict[str, Any], *, expected_action: str | None
) -> tuple[dict[str, Any] | None, Outcome | None]:
"""Sucht die Lizenz und prueft die Anfrage. Rueckgabe: ``(zeile, fehler)``."""
if not isinstance(request, dict):
return None, _error(400, "bad_request", "Anfrage ist kein Objekt.")
license_id = str(request.get("license_id") or "")
row = Database.one(conn, "SELECT * FROM licenses WHERE license_id=?", (license_id,))
if row is None:
return None, _error(404, "unknown_license", "Unbekannte Lizenz.")
def seen(nonce: str) -> bool:
return (
Database.value(conn, "SELECT 1 FROM license_nonces WHERE nonce=?", (nonce,))
is not None
)
try:
licsrv.verify_client_request(
request,
str(row["license_pubkey"]),
expected_action=expected_action,
seen_nonce=seen,
)
except lic.SignatureInvalid:
return None, _error(400, "invalid_signature", "Signatur der Anfrage ist ungueltig.")
except lic.ReplayDetected as exc:
return None, _error(409, "replay", str(exc))
except lic.ProtocolViolation as exc:
return None, _error(400, "bad_request", str(exc))
return row, None
# --------------------------------------------------------------------------- #
# Aktionen
# --------------------------------------------------------------------------- #
def process_activate(
conn: sqlite3.Connection, request: dict[str, Any], *, master_private_key: str
) -> Outcome:
row, error = _verify(conn, request, expected_action="activate")
if error is not None or row is None:
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
fingerprint = str(request["fingerprint"])
hostname = str(request.get("hostname") or "")
status = str(row["status"])
if status == "revoked":
_remember_nonce(conn, request)
return _signed_response(
conn,
request,
master_private_key=master_private_key,
status="revoked",
detail="Diese Lizenz wurde widerrufen.",
)
if status == "active" and str(row["fingerprint"] or "") not in {"", fingerprint}:
_remember_nonce(conn, request)
return _error(
409,
"already_bound_elsewhere",
"Diese Lizenz ist bereits auf einer anderen Maschine aktiv. "
"Bitte dort zuerst deaktivieren.",
)
# Eine Maschine traegt hoechstens eine aktive Lizenz: eine andere aktive
# Lizenz auf demselben Fingerprint wird verdraengt.
superseded = Database.all(
conn,
"SELECT license_id FROM licenses WHERE fingerprint=? AND status='active' "
"AND license_id<>?",
(fingerprint, str(row["license_id"])),
)
for other in superseded:
conn.execute(
"UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?",
(_now(), other["license_id"]),
)
conn.execute(
"UPDATE licenses SET status='active', fingerprint=?, "
"hostname=COALESCE(NULLIF(?, ''), hostname), activated_at=?, deactivated_at=NULL "
"WHERE license_id=?",
(fingerprint, hostname, _now(), str(row["license_id"])),
)
_remember_nonce(conn, request)
audit.log(
conn,
actor="api",
action="license.activated",
target=str(row["license_id"]),
detail=(
f"Fingerprint {fingerprint[:16]}..., Host {hostname or 'unbekannt'}"
+ (f", verdraengt {len(superseded)} Lizenz(en)" if superseded else "")
),
severity="notice",
)
return _signed_response(
conn,
request,
master_private_key=master_private_key,
status="ok",
detail="Aktivierung bestaetigt.",
)
def process_deactivate(
conn: sqlite3.Connection, request: dict[str, Any], *, master_private_key: str
) -> Outcome:
row, error = _verify(conn, request, expected_action="deactivate")
if error is not None or row is None:
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
fingerprint = str(request["fingerprint"])
if str(row["status"]) != "active" or str(row["fingerprint"] or "") != fingerprint:
_remember_nonce(conn, request)
return _error(
409,
"not_active_on_this_fingerprint",
"Diese Lizenz ist auf dieser Maschine nicht aktiv.",
)
conn.execute(
"UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?",
(_now(), str(row["license_id"])),
)
_remember_nonce(conn, request)
audit.log(
conn,
actor="api",
action="license.deactivated",
target=str(row["license_id"]),
detail=f"Fingerprint {fingerprint[:16]}...",
severity="notice",
)
return _signed_response(
conn,
request,
master_private_key=master_private_key,
status="ok",
detail="Deaktivierung bestaetigt. Die Lizenz kann auf einer anderen Maschine aktiviert werden.",
)
def process_heartbeat(
conn: sqlite3.Connection, request: dict[str, Any], *, master_private_key: str
) -> Outcome:
row, error = _verify(conn, request, expected_action="heartbeat")
if error is not None or row is None:
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
fingerprint = str(request["fingerprint"])
hostname = str(request.get("hostname") or "")
if str(row["status"]) == "revoked":
# Auch hier eine signierte Antwort: der Client loescht seine Lizenzdatei
# erst, wenn er die Signatur selbst geprueft hat.
_remember_nonce(conn, request)
return _signed_response(
conn,
request,
master_private_key=master_private_key,
status="revoked",
detail="Diese Lizenz wurde widerrufen.",
)
if str(row["status"]) != "active" or str(row["fingerprint"] or "") != fingerprint:
_remember_nonce(conn, request)
return _error(409, "not_active", "Diese Lizenz ist auf dieser Maschine nicht aktiv.")
conn.execute(
"UPDATE licenses SET last_heartbeat_at=?, hostname=COALESCE(NULLIF(?, ''), hostname) "
"WHERE license_id=?",
(_now(), hostname, str(row["license_id"])),
)
_remember_nonce(conn, request)
report = request.get("report")
if report is not None:
from .reporting import ingest_report
# Nur Lizenzen, die in **dieser** Datenbank als Lizenzserver eingetragen
# sind, duerfen berichten. Was der Client behauptet, ist unerheblich.
if str(row["role"]) == "license_server":
ingested = ingest_report(conn, str(row["license_id"]), report)
audit.log(
conn,
actor="api",
action="license.report_ingested",
target=str(row["license_id"]),
detail=(
f"{ingested['customers']} Kunden, {ingested['tickets']} Tickets, "
f"{ingested['licenses']} Lizenzen"
),
)
else:
logger.warning(
"Bericht von Lizenz %s verworfen -- Rolle ist %s, nicht license_server",
row["license_id"],
row["role"],
)
# Nachtraegliche Aenderungen (z. B. Modulerweiterung) erreichen den Client
# ueber den Heartbeat -- ohne erneute Aktivierung.
document = json.loads(str(row["license_document"]))
client_seq = int(request.get("seq") or 0)
update = document if int(document.get("seq") or 1) > client_seq else None
return _signed_response(
conn,
request,
master_private_key=master_private_key,
status="ok",
detail="Verbindung bestaetigt.",
license_update=update,
)
PROCESSORS = {
"activate": process_activate,
"deactivate": process_deactivate,
"heartbeat": process_heartbeat,
}
def process(
conn: sqlite3.Connection, request: dict[str, Any], *, master_private_key: str
) -> Outcome:
"""Verteilt anhand des ``action``-Feldes -- fuer den Offline-Code-Weg."""
action = str((request or {}).get("action") or "")
processor = PROCESSORS.get(action)
if processor is None:
return _error(400, "bad_action", "Unbekannte Aktion.")
return processor(conn, request, master_private_key=master_private_key)
@@ -0,0 +1,766 @@
"""Kunden, Tickets und die Ausstellung von Lizenzen."""
from __future__ import annotations
import json
import re
import secrets
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Iterable, Sequence
import tesm_licensing as lic
import tesm_licensing_server as licsrv
from tesm_core.db import Database
TICKET_TYPES = lic.LICENSE_TYPES
TICKET_STATES = ("open", "blocked")
LICENSE_STATES = ("issued", "active", "deactivated", "revoked")
#: Ampel-Schwellen der Anzeige. Bewusst getrennt von der Kulanzlogik in
#: ``tesm_licensing`` -- gleiche Zahl (30), anderes Konzept.
GREEN_ABOVE_DAYS = 90
RED_BELOW_DAYS = 30
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
class IssuanceError(Exception):
"""Eingabe- oder Regelverstoss mit Klartextmeldung."""
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
# --------------------------------------------------------------------------- #
# Kunden
# --------------------------------------------------------------------------- #
def validate_email(value: str, *, required: bool) -> str:
text = (value or "").strip()
if not text:
if required:
raise IssuanceError("Eine E-Mail-Adresse ist erforderlich.")
# Leer bleibt erlaubt: Altkunden von vor der Pflichtfeld-Einfuehrung
# sollen weiter bearbeitbar bleiben, statt ausgesperrt zu werden.
return ""
if not EMAIL_RE.match(text):
raise IssuanceError(f"{text!r} ist keine gueltige E-Mail-Adresse.")
return text
def list_customers(conn: sqlite3.Connection) -> list[dict[str, Any]]:
return Database.all(
conn,
"""
SELECT c.*,
(SELECT COUNT(*) FROM license_tickets t WHERE t.customer_id = c.id) AS ticket_count,
(SELECT COUNT(*) FROM licenses l
WHERE l.customer_id = c.id AND l.status IN ('issued','active')) AS open_licenses,
(SELECT l.license_id FROM licenses l
WHERE l.customer_id = c.id AND l.status='active' AND l.role='license_server'
LIMIT 1) AS license_server_id
FROM license_customers c
ORDER BY c.name COLLATE NOCASE
""",
)
def get_customer(conn: sqlite3.Connection, customer_id: int) -> dict[str, Any] | None:
return Database.one(conn, "SELECT * FROM license_customers WHERE id=?", (customer_id,))
def create_customer(conn: sqlite3.Connection, *, actor: str, **fields: Any) -> int:
name = str(fields.get("name") or "").strip()
if not name:
raise IssuanceError("Der Firmenname darf nicht leer sein.")
email = validate_email(str(fields.get("contact_email") or ""), required=True)
cursor = conn.execute(
"INSERT INTO license_customers (name, contact_email, contact_phone, contact_person, "
"street, house_number, postal_code, city, notes, created_at, updated_at, created_by) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(
name,
email,
str(fields.get("contact_phone") or "").strip(),
str(fields.get("contact_person") or "").strip(),
str(fields.get("street") or "").strip(),
str(fields.get("house_number") or "").strip(),
str(fields.get("postal_code") or "").strip(),
str(fields.get("city") or "").strip(),
str(fields.get("notes") or "").strip(),
_now(),
_now(),
actor,
),
)
return int(cursor.lastrowid or 0)
def update_customer(conn: sqlite3.Connection, customer_id: int, **fields: Any) -> list[str]:
row = get_customer(conn, customer_id)
if row is None:
raise IssuanceError("Dieser Kunde existiert nicht.")
name = str(fields.get("name") or "").strip()
if not name:
raise IssuanceError("Der Firmenname darf nicht leer sein.")
email = validate_email(str(fields.get("contact_email") or ""), required=False)
columns = {
"name": name,
"contact_email": email,
"contact_phone": str(fields.get("contact_phone") or "").strip(),
"contact_person": str(fields.get("contact_person") or "").strip(),
"street": str(fields.get("street") or "").strip(),
"house_number": str(fields.get("house_number") or "").strip(),
"postal_code": str(fields.get("postal_code") or "").strip(),
"city": str(fields.get("city") or "").strip(),
"notes": str(fields.get("notes") or "").strip(),
}
changes = [key for key, value in columns.items() if str(row.get(key) or "") != value]
if not changes:
return []
assignments = ", ".join(f"{key}=?" for key in columns)
conn.execute(
f"UPDATE license_customers SET {assignments}, updated_at=? WHERE id=?",
(*columns.values(), _now(), customer_id),
)
return changes
def delete_customer(conn: sqlite3.Connection, customer_id: int) -> None:
tickets = Database.value(
conn, "SELECT COUNT(*) FROM license_tickets WHERE customer_id=?", (customer_id,), default=0
)
if int(tickets or 0):
raise IssuanceError(
f"An diesem Kunden haengen noch {tickets} Ticket(s). Bitte zuerst entfernen."
)
conn.execute("DELETE FROM license_customers WHERE id=?", (customer_id,))
def find_customer_by_email(conn: sqlite3.Connection, email: str) -> dict[str, Any] | None:
return Database.one(
conn,
"SELECT * FROM license_customers WHERE contact_email <> '' "
"AND lower(contact_email)=lower(?)",
((email or "").strip(),),
)
# --------------------------------------------------------------------------- #
# Tickets
# --------------------------------------------------------------------------- #
def list_tickets(
conn: sqlite3.Connection, *, customer_id: int | None = None
) -> list[dict[str, Any]]:
clause = "WHERE t.customer_id = ?" if customer_id else ""
params = (customer_id,) if customer_id else ()
return Database.all(
conn,
f"""
SELECT t.*, c.name AS customer_name, c.contact_email,
(SELECT COUNT(*) FROM licenses l WHERE l.ticket_id = t.id) AS license_count,
(SELECT l.license_id FROM licenses l
WHERE l.ticket_id = t.id AND l.status IN ('issued','active') LIMIT 1) AS open_license_id
FROM license_tickets t
JOIN license_customers c ON c.id = t.customer_id
{clause}
ORDER BY t.created_at DESC
""",
params,
)
def get_ticket(conn: sqlite3.Connection, ticket_id: str) -> dict[str, Any] | None:
return Database.one(
conn,
"SELECT t.*, c.name AS customer_name, c.contact_email, c.contact_person "
"FROM license_tickets t JOIN license_customers c ON c.id = t.customer_id WHERE t.id=?",
(ticket_id,),
)
def normalize_modules(license_type: str, role: str, modules: Iterable[str]) -> list[str]:
"""Bestimmt den Modulsatz eines Tickets.
Regeln (bewusst hier zentral, nicht in der Oberflaeche):
* ``role != "customer"``: immer leer -- das TESM-Modulkonzept ist fuer einen
Lizenzserver bedeutungslos.
* ``trial``/``standard``: leer (Trial bekommt den vollen Satz erst bei der
Ausstellung als Trial-Override).
* ``enterprise``: alle Module.
* ``custom``: die uebergebene Auswahl.
"""
if role != "customer":
return []
if license_type == "enterprise":
return sorted(lic.ALL_MODULES)
if license_type == "custom":
return sorted(set(modules) & set(lic.ALL_MODULES))
return []
def create_ticket(
conn: sqlite3.Connection,
*,
customer_id: int,
license_type: str,
modules: Sequence[str],
valid_days: int,
lifetime: bool,
role: str,
notes: str = "",
actor: str = "",
) -> str:
if get_customer(conn, customer_id) is None:
raise IssuanceError("Dieser Kunde existiert nicht.")
if license_type not in TICKET_TYPES:
raise IssuanceError("Unbekannter Lizenztyp.")
if role not in lic.LICENSE_ROLES or role == "master":
# "master" entsteht ausschliesslich lokal per Bootstrap-Skript.
raise IssuanceError("Unzulaessige Rolle.")
# Trial + unbefristet ist ein sinnloser Zustand: eine permanente Lizenz ohne
# Module. Diese Regel wird an drei Stellen geprueft (hier, beim Bearbeiten
# und unmittelbar vor dem Signieren).
if lifetime and license_type == "trial":
raise IssuanceError("Eine Testlizenz kann nicht unbefristet sein.")
if not lifetime and int(valid_days) <= 0:
raise IssuanceError("Die Laufzeit muss positiv sein.")
ticket_id = secrets.token_urlsafe(24)
conn.execute(
"INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, lifetime, "
"status, role, notes, created_by, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,'open',?,?,?,?,?)",
(
ticket_id,
customer_id,
license_type,
json.dumps(normalize_modules(license_type, role, modules)),
int(valid_days),
int(bool(lifetime)),
role,
(notes or "").strip(),
actor,
_now(),
_now(),
),
)
return ticket_id
def update_ticket(
conn: sqlite3.Connection,
ticket_id: str,
*,
license_type: str,
modules: Sequence[str],
valid_days: int,
lifetime: bool,
notes: str = "",
) -> list[str]:
"""Aendert die Parameter eines Tickets.
Wirkt ausschliesslich auf die **naechste** Ausstellung. Bereits ausgestellte
Lizenzen behalten die Werte, mit denen sie signiert wurden -- eine
nachtraegliche Aenderung waere sonst eine Signaturaenderung ohne neue Datei.
Kunde und Rolle sind nach der Anlage unveraenderlich: das Self-Service-Ticket
ist an genau diesen Kunden gebunden, und ein Rollenwechsel wuerde eine
Vertrauensgrenze verschieben.
"""
ticket = get_ticket(conn, ticket_id)
if ticket is None:
raise IssuanceError("Dieses Ticket existiert nicht.")
if license_type not in TICKET_TYPES:
raise IssuanceError("Unbekannter Lizenztyp.")
if lifetime and license_type == "trial":
raise IssuanceError("Eine Testlizenz kann nicht unbefristet sein.")
role = str(ticket["role"])
new_modules = json.dumps(normalize_modules(license_type, role, modules))
changes: list[str] = []
if license_type != str(ticket["type"]):
changes.append(f"Typ: {ticket['type']} -> {license_type}")
if new_modules != str(ticket["modules"]):
changes.append("Module geaendert")
if int(valid_days) != int(ticket["valid_days"]):
changes.append(f"Laufzeit: {ticket['valid_days']} -> {valid_days} Tage")
if bool(lifetime) != bool(ticket["lifetime"]):
changes.append(f"Unbefristet: {bool(ticket['lifetime'])} -> {bool(lifetime)}")
conn.execute(
"UPDATE license_tickets SET type=?, modules=?, valid_days=?, lifetime=?, notes=?, "
"updated_at=? WHERE id=?",
(
license_type,
new_modules,
int(valid_days),
int(bool(lifetime)),
(notes or "").strip(),
_now(),
ticket_id,
),
)
return changes
def set_ticket_status(conn: sqlite3.Connection, ticket_id: str, status: str) -> None:
if status not in TICKET_STATES:
raise IssuanceError("Unbekannter Ticketzustand.")
conn.execute(
"UPDATE license_tickets SET status=?, updated_at=? WHERE id=?",
(status, _now(), ticket_id),
)
def ticket_has_open_license(conn: sqlite3.Connection, ticket_id: str) -> bool:
return (
Database.value(
conn,
"SELECT COUNT(*) FROM licenses WHERE ticket_id=? AND status IN ('issued','active')",
(ticket_id,),
default=0,
)
or 0
) > 0
def delete_ticket(conn: sqlite3.Connection, ticket_id: str) -> None:
if ticket_has_open_license(conn, ticket_id):
raise IssuanceError(
"Zu diesem Ticket existiert noch eine offene Lizenz. Bitte zuerst widerrufen."
)
# Historische Lizenzzeilen haengen per ON DELETE CASCADE mit -- sonst blieben
# verwaiste Zeilen mit toter ticket_id zurueck.
conn.execute("DELETE FROM license_tickets WHERE id=?", (ticket_id,))
# --------------------------------------------------------------------------- #
# Lizenzen
# --------------------------------------------------------------------------- #
def list_licenses(
conn: sqlite3.Connection,
*,
customer_id: int | None = None,
ticket_id: str | None = None,
status: str | None = None,
) -> list[dict[str, Any]]:
clauses: list[str] = []
params: list[Any] = []
if customer_id:
clauses.append("l.customer_id = ?")
params.append(customer_id)
if ticket_id:
clauses.append("l.ticket_id = ?")
params.append(ticket_id)
if status:
clauses.append("l.status = ?")
params.append(status)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
return Database.all(
conn,
f"""
SELECT l.*, c.name AS customer_name, c.contact_email
FROM licenses l JOIN license_customers c ON c.id = l.customer_id
{where}
ORDER BY l.created_at DESC
""",
tuple(params),
)
def get_license(conn: sqlite3.Connection, license_id: str) -> dict[str, Any] | None:
return Database.one(
conn,
"SELECT l.*, c.name AS customer_name, c.contact_email, c.contact_person "
"FROM licenses l JOIN license_customers c ON c.id = l.customer_id WHERE l.license_id=?",
(license_id,),
)
@dataclass(frozen=True, slots=True)
class IssuedLicense:
license_id: str
bundle: dict[str, Any]
document: dict[str, Any]
already_existed: bool = False
def client_key_aad(license_id: str) -> str:
return f"licenses.client_key:{license_id}"
def issue_from_ticket(
conn: sqlite3.Connection,
ticket_id: str,
*,
master_private_key: str,
master_public_key: str,
master_endpoint: str,
vendor: dict[str, Any],
actor: str,
keystore: Any,
as_trial: bool = False,
) -> IssuedLicense:
"""Stellt aus einem Ticket eine Lizenz aus.
``as_trial`` ist ein reiner **Ausstellungs-Override**: 30 Tage, alle Module,
Typ ``trial``. Das Ticket selbst bleibt dabei unveraendert -- nur diese eine
Datei traegt Trial-Konditionen. Fuer Tickets mit einer anderen Rolle als
``customer`` ist der Override sinnlos und wird abgewiesen.
"""
ticket = get_ticket(conn, ticket_id)
if ticket is None:
raise IssuanceError("Dieses Ticket existiert nicht.")
if str(ticket["status"]) != "open":
raise IssuanceError("Dieses Ticket ist gesperrt.")
role = str(ticket["role"])
if as_trial and role != "customer":
raise IssuanceError(
"Eine Testlizenz gibt es nur fuer Kundeninstanzen, nicht fuer Lizenzserver."
)
if as_trial:
license_type = "trial"
modules = sorted(lic.ALL_MODULES)
valid_days = lic.TRIAL_DEFAULT_DAYS
lifetime = False
else:
license_type = str(ticket["type"])
modules = json.loads(str(ticket["modules"] or "[]"))
valid_days = int(ticket["valid_days"])
lifetime = bool(ticket["lifetime"])
# Dritte Absicherung derselben Regel, unmittelbar vor dem Signieren.
if lifetime and license_type == "trial":
raise IssuanceError("Eine Testlizenz kann nicht unbefristet sein.")
# Der Endpunkt wird mitsigniert. Ohne ihn entsteht eine Lizenz, die sich
# nie online aktivieren laesst -- und das laesst sich nachtraeglich nur
# durch eine neue Signatur beheben, nicht durch eine Einstellung. Deshalb
# hier, an der einzigen Stelle, durch die jede Ausstellung laeuft.
if not str(master_endpoint or "").strip():
raise IssuanceError(
"Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
"Ohne ihn koennte sich diese Lizenz nie online aktivieren -- der Endpunkt "
"wird in die Lizenz signiert und laesst sich spaeter nicht nachtragen."
)
customer = get_customer(conn, int(ticket["customer_id"]))
assert customer is not None
bundle, license_pub = licsrv.issue_license(
customer={
"name": str(customer["name"]),
"contact_email": str(customer["contact_email"] or ""),
"customer_ref": str(customer["id"]),
},
license_type=license_type,
modules=modules,
valid_days=valid_days,
master_private_key_b64=master_private_key,
master_public_key_b64=master_public_key,
master_endpoint=master_endpoint,
vendor=vendor,
role=role,
lifetime=lifetime,
)
document = bundle["license"]
try:
conn.execute(
"INSERT INTO licenses (license_id, ticket_id, customer_id, type, role, modules, "
"lifetime, seq, issued_at, expires_at, license_pubkey, license_document, "
"client_key_encrypted, status, created_by, created_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,'issued',?,?)",
(
document["license_id"],
ticket_id,
int(ticket["customer_id"]),
license_type,
role,
json.dumps(modules),
int(bool(lifetime)),
int(document["seq"]),
document["issued_at"],
document["expires_at"],
license_pub,
json.dumps(document),
keystore.encrypt(
str(bundle["client_key"]), aad=client_key_aad(str(document["license_id"]))
),
actor,
_now(),
),
)
except sqlite3.IntegrityError:
# Der partielle Unique-Index hat zugeschlagen: ein zweiter Klick oder ein
# zweiter Tab war schneller. Wir geben die tatsaechlich erzeugte Lizenz
# zurueck statt einen Fehler zu zeigen.
existing = Database.one(
conn,
"SELECT license_id, license_document FROM licenses "
"WHERE ticket_id=? AND status IN ('issued','active')",
(ticket_id,),
)
if existing is None:
raise
return IssuedLicense(
license_id=str(existing["license_id"]),
bundle={},
document=json.loads(str(existing["license_document"])),
already_existed=True,
)
return IssuedLicense(
license_id=str(document["license_id"]), bundle=bundle, document=document
)
def delivery_bundle(conn: sqlite3.Connection, license_id: str, *, keystore: Any) -> dict[str, Any]:
"""Baut das Auslieferungs-Bundle inklusive Client-Schluessel neu auf.
Der Schluessel liegt verschluesselt in der Datenbank; hier wird er genau
fuer die Auslieferung entschluesselt. Fehlt er (Lizenz aus einer aelteren
Version), enthaelt das Bundle nur das Dokument -- der Kunde braucht dann eine
neu ausgestellte Lizenz.
"""
row = get_license(conn, license_id)
if row is None:
raise IssuanceError("Diese Lizenz existiert nicht.")
document = json.loads(str(row["license_document"]))
bundle: dict[str, Any] = {
"kind": "tesm-license-bundle",
"fmt": lic.FORMAT_VERSION,
"license": document,
}
encrypted = str(row["client_key_encrypted"] or "")
if encrypted:
try:
bundle["client_key"] = keystore.decrypt(encrypted, aad=client_key_aad(license_id))
except Exception as exc: # noqa: BLE001
raise IssuanceError(
"Der Client-Schluessel dieser Lizenz konnte nicht entschluesselt werden. "
"Bitte eine neue Lizenz ausstellen."
) from exc
return bundle
def revoke_license(conn: sqlite3.Connection, license_id: str, *, reason: str = "") -> None:
"""Widerruft eine Lizenz.
Das Ticket bleibt bewusst offen: ein Widerruf ist Routine (Systemwechsel,
Neuinstallation), keine Sperre. Wer den Kunden aussperren will, sperrt das
Ticket.
"""
row = get_license(conn, license_id)
if row is None:
raise IssuanceError("Diese Lizenz existiert nicht.")
if str(row["status"]) == "revoked":
raise IssuanceError("Diese Lizenz ist bereits widerrufen.")
conn.execute(
"UPDATE licenses SET status='revoked', revoked_at=?, revoke_reason=? WHERE license_id=?",
(_now(), (reason or "").strip()[:400], license_id),
)
def delete_license(conn: sqlite3.Connection, license_id: str) -> None:
row = get_license(conn, license_id)
if row is None:
raise IssuanceError("Diese Lizenz existiert nicht.")
if str(row["status"]) not in {"revoked", "issued"}:
raise IssuanceError(
"Eine aktive Lizenz kann nicht geloescht werden. Bitte zuerst widerrufen."
)
conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,))
def resign_license(
conn: sqlite3.Connection,
license_id: str,
*,
master_private_key: str,
changes: dict[str, Any],
) -> dict[str, Any]:
"""Signiert eine neue Revision -- z. B. nach einer Modulerweiterung.
Der Client erhaelt sie beim naechsten Heartbeat. ``seq`` steigt dabei; eine
aeltere Revision kann ihm damit nicht mehr eingespielt werden.
"""
row = get_license(conn, license_id)
if row is None:
raise IssuanceError("Diese Lizenz existiert nicht.")
document = json.loads(str(row["license_document"]))
updated = licsrv.resign_license(
document, master_private_key_b64=master_private_key, changes=changes
)
conn.execute(
"UPDATE licenses SET license_document=?, seq=?, type=?, modules=?, lifetime=?, "
"expires_at=?, revision_at=? WHERE license_id=?",
(
json.dumps(updated),
int(updated["seq"]),
str(updated["type"]),
json.dumps(updated["modules"]),
int(bool(updated["lifetime"])),
str(updated["expires_at"]),
_now(),
license_id,
),
)
return updated
def record_delivery(
conn: sqlite3.Connection,
*,
license_id: str,
channel: str,
recipient: str,
actor: str,
detail: str = "",
) -> None:
conn.execute(
"INSERT INTO license_deliveries (license_id, channel, recipient, delivered_at, "
"delivered_by, detail) VALUES (?,?,?,?,?,?)",
(license_id, channel, recipient, _now(), actor, detail[:400]),
)
# --------------------------------------------------------------------------- #
# Anzeige
# --------------------------------------------------------------------------- #
def revision_pending(row: Any) -> bool:
"""Wurde seit der letzten Revision noch kein Kontakt vom Client gesehen?
Widerrufene Lizenzen zaehlen nicht -- dort ist nichts mehr abzuholen.
"""
def field(name: str) -> Any:
# Die Zeile kommt aus verschiedenen Abfragen; nicht jede waehlt alles.
# Eine fehlende Spalte darf keine Ausnahme werfen -- die Funktion sitzt
# in Listen, Detailseiten und im Kundenportal.
try:
return row[name]
except (KeyError, IndexError, TypeError):
return None
revision_at = field("revision_at")
if not revision_at or str(field("status") or "") == "revoked":
return False
last = field("last_heartbeat_at") or field("activated_at")
if not last:
return True
# Beide Zeitstempel stammen aus ``_now()`` und sind damit gleich formatiert.
return str(last) < str(revision_at)
def display_state(row: dict[str, Any], *, now: datetime | None = None) -> dict[str, Any]:
"""Ampelfarbe und Resttage einer Lizenzzeile.
Genau eine Stelle im Code, von jeder Oberflaeche benutzt (Dashboard,
Listen, Detailseiten, Self-Service, Kundenportal, gemeldete Bestaende der
Sub-Lizenzserver).
Grenzen: mehr als 90 Tage = gruen, 30 bis 90 Tage = orange, unter 30 Tage
(auch abgelaufen) = rot. Unbefristete Lizenzen sind immer gruen.
"""
moment = now or datetime.now(timezone.utc)
result = {
"color": "green",
"days_left": None,
"is_expired": False,
"lifetime": bool(row.get("lifetime")),
# Kein eigener Status: eine Lizenz kann aktiv sein *und* eine noch
# nicht abgeholte Revision haben.
"revision_pending": revision_pending(row),
}
if row.get("lifetime"):
return result
raw = row.get("expires_at")
if not raw:
return {**result, "color": "red"}
try:
expires = lic.parse_iso(str(raw))
except ValueError:
# Fail-safe: unlesbares Datum gilt als kritisch, nicht als in Ordnung.
return {**result, "color": "red"}
delta = expires - moment
days_left = int(delta.total_seconds() // 86400)
expired = delta.total_seconds() <= 0
if expired:
color = "red"
elif days_left < RED_BELOW_DAYS:
color = "red"
elif days_left <= GREEN_ABOVE_DAYS:
color = "orange"
else:
color = "green"
return {
**result,
"color": color,
"days_left": max(0, days_left),
"is_expired": expired,
"lifetime": False,
}
def dashboard_summary(conn: sqlite3.Connection) -> dict[str, Any]:
rows = list_licenses(conn)
buckets: dict[str, list[dict[str, Any]]] = {"red": [], "orange": [], "green": []}
for row in rows:
if str(row["status"]) in {"revoked", "deactivated"}:
continue
state = display_state(row)
entry = {**row, **state}
buckets[state["color"]].append(entry)
return {
"buckets": buckets,
"counts": {
"customers": int(
Database.value(conn, "SELECT COUNT(*) FROM license_customers", default=0) or 0
),
"tickets_open": int(
Database.value(
conn, "SELECT COUNT(*) FROM license_tickets WHERE status='open'", default=0
)
or 0
),
"tickets_blocked": int(
Database.value(
conn, "SELECT COUNT(*) FROM license_tickets WHERE status='blocked'", default=0
)
or 0
),
"licenses_active": int(
Database.value(
conn, "SELECT COUNT(*) FROM licenses WHERE status='active'", default=0
)
or 0
),
"licenses_issued": int(
Database.value(
conn, "SELECT COUNT(*) FROM licenses WHERE status='issued'", default=0
)
or 0
),
"licenses_revoked": int(
Database.value(
conn, "SELECT COUNT(*) FROM licenses WHERE status='revoked'", default=0
)
or 0
),
},
}
@@ -0,0 +1,355 @@
"""Uebernahme eines Lizenzservers der Vorgaengerfassung.
Die alten Lizenzdateien sind mit der neuen Fassung **nicht** verwendbar, und
das ist Absicht: dort wurde ohne Domaenentrennung signiert (die Signatur ueber
eine Lizenz war damit auch als Signatur ueber eine Protokollantwort gueltig),
es fehlten Format-Version, Sequenznummer und Gueltigkeitsbeginn. Beides
gleichzeitig zu akzeptieren hiesse, die Haertung wieder aufzugeben -- eine
Uebergangsfrist waere hier eine dauerhaft offene Tuer.
Was **sehr wohl** uebernommen wird, ist alles andere:
* **Der Signaturschluessel.** Damit bleibt die Anbieteridentitaet dieselbe. Bei
Kunden, die den Aussteller bereits gepinnt haben, passt der Schluessel
weiterhin -- sie muessen keinen Ausstellerwechsel bestaetigen.
* **Kunden, Tickets und Lizenzen** aus der alten Datenbank.
Jede uebernommene Lizenz wird im neuen Format **neu signiert**. Der Kunde
bekommt dadurch eine neue Lizenzdatei; das laesst sich nicht vermeiden, denn
die alte traegt eine Signatur, die es in dieser Form nicht mehr gibt. Der
Bericht am Ende sagt genau, wer eine neue Datei braucht.
Der Vorgang ist **lesend** gegenueber der alten Installation. Sie wird nicht
angefasst, nicht angehalten und nicht veraendert.
"""
from __future__ import annotations
import json
import sqlite3
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import tesm_licensing as lic
import tesm_licensing_server as licsrv
from tesm_core.db import Database
from tesm_core.keystore import Keystore
from . import issuance
class LegacyImportError(Exception):
"""Die alte Installation ist nicht lesbar oder passt nicht."""
@dataclass
class Report:
"""Was die Uebernahme getan hat -- vollstaendig und nachpruefbar."""
key_adopted: bool = False
key_id: str = ""
customers: int = 0
tickets: int = 0
licenses: int = 0
reissued: list[str] = field(default_factory=list)
skipped: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def as_text(self) -> str:
lines = [
f"Signaturschluessel uebernommen: {'ja' if self.key_adopted else 'nein'}"
+ (f" ({self.key_id})" if self.key_id else ""),
f"Kunden : {self.customers}",
f"Tickets : {self.tickets}",
f"Lizenzen : {self.licenses}",
]
if self.reissued:
lines.append("")
lines.append("Neu signiert -- diese Kunden brauchen eine neue Lizenzdatei:")
lines.extend(f" * {entry}" for entry in self.reissued)
if self.skipped:
lines.append("")
lines.append("Uebersprungen:")
lines.extend(f" * {entry}" for entry in self.skipped)
if self.warnings:
lines.append("")
lines.append("Hinweise:")
lines.extend(f" * {entry}" for entry in self.warnings)
return "\n".join(lines)
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
# --------------------------------------------------------------------------- #
# Quelle
# --------------------------------------------------------------------------- #
def open_legacy(root: Path) -> sqlite3.Connection:
"""Oeffnet die alte Datenbank **nur lesend**."""
path = Path(root) / "sqlite.db"
if not path.is_file():
raise LegacyImportError(f"Unter {path} liegt keine Datenbank der Vorgaengerfassung.")
try:
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
except sqlite3.Error as exc:
raise LegacyImportError(f"Die alte Datenbank ist nicht lesbar: {exc}") from exc
conn.row_factory = sqlite3.Row
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
missing = {"license_customers", "license_tickets", "licenses"} - tables
if missing:
conn.close()
raise LegacyImportError(
f"Das sieht nicht nach einem Lizenzserver aus -- es fehlen: {', '.join(sorted(missing))}"
)
return conn
def read_legacy_key(root: Path) -> tuple[str, str]:
"""Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``."""
path = Path(root) / "master_signing_key.json"
if not path.is_file():
raise LegacyImportError(f"Der alte Signaturschluessel {path} fehlt.")
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise LegacyImportError(f"Der alte Signaturschluessel ist unlesbar: {exc}") from exc
# Die Vorgaengerfassung nannte die Felder "private"/"public".
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:
raise LegacyImportError(
"Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht "
"zueinander. Die Datei ist beschaedigt."
)
return private, public
def adopt_key(private: str, public: str, target: Path) -> str:
"""Uebernimmt den alten Schluessel in das Format der neuen Fassung.
Damit bleibt die Anbieteridentitaet erhalten: Kunden, die den Aussteller
schon gepinnt haben, sehen weiterhin denselben Schluessel und muessen
keinen Wechsel bestaetigen.
"""
from tesm_core.keystore import write_private_file
if Path(target).is_file():
raise LegacyImportError(
f"Unter {target} liegt bereits ein Signaturschluessel. Er wird nicht "
"ueberschrieben -- das wuerde jede damit ausgestellte Lizenz unbrauchbar "
"machen. Fuer eine Uebernahme bitte eine frische Installation verwenden."
)
payload = {
"private_key": private,
"public_key": public,
"key_id": lic.key_id(public),
"created_at": _now(),
"origin": "Uebernommen aus der Vorgaengerfassung.",
}
write_private_file(Path(target), json.dumps(payload, indent=2, sort_keys=True) + "\n")
return lic.key_id(public)
# --------------------------------------------------------------------------- #
# Uebernahme
# --------------------------------------------------------------------------- #
def _remaining_days(expires_at: str) -> int:
"""Restlaufzeit in Tagen, mindestens 1 bei noch gueltiger Lizenz.
Die neue Ausstellung rechnet in Tagen ab jetzt. Wer noch drei Monate
hatte, soll auch nach der Uebernahme drei Monate haben -- nicht ploetzlich
ein volles Jahr und nicht weniger als vorher.
"""
try:
moment = lic.parse_iso(expires_at)
except (ValueError, TypeError):
return 0
remaining = (moment - datetime.now(timezone.utc)).days
return remaining if remaining > 0 else 0
def _modules(raw: Any) -> list[str]:
try:
values = json.loads(str(raw or "[]"))
except ValueError:
return []
return sorted(set(str(value) for value in values) & set(lic.ALL_MODULES))
def import_all(
legacy: sqlite3.Connection,
conn: sqlite3.Connection,
*,
keystore: Keystore,
master_private_key: str,
master_public_key: str,
master_endpoint: str,
vendor: dict[str, Any],
actor: str = "migration",
reissue: bool = True,
) -> Report:
"""Uebernimmt Kunden, Tickets und Lizenzen. Erwartet eine offene Transaktion."""
report = Report()
# -- Kunden ------------------------------------------------------------ #
customer_map: dict[int, int] = {}
for row in legacy.execute("SELECT * FROM license_customers ORDER BY id"):
name = str(row["name"] or "").strip()
if not name:
report.skipped.append(f"Kunde ohne Namen (alte Nummer {row['id']})")
continue
existing = Database.one(
conn, "SELECT id FROM license_customers WHERE name=? COLLATE NOCASE", (name,)
)
if existing is not None:
customer_map[int(row["id"])] = int(existing["id"])
report.warnings.append(f"Kunde {name!r} war schon vorhanden -- nicht doppelt angelegt.")
continue
new_id = issuance.create_customer(
conn,
actor=actor,
name=name,
contact_email=str(row["contact_email"] or ""),
contact_phone=str(row["contact_phone"] or ""),
contact_person=str(row["contact_person"] or ""),
street=str(row["street"] or ""),
house_number=str(row["house_number"] or ""),
postal_code=str(row["postal_code"] or ""),
city=str(row["city"] or ""),
notes=str(row["notes"] or ""),
)
customer_map[int(row["id"])] = new_id
report.customers += 1
# -- Tickets ----------------------------------------------------------- #
for row in legacy.execute("SELECT * FROM license_tickets ORDER BY created_at"):
customer_id = customer_map.get(int(row["customer_id"] or 0))
if customer_id is None:
report.skipped.append(f"Ticket {str(row['id'])[:12]} -- Kunde nicht uebernommen")
continue
if Database.one(conn, "SELECT id FROM license_tickets WHERE id=?", (row["id"],)):
continue
conn.execute(
"INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, lifetime, "
"status, role, notes, created_by, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(
str(row["id"]),
customer_id,
str(row["type"] or "standard"),
json.dumps(_modules(row["modules"])),
int(row["valid_days"] or 365),
int(row["lifetime"] or 0),
str(row["status"] or "open"),
str(row["role"] or "customer"),
"Aus der Vorgaengerfassung uebernommen.",
str(row["created_by"] or actor),
str(row["created_at"] or _now()),
_now(),
),
)
report.tickets += 1
# -- Lizenzen ---------------------------------------------------------- #
for row in legacy.execute("SELECT * FROM licenses ORDER BY created_at"):
license_id = str(row["license_id"] or "")
customer_id = customer_map.get(int(row["customer_id"] or 0))
if not license_id or customer_id is None:
report.skipped.append(f"Lizenz {license_id[:12] or '?'} -- Kunde nicht uebernommen")
continue
if Database.one(conn, "SELECT id FROM licenses WHERE license_id=?", (license_id,)):
continue
customer = issuance.get_customer(conn, customer_id)
status = str(row["status"] or "issued")
modules = _modules(row["modules"])
if not reissue:
report.skipped.append(f"{customer['name']} / {license_id[:12]} -- nur gezaehlt")
report.licenses += 1
continue
# Neu ausstellen: gleiche Kennung, gleiche Eckdaten, neues Format und
# ein frisches Clientschluesselpaar. Das alte Paar gehoert zu einer
# Signatur, die es nicht mehr gibt.
lifetime = bool(row["lifetime"])
valid_days = _remaining_days(str(row["expires_at"] or ""))
if not lifetime and valid_days <= 0:
report.skipped.append(
f"{customer['name']} / {license_id[:12]} -- abgelaufen am "
f"{str(row['expires_at'])[:10]}, nicht uebernommen"
)
continue
bundle, license_public = licsrv.issue_license(
customer={
"name": str(customer["name"]),
"contact_email": str(customer["contact_email"] or ""),
"customer_ref": str(customer["id"]),
},
license_type=str(row["type"] or "standard"),
modules=modules,
valid_days=valid_days,
master_private_key_b64=master_private_key,
master_public_key_b64=master_public_key,
master_endpoint=master_endpoint,
vendor=vendor,
license_id=license_id,
role=str(row["role"] or "customer"),
lifetime=lifetime,
)
document = bundle["license"]
conn.execute(
"INSERT INTO licenses (license_id, ticket_id, customer_id, type, role, modules, "
"lifetime, seq, issued_at, expires_at, license_pubkey, license_document, "
"client_key_encrypted, status, created_by, created_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
license_id,
str(row["ticket_id"] or ""),
customer_id,
str(row["type"] or "standard"),
str(row["role"] or "customer"),
json.dumps(modules),
int(lifetime),
int(document["seq"]),
document["issued_at"],
document["expires_at"],
license_public,
json.dumps(document),
keystore.encrypt(
str(bundle["client_key"]), aad=issuance.client_key_aad(license_id)
),
# Kein Zustand, der eine Maschine nennt: die alte Bindung gilt
# fuer die alte Datei, nicht fuer diese.
"revoked" if status == "revoked" else "issued",
str(row["created_by"] or actor),
str(row["created_at"] or _now()),
),
)
report.licenses += 1
if status != "revoked":
report.reissued.append(
f"{customer['name']} -- {license_id} "
f"({'aktiv gewesen' if status == 'active' else status})"
)
if report.reissued:
report.warnings.append(
"Die neu signierten Lizenzen sind noch nicht aktiviert: die Bindung an eine "
"Maschine gilt nur fuer die alte Datei. Nach dem Einspielen aktiviert der "
"Kunde erneut."
)
return report
@@ -0,0 +1,213 @@
"""E-Mail-Versand ueber die Microsoft-Graph-API (App-only).
Kein SMTP und keine zusaetzliche Abhaengigkeit: der Client-Credentials-Flow ist
ein Token-Abruf und ein POST -- das geht mit ``urllib`` aus der
Standardbibliothek. Eine Bibliothek wie ``msal`` waere hier reiner Ballast.
Das Client-Secret liegt AES-GCM-verschluesselt in ``service_accounts``, an den
Zweck gebunden. Der Vorgaenger hielt es unverschluesselt in der
``settings``-Tabelle.
"""
from __future__ import annotations
import base64
import json
import logging
import sqlite3
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from tesm_core.db import Database
from tesm_core.extension import core
from tesm_core.settings_store import SettingSpec
logger = logging.getLogger(__name__)
SERVICE_PURPOSE = "graph_client_secret"
AAD_GRAPH_SECRET = "service_accounts.graph_client_secret"
TOKEN_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
SEND_URL = "https://graph.microsoft.com/v1.0/users/{sender}/sendMail"
SCOPE = "https://graph.microsoft.com/.default"
SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec("graph_enabled", False, "bool", "E-Mail-Versand aktiv", section="mail"),
SettingSpec("graph_tenant_id", "", "str", "Verzeichnis-ID (Tenant)", section="mail"),
SettingSpec("graph_client_id", "", "str", "Anwendungs-ID (Client)", section="mail"),
SettingSpec("graph_sender", "", "str", "Absender-Postfach", section="mail"),
SettingSpec(
"graph_reply_to", "", "str", "Antwortadresse (optional)", section="mail"
),
)
_token_cache: dict[str, tuple[str, float]] = {}
class MailError(Exception):
"""Versandfehler mit Klartextmeldung."""
@dataclass(frozen=True, slots=True)
class Attachment:
name: str
content: bytes
content_type: str = "application/json"
def store_secret(conn: sqlite3.Connection, secret: str | None) -> None:
existing = Database.one(
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,)
)
encrypted = str(existing.get("password_encrypted") or "") if existing else ""
if secret:
encrypted = core().keystore.encrypt(secret, aad=AAD_GRAPH_SECRET)
conn.execute(
"INSERT INTO service_accounts (purpose, username, password_encrypted, updated_at) "
"VALUES (?,'',?,?) ON CONFLICT(purpose) DO UPDATE SET "
"password_encrypted=excluded.password_encrypted, updated_at=excluded.updated_at",
(SERVICE_PURPOSE, encrypted, datetime.now(timezone.utc).isoformat(timespec="seconds")),
)
def has_secret(conn: sqlite3.Connection) -> bool:
value = Database.value(
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,)
)
return bool(value)
def _secret(conn: sqlite3.Connection) -> str:
value = Database.value(
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,)
)
if not value:
raise MailError("Es ist kein Client-Secret hinterlegt.")
try:
return core().keystore.decrypt(str(value), aad=AAD_GRAPH_SECRET)
except Exception as exc: # noqa: BLE001
raise MailError(
"Das Client-Secret konnte nicht entschluesselt werden -- bitte neu eintragen."
) from exc
def _config(conn: sqlite3.Connection) -> dict[str, Any]:
return core().settings.get_many(conn, [spec.key for spec in SETTINGS])
def is_enabled(conn: sqlite3.Connection) -> bool:
config = _config(conn)
return bool(
config.get("graph_enabled")
and config.get("graph_tenant_id")
and config.get("graph_client_id")
and config.get("graph_sender")
and has_secret(conn)
)
def _post(url: str, data: bytes, headers: dict[str, str], *, timeout: int = 20) -> bytes:
request = urllib.request.Request(url, data=data, headers=headers, method="POST") # noqa: S310
try:
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
return response.read()
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
detail = body
try:
parsed = json.loads(body)
detail = str(parsed.get("error", {}).get("message") or parsed.get("error_description") or body)
except ValueError:
pass
raise MailError(f"Graph antwortete mit HTTP {exc.code}: {detail[:400]}") from exc
except urllib.error.URLError as exc:
raise MailError(f"Graph ist nicht erreichbar: {exc.reason}") from exc
def fetch_token(conn: sqlite3.Connection) -> str:
config = _config(conn)
tenant = str(config["graph_tenant_id"])
client_id = str(config["graph_client_id"])
cache_key = f"{tenant}:{client_id}"
cached = _token_cache.get(cache_key)
if cached and cached[1] > time.time() + 60:
return cached[0]
payload = urllib.parse.urlencode(
{
"client_id": client_id,
"client_secret": _secret(conn),
"scope": SCOPE,
"grant_type": "client_credentials",
}
).encode("utf-8")
raw = _post(
TOKEN_URL.format(tenant=urllib.parse.quote(tenant)),
payload,
{"Content-Type": "application/x-www-form-urlencoded"},
)
data = json.loads(raw.decode("utf-8"))
token = str(data.get("access_token") or "")
if not token:
raise MailError("Graph hat kein Zugriffstoken geliefert.")
_token_cache[cache_key] = (token, time.time() + int(data.get("expires_in") or 3000))
return token
def test_connection(conn: sqlite3.Connection) -> dict[str, Any]:
"""Holt nur ein Token -- verschickt bewusst keine echte Nachricht."""
if not is_enabled(conn):
return {"ok": False, "message": "Der E-Mail-Versand ist nicht vollstaendig konfiguriert."}
try:
fetch_token(conn)
except MailError as exc:
return {"ok": False, "message": str(exc)}
return {"ok": True, "message": "Anmeldung an Microsoft Graph erfolgreich."}
def send(
conn: sqlite3.Connection,
*,
to: str,
subject: str,
body: str,
attachment: Attachment | None = None,
) -> None:
if not is_enabled(conn):
raise MailError("Der E-Mail-Versand ist nicht konfiguriert.")
config = _config(conn)
message: dict[str, Any] = {
"message": {
"subject": subject,
# Reiner Text: kein HTML, damit nichts aus Kundendaten interpretiert wird.
"body": {"contentType": "Text", "content": body},
"toRecipients": [{"emailAddress": {"address": to}}],
},
"saveToSentItems": True,
}
reply_to = str(config.get("graph_reply_to") or "")
if reply_to:
message["message"]["replyTo"] = [{"emailAddress": {"address": reply_to}}]
if attachment is not None:
message["message"]["attachments"] = [
{
"@odata.type": "#microsoft.graph.fileAttachment",
"name": attachment.name,
"contentType": attachment.content_type,
"contentBytes": base64.b64encode(attachment.content).decode("ascii"),
}
]
token = fetch_token(conn)
_post(
SEND_URL.format(sender=urllib.parse.quote(str(config["graph_sender"]))),
json.dumps(message).encode("utf-8"),
{"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
timeout=30,
)
logger.info("E-Mail an %s versendet: %s", to, subject)
@@ -0,0 +1,113 @@
"""Signaturschluessel des Lizenzservers.
Ein Ed25519-Schluesselpaar pro Installation. Der private Teil verlaesst diese
Maschine nie und wird fuer genau zwei Dinge benutzt:
1. Lizenzdateien signieren,
2. Antworten auf Aktivierungs-/Deaktivierungs-/Heartbeat-Anfragen signieren.
Der oeffentliche Teil reist in jeder ausgestellten Lizenz mit -- damit kann der
Client Antworten vollstaendig offline pruefen, ohne je einen Schluessel abholen
zu muessen.
Die Datei liegt mit Modus 0600 und ist aus dem Deployment-Abgleich
ausgeschlossen: ein Update darf sie nie ueberschreiben. Ginge sie verloren,
waere **jede** ausgestellte Lizenz nicht mehr verifizierbar.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
import tesm_licensing as lic
import tesm_licensing_server as licsrv
from tesm_core.keystore import check_private_file, write_private_file
logger = logging.getLogger(__name__)
class MasterKeyError(Exception):
"""Schluessel fehlt oder ist unlesbar."""
@dataclass(frozen=True, slots=True)
class MasterKey:
private_key: str
public_key: str
created_at: str
@property
def key_id(self) -> str:
return lic.key_id(self.public_key)
def load(path: Path) -> MasterKey:
if not path.is_file():
raise MasterKeyError(
f"Der Signaturschluessel {path} fehlt. Ohne ihn kann keine Lizenz ausgestellt "
"werden. Erzeugen Sie ihn mit 'tesm-license-admin init-key'."
)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise MasterKeyError(f"Der Signaturschluessel ist unlesbar: {exc}") from exc
private = str(data.get("private_key") or "")
public = str(data.get("public_key") or "")
if not private or not public:
raise MasterKeyError("Der Signaturschluessel ist unvollstaendig.")
if licsrv.public_from_private(private) != public:
raise MasterKeyError(
"Der oeffentliche Teil des Signaturschluessels passt nicht zum privaten. "
"Die Datei ist beschaedigt oder wurde manipuliert."
)
return MasterKey(
private_key=private, public_key=public, created_at=str(data.get("created_at") or "")
)
def ensure(path: Path) -> MasterKey:
"""Erzeugt den Schluessel beim ersten Aufruf, laedt ihn danach nur noch.
**Niemals** neu erzeugen, wenn die Datei existiert -- das wuerde alle
bestehenden Lizenzen unbrauchbar machen.
"""
if path.is_file():
return load(path)
private, public = licsrv.generate_keypair()
payload = {
"private_key": private,
"public_key": public,
"key_id": lic.key_id(public),
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
write_private_file(path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
logger.warning(
"Neuer Signaturschluessel erzeugt (%s). Diese Datei ist unwiederbringlich -- "
"bitte sofort gesichert ablegen: %s",
lic.key_id(public),
path,
)
return load(path)
def health(path: Path) -> dict[str, object]:
"""Zustand fuer die Diagnoseseite."""
if not path.is_file():
return {"present": False, "problems": ["Signaturschluessel fehlt"]}
problems = check_private_file(path)
try:
key = load(path)
except MasterKeyError as exc:
return {"present": True, "problems": [*problems, str(exc)]}
return {
"present": True,
"key_id": key.key_id,
"public_key": key.public_key,
"created_at": key.created_at,
"problems": problems,
}
@@ -0,0 +1,64 @@
"""Laufendes Protokoll des Lizenzservers.
Anders als TESM hat der Lizenzserver keinen Hintergrundprozess, der Telemetrie
erzeugt -- es gibt keine Geraete zu pingen. Damit die Ansicht "Laufend"
trotzdem Inhalt hat, spiegelt sie hierher denselben Vorgangsstrom, der auch im
Aenderungsprotokoll landet: Ausstellung, Aktivierung, Widerruf, Heartbeat,
Mailversand.
Das ist ausdruecklich kein zweites Protokollkonzept, sondern eine
menschenlesbare Fortschreibung -- die belastbare Quelle bleibt ``audit_log``
mit seiner Hash-Kette.
"""
from __future__ import annotations
import logging
import threading
from datetime import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
class LiveLog:
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._lock = threading.Lock()
def write(self, level: str, scope: str, message: str) -> None:
line = (
f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {level:<7} {scope:<12} {message}\n"
)
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
with self._lock, self.path.open("a", encoding="utf-8", newline="\n") as stream:
stream.write(line)
except OSError as exc: # pragma: no cover
logger.warning("Konnte nicht ins laufende Protokoll schreiben: %s", exc)
def info(self, scope: str, message: str) -> None:
self.write("INFO", scope, message)
def warning(self, scope: str, message: str) -> None:
self.write("WARNUNG", scope, message)
def error(self, scope: str, message: str) -> None:
self.write("FEHLER", scope, message)
def tail(self, lines: int = 400, *, needle: str = "") -> list[str]:
if not self.path.is_file():
return []
try:
size = self.path.stat().st_size
block = min(size, max(8192, lines * 200))
with self.path.open("rb") as stream:
stream.seek(max(0, size - block))
content = stream.read().decode("utf-8", errors="replace")
except OSError:
return []
result = content.splitlines()
if needle:
lowered = needle.lower()
result = [line for line in result if lowered in line.lower()]
return result[-lines:]
@@ -0,0 +1,274 @@
"""Sub-Lizenzserver-Berichte: eigenen Bestand melden, fremden aufnehmen.
Ein Lizenzserver, der selbst mit einer ``role="license_server"``-Lizenz laeuft,
meldet seinen kompletten Kunden-/Ticket-/Lizenzbestand an seinen Master --
huckepack auf dem bestehenden Heartbeat, zusaetzlich sofort nach jeder
Aenderung.
**Vollstaendiger Schnappschuss, kein Delta.** Jeder Bericht ersetzt alle Zeilen
seiner Quelle. Das ist bei diesen Datenmengen deutlich einfacher korrekt zu
halten: ein beim Sub-Lizenzserver geloeschtes Ticket verschwindet automatisch
auch beim Master, ohne dass je ein "geloescht"-Ereignis uebertragen werden muss.
"""
from __future__ import annotations
import json
import logging
import sqlite3
import threading
from datetime import datetime, timezone
from typing import Any
import tesm_licensing as lic
from tesm_core.db import Database
logger = logging.getLogger(__name__)
MAX_ROWS = lic.MAX_REPORT_ROWS
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
# --------------------------------------------------------------------------- #
# Eigenen Bestand melden (nur als Sub-Lizenzserver relevant)
# --------------------------------------------------------------------------- #
def build_own_report(conn: sqlite3.Connection) -> dict[str, Any]:
"""Baut den Bericht ueber den eigenen Bestand.
Die eigene Bootstrap-Lizenz ist nicht Teil davon -- sie steht in
``license.json``, nicht in diesen Tabellen.
"""
customers = Database.all(
conn,
"SELECT id, name, contact_email, contact_person, city FROM license_customers "
"ORDER BY id LIMIT ?",
(MAX_ROWS,),
)
tickets = Database.all(
conn,
"SELECT id, customer_id, type, modules, valid_days, lifetime, status, role, created_at "
"FROM license_tickets ORDER BY created_at LIMIT ?",
(MAX_ROWS,),
)
licenses = Database.all(
conn,
"SELECT license_id, ticket_id, type, modules, lifetime, issued_at, expires_at, status, "
"fingerprint, hostname, activated_at, last_heartbeat_at FROM licenses "
"ORDER BY created_at LIMIT ?",
(MAX_ROWS,),
)
return {
"customers": [
{
"ref": str(row["id"]),
"name": str(row["name"]),
"contact_email": str(row["contact_email"] or ""),
"contact_person": str(row["contact_person"] or ""),
"city": str(row["city"] or ""),
}
for row in customers
],
"tickets": [
{
"ref": str(row["id"]),
"customer_ref": str(row["customer_id"]),
"type": str(row["type"]),
"modules": json.loads(str(row["modules"] or "[]")),
"valid_days": int(row["valid_days"] or 0),
"lifetime": bool(row["lifetime"]),
"status": str(row["status"]),
"role": str(row["role"]),
"created_at": str(row["created_at"] or ""),
}
for row in tickets
],
"licenses": [
{
"ref": str(row["license_id"]),
"ticket_ref": str(row["ticket_id"] or ""),
"type": str(row["type"]),
"modules": json.loads(str(row["modules"] or "[]")),
"lifetime": bool(row["lifetime"]),
"issued_at": str(row["issued_at"] or ""),
"expires_at": str(row["expires_at"] or ""),
"status": str(row["status"]),
"fingerprint": str(row["fingerprint"] or ""),
"hostname": str(row["hostname"] or ""),
"activated_at": str(row["activated_at"] or ""),
"last_heartbeat_at": str(row["last_heartbeat_at"] or ""),
}
for row in licenses
],
}
def report_soon(extension: Any) -> None:
"""Meldet den eigenen Bestand im Hintergrund an den Master.
Best effort: schlaegt der Versand fehl, bleibt es beim regulaeren Heartbeat.
Der Master ist damit hoechstens sechs Stunden hinterher, nie inkonsistent.
"""
manager = extension.license
if manager is None or manager.state.role != "license_server":
return
def worker() -> None:
try:
manager.run_heartbeat()
except Exception as exc: # noqa: BLE001
logger.info("Sofortmeldung an den Master fehlgeschlagen: %s", exc)
threading.Thread(target=worker, name="report-to-master", daemon=True).start()
# --------------------------------------------------------------------------- #
# Fremden Bestand aufnehmen (Master-Seite)
# --------------------------------------------------------------------------- #
def ingest_report(
conn: sqlite3.Connection, source_license_id: str, report: dict[str, Any]
) -> dict[str, int]:
"""Uebernimmt einen Bericht. Ersetzt alle bisherigen Zeilen dieser Quelle."""
counts = {"customers": 0, "tickets": 0, "licenses": 0}
stamp = _now()
for table in ("reported_customers", "reported_tickets", "reported_licenses"):
conn.execute(f"DELETE FROM {table} WHERE source_license_id=?", (source_license_id,))
for entry in (report.get("customers") or [])[:MAX_ROWS]:
conn.execute(
"INSERT INTO reported_customers (source_license_id, remote_ref, name, contact_email, "
"contact_person, city, last_reported_at) VALUES (?,?,?,?,?,?,?)",
(
source_license_id,
str(entry.get("ref") or ""),
str(entry.get("name") or ""),
str(entry.get("contact_email") or ""),
str(entry.get("contact_person") or ""),
str(entry.get("city") or ""),
stamp,
),
)
counts["customers"] += 1
for entry in (report.get("tickets") or [])[:MAX_ROWS]:
conn.execute(
"INSERT INTO reported_tickets (source_license_id, remote_ref, customer_ref, type, "
"modules, valid_days, lifetime, status, role, created_at, last_reported_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(
source_license_id,
str(entry.get("ref") or ""),
str(entry.get("customer_ref") or ""),
str(entry.get("type") or ""),
json.dumps(entry.get("modules") or []),
int(entry.get("valid_days") or 0),
int(bool(entry.get("lifetime"))),
str(entry.get("status") or "open"),
str(entry.get("role") or "customer"),
str(entry.get("created_at") or ""),
stamp,
),
)
counts["tickets"] += 1
for entry in (report.get("licenses") or [])[:MAX_ROWS]:
conn.execute(
"INSERT INTO reported_licenses (source_license_id, remote_ref, ticket_ref, type, "
"modules, lifetime, issued_at, expires_at, status, fingerprint, hostname, "
"activated_at, last_heartbeat_at, last_reported_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
source_license_id,
str(entry.get("ref") or ""),
str(entry.get("ticket_ref") or ""),
str(entry.get("type") or ""),
json.dumps(entry.get("modules") or []),
int(bool(entry.get("lifetime"))),
str(entry.get("issued_at") or ""),
str(entry.get("expires_at") or ""),
str(entry.get("status") or ""),
str(entry.get("fingerprint") or ""),
str(entry.get("hostname") or ""),
str(entry.get("activated_at") or ""),
str(entry.get("last_heartbeat_at") or ""),
stamp,
),
)
counts["licenses"] += 1
return counts
def reported_for_customer(conn: sqlite3.Connection, customer_id: int) -> dict[str, Any]:
"""Gemeldeter Bestand eines Kunden, der selbst Lizenzserver ist."""
source = Database.value(
conn,
"SELECT license_id FROM licenses WHERE customer_id=? AND role='license_server' "
"AND status='active' LIMIT 1",
(customer_id,),
)
if source is None:
return {"source_license_id": "", "customers": [], "last_reported_at": ""}
customers = Database.all(
conn,
"SELECT * FROM reported_customers WHERE source_license_id=? ORDER BY name COLLATE NOCASE",
(str(source),),
)
tickets = Database.all(
conn, "SELECT * FROM reported_tickets WHERE source_license_id=?", (str(source),)
)
licenses = Database.all(
conn, "SELECT * FROM reported_licenses WHERE source_license_id=?", (str(source),)
)
from .issuance import display_state
tickets_by_customer: dict[str, list[dict[str, Any]]] = {}
for ticket in tickets:
tickets_by_customer.setdefault(str(ticket["customer_ref"]), []).append(dict(ticket))
licenses_by_ticket: dict[str, list[dict[str, Any]]] = {}
for entry in licenses:
licenses_by_ticket.setdefault(str(entry["ticket_ref"]), []).append(
{**dict(entry), **display_state(dict(entry))}
)
result = []
for customer in customers:
own_tickets = tickets_by_customer.get(str(customer["remote_ref"]), [])
for ticket in own_tickets:
ticket["licenses"] = licenses_by_ticket.get(str(ticket["remote_ref"]), [])
result.append({**dict(customer), "tickets": own_tickets})
return {
"source_license_id": str(source),
"customers": result,
"last_reported_at": max(
[str(row["last_reported_at"]) for row in customers] or [""],
),
}
def reporting_sources(conn: sqlite3.Connection) -> list[dict[str, Any]]:
"""Alle Sub-Lizenzserver, die schon einmal berichtet haben."""
return Database.all(
conn,
"""
SELECT l.license_id, l.hostname, l.last_heartbeat_at, c.name AS customer_name, c.id AS customer_id,
(SELECT COUNT(*) FROM reported_customers r WHERE r.source_license_id = l.license_id) AS customers,
(SELECT COUNT(*) FROM reported_tickets r WHERE r.source_license_id = l.license_id) AS tickets,
(SELECT COUNT(*) FROM reported_licenses r WHERE r.source_license_id = l.license_id) AS licenses,
(SELECT MAX(r.last_reported_at) FROM reported_customers r
WHERE r.source_license_id = l.license_id) AS last_reported_at
FROM licenses l JOIN license_customers c ON c.id = l.customer_id
WHERE l.role='license_server'
ORDER BY c.name COLLATE NOCASE
""",
)
@@ -0,0 +1,420 @@
"""Sicherung des Lizenzservers: Kunden, Tickets, Lizenzen, Konten, Einstellungen.
Der verschluesselte Umschlag liegt im Kern (``tesm_core.transfer``). Hier steht,
was gesichert wird -- und was bewusst nicht.
Bewusst **nicht** in der Sicherung:
* Der Signaturschluessel (``master_signing_key.json``). Er ist der Kern des
Vertrauens und gehoert in eine getrennte, bewusst verwahrte Sicherung -- nicht
in dieselbe Datei wie die Kundendaten.
* Verbrauchte Protokoll-Nonces und Portal-Token: reiner Laufzeitzustand.
* Der eigene Lizenz- und Aktivierungszustand: er gehoert zur Maschine.
Die **verschluesselten Client-Schluessel** der ausgestellten Lizenzen sind
enthalten -- allerdings weiterhin verschluesselt mit dem Datenschluessel dieser
Installation. Eine Sicherung ohne ``data.keys`` gibt sie also nicht her; das ist
gewollt und in der Oberflaeche vermerkt.
"""
from __future__ import annotations
import json
import sqlite3
from typing import Any, Callable, Iterable
from tesm_core.db import Database
from tesm_core.transfer import (
MIN_PASSPHRASE_LENGTH,
PREVIEW_TTL_MINUTES,
Category,
TransferError,
drop_preview,
load_preview,
seal as _seal,
store_preview,
summarize as _summarize,
unseal as _unseal,
)
APP_TAG = "tesm-license"
CATEGORIES: tuple[Category, ...] = (
Category("customers", "Kunden", "Stammdaten der Kundeninstanzen"),
Category("tickets", "Tickets", "Anspruechte inklusive Self-Service-Schluessel", sensitive=True),
Category(
"licenses",
"Lizenzen",
"Ausgestellte Lizenzen mit signiertem Dokument und verschluesseltem Client-Schluessel",
sensitive=True,
),
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
Category("settings", "Einstellungen", "Anbieterdaten, Portal, Mailversand", admin_only=True),
)
CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES}
def seal(payload: dict[str, Any], passphrase: str) -> str:
return _seal(payload, passphrase, app_tag=APP_TAG)
def unseal(raw: bytes | str, passphrase: str) -> tuple[dict[str, Any], dict[str, Any]]:
return _unseal(raw, passphrase, app_tag=APP_TAG)
def summarize(payload: dict[str, Any]) -> list[dict[str, Any]]:
return _summarize(payload, CATEGORIES)
# --------------------------------------------------------------------------- #
# Export
# --------------------------------------------------------------------------- #
def build_export(conn: sqlite3.Connection, *, categories: Iterable[str]) -> dict[str, Any]:
wanted = {key for key in categories if key in CATEGORY_BY_KEY}
payload: dict[str, Any] = {}
if "customers" in wanted:
payload["customers"] = Database.all(
conn,
"SELECT id, name, contact_email, contact_phone, contact_person, street, "
"house_number, postal_code, city, notes, created_at FROM license_customers",
)
if "tickets" in wanted:
payload["tickets"] = Database.all(
conn,
"SELECT id, customer_id, type, modules, valid_days, lifetime, status, role, notes, "
"created_by, created_at FROM license_tickets",
)
if "licenses" in wanted:
payload["licenses"] = Database.all(
conn,
"SELECT license_id, ticket_id, customer_id, type, role, modules, lifetime, seq, "
"issued_at, expires_at, license_pubkey, license_document, client_key_encrypted, "
"status, fingerprint, hostname, activated_at, deactivated_at, last_heartbeat_at, "
"revoked_at, revoke_reason, created_by, created_at FROM licenses",
)
if "users" in wanted:
payload["users"] = Database.all(
conn,
"SELECT u.username, u.password_hash, u.is_admin, u.is_locked, u.first_name, "
"u.last_name, u.email, u.must_change_password, "
"(SELECT group_concat(g.name, char(10)) FROM user_groups ug "
" JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = u.id) AS group_names "
"FROM users u WHERE u.deleted_at IS NULL AND u.auth_source='local'",
)
if "groups" in wanted:
payload["groups"] = [
{
"name": row["name"],
"description": row["description"],
"is_default": row["is_default"],
"is_system": row["is_system"],
"preset_key": row["preset_key"],
"permissions": [
str(entry["permission"])
for entry in Database.all(
conn,
"SELECT permission FROM group_permissions WHERE group_id=? "
"ORDER BY permission",
(row["id"],),
)
],
}
for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL")
]
if "settings" in wanted:
payload["settings"] = {
str(row["key"]): str(row["value"])
for row in Database.all(conn, "SELECT key, value FROM settings")
if not str(row["key"]).startswith("license_")
}
return payload
# --------------------------------------------------------------------------- #
# Uebernahme
# --------------------------------------------------------------------------- #
def apply_import(
conn: sqlite3.Connection,
payload: dict[str, Any],
*,
categories: Iterable[str],
set_setting: Callable[[str, Any], None],
) -> dict[str, dict[str, int]]:
"""Uebernimmt ausgewaehlte Kategorien.
Abgleich nach natuerlichem Schluessel. Kunden werden ueber ihren Namen
zusammengefuehrt; Tickets und Lizenzen ueber ihre ID, weil die zugleich
kryptographisch relevant ist (Self-Service-Link bzw. signierte Lizenz-ID).
"""
wanted = {key for key in categories if key in CATEGORY_BY_KEY}
report: dict[str, dict[str, int]] = {}
stamp = _stamp()
def bump(category: str, field: str) -> None:
report.setdefault(category, {"created": 0, "updated": 0, "skipped": 0})[field] += 1
customer_map: dict[str, int] = {}
if "customers" in wanted:
for entry in payload.get("customers", []):
name = str(entry.get("name") or "").strip()
if not name:
continue
existing = Database.one(
conn,
"SELECT id FROM license_customers WHERE name=? COLLATE NOCASE",
(name,),
)
columns = (
str(entry.get("contact_email") or ""),
str(entry.get("contact_phone") or ""),
str(entry.get("contact_person") or ""),
str(entry.get("street") or ""),
str(entry.get("house_number") or ""),
str(entry.get("postal_code") or ""),
str(entry.get("city") or ""),
str(entry.get("notes") or ""),
)
if existing is None:
cursor = conn.execute(
"INSERT INTO license_customers (name, contact_email, contact_phone, "
"contact_person, street, house_number, postal_code, city, notes, "
"created_at, updated_at, created_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,'import')",
(name, *columns, stamp, stamp),
)
customer_id = int(cursor.lastrowid or 0)
bump("customers", "created")
else:
customer_id = int(existing["id"])
conn.execute(
"UPDATE license_customers SET contact_email=?, contact_phone=?, "
"contact_person=?, street=?, house_number=?, postal_code=?, city=?, notes=?, "
"updated_at=? WHERE id=?",
(*columns, stamp, customer_id),
)
bump("customers", "updated")
customer_map[str(entry.get("id") or "")] = customer_id
def resolve_customer(reference: Any) -> int | None:
mapped = customer_map.get(str(reference or ""))
if mapped:
return mapped
# Ohne mitgesicherte Kundenliste kann ein Ticket nicht zugeordnet werden.
value = Database.value(
conn, "SELECT id FROM license_customers WHERE id=?", (reference,)
)
return int(value) if value is not None else None
if "tickets" in wanted:
for entry in payload.get("tickets", []):
ticket_id = str(entry.get("id") or "")
customer_id = resolve_customer(entry.get("customer_id"))
if not ticket_id or customer_id is None:
bump("tickets", "skipped")
continue
existing = Database.one(
conn, "SELECT id FROM license_tickets WHERE id=?", (ticket_id,)
)
values = (
customer_id,
str(entry.get("type") or "standard"),
json.dumps(entry.get("modules") if isinstance(entry.get("modules"), list)
else json.loads(str(entry.get("modules") or "[]"))),
int(entry.get("valid_days") or 365),
int(bool(entry.get("lifetime"))),
str(entry.get("status") or "open"),
str(entry.get("role") or "customer"),
str(entry.get("notes") or ""),
)
if existing is None:
conn.execute(
"INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, "
"lifetime, status, role, notes, created_by, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?,?,'import',?,?)",
(ticket_id, *values, stamp, stamp),
)
bump("tickets", "created")
else:
conn.execute(
"UPDATE license_tickets SET customer_id=?, type=?, modules=?, valid_days=?, "
"lifetime=?, status=?, role=?, notes=?, updated_at=? WHERE id=?",
(*values, stamp, ticket_id),
)
bump("tickets", "updated")
if "licenses" in wanted:
for entry in payload.get("licenses", []):
license_id = str(entry.get("license_id") or "")
customer_id = resolve_customer(entry.get("customer_id"))
if not license_id or customer_id is None:
bump("licenses", "skipped")
continue
if Database.one(
conn, "SELECT 1 FROM licenses WHERE license_id=?", (license_id,)
):
# Eine signierte Lizenz wird nie ueberschrieben: ihr Inhalt ist
# kryptographisch festgelegt.
bump("licenses", "skipped")
continue
ticket_id = str(entry.get("ticket_id") or "") or None
if ticket_id and not Database.one(
conn, "SELECT 1 FROM license_tickets WHERE id=?", (ticket_id,)
):
ticket_id = None
conn.execute(
"INSERT INTO licenses (license_id, ticket_id, customer_id, type, role, modules, "
"lifetime, seq, issued_at, expires_at, license_pubkey, license_document, "
"client_key_encrypted, status, fingerprint, hostname, activated_at, "
"deactivated_at, last_heartbeat_at, revoked_at, revoke_reason, created_by, "
"created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'import',?)",
(
license_id,
ticket_id,
customer_id,
str(entry.get("type") or "standard"),
str(entry.get("role") or "customer"),
json.dumps(entry.get("modules") if isinstance(entry.get("modules"), list)
else json.loads(str(entry.get("modules") or "[]"))),
int(bool(entry.get("lifetime"))),
int(entry.get("seq") or 1),
str(entry.get("issued_at") or stamp),
str(entry.get("expires_at") or stamp),
str(entry.get("license_pubkey") or ""),
str(entry.get("license_document") or "{}"),
str(entry.get("client_key_encrypted") or ""),
str(entry.get("status") or "issued"),
entry.get("fingerprint"),
entry.get("hostname"),
entry.get("activated_at"),
entry.get("deactivated_at"),
entry.get("last_heartbeat_at"),
entry.get("revoked_at"),
str(entry.get("revoke_reason") or ""),
stamp,
),
)
bump("licenses", "created")
if "groups" in wanted:
for entry in payload.get("groups", []):
name = str(entry.get("name") or "").strip()
if not name:
continue
existing = Database.one(
conn,
"SELECT id FROM groups WHERE name=? COLLATE NOCASE AND deleted_at IS NULL",
(name,),
)
if existing is None:
cursor = conn.execute(
"INSERT INTO groups (name, description, is_default, is_system, preset_key, "
"created_at, updated_at) VALUES (?,?,?,?,?,?,?)",
(
name,
str(entry.get("description") or ""),
int(bool(entry.get("is_default"))),
int(bool(entry.get("is_system"))),
str(entry.get("preset_key") or ""),
stamp,
stamp,
),
)
group_id = int(cursor.lastrowid or 0)
bump("groups", "created")
else:
group_id = int(existing["id"])
bump("groups", "updated")
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(group_id, str(permission)) for permission in entry.get("permissions", [])],
)
if "users" in wanted:
for entry in payload.get("users", []):
username = str(entry.get("username") or "").strip()
if not username:
continue
existing = Database.one(
conn, "SELECT id, deleted_at FROM users WHERE username=? COLLATE NOCASE", (username,)
)
if existing is not None and existing["deleted_at"]:
bump("users", "skipped")
continue
if existing is None:
cursor = conn.execute(
"INSERT INTO users (username, password_hash, is_admin, is_locked, "
"auth_source, first_name, last_name, email, must_change_password, "
"created_at, updated_at) VALUES (?,?,?,?,'local',?,?,?,?,?,?)",
(
username,
str(entry.get("password_hash") or ""),
int(bool(entry.get("is_admin"))),
int(bool(entry.get("is_locked"))),
str(entry.get("first_name") or ""),
str(entry.get("last_name") or ""),
str(entry.get("email") or ""),
int(bool(entry.get("must_change_password"))),
stamp,
stamp,
),
)
user_id = int(cursor.lastrowid or 0)
bump("users", "created")
else:
user_id = int(existing["id"])
bump("users", "updated")
names = [n for n in str(entry.get("group_names") or "").split("\n") if n.strip()]
if names:
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
for name in names:
group_id = Database.value(
conn,
"SELECT id FROM groups WHERE name=? COLLATE NOCASE AND deleted_at IS NULL",
(name.strip(),),
)
if group_id is not None:
conn.execute(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
(user_id, int(group_id)),
)
if "settings" in wanted:
for key, value in (payload.get("settings") or {}).items():
if str(key).startswith("license_"):
continue
set_setting(str(key), value)
bump("settings", "updated")
return report
def _stamp() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat(timespec="seconds")
__all__ = [
"APP_TAG",
"CATEGORIES",
"CATEGORY_BY_KEY",
"MIN_PASSPHRASE_LENGTH",
"PREVIEW_TTL_MINUTES",
"TransferError",
"apply_import",
"build_export",
"drop_preview",
"load_preview",
"seal",
"store_preview",
"summarize",
"unseal",
]
@@ -0,0 +1,97 @@
"""Laufzeiteinstellungen des Lizenzservers."""
from __future__ import annotations
from tesm_core.settings_store import SettingSpec
from .services.mail import SETTINGS as MAIL_SETTINGS
VENDOR_SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec("vendor_name", "", "str", "Anbietername", section="vendor"),
SettingSpec("vendor_email", "", "str", "Kontakt-E-Mail", section="vendor"),
SettingSpec("vendor_phone", "", "str", "Telefon", section="vendor"),
SettingSpec("vendor_address", "", "str", "Adresse", section="vendor"),
SettingSpec(
"vendor_logo_base64",
"",
"str",
"Logo (Data-URI)",
hint="Reist in jeder ausgestellten Lizenz mit und wird beim Kunden angezeigt. "
"Maximal 200 KB.",
section="vendor",
export=False,
),
SettingSpec(
"master_endpoint",
"",
"str",
"Oeffentlicher Endpunkt dieses Lizenzservers",
hint="Basis-URL, unter der Kundeninstanzen diesen Server erreichen -- z. B. "
"https://lizenz.firma.de. Wird in jede Lizenz eingebettet und fuer alle "
"kundengerichteten Links verwendet.",
section="vendor",
),
)
PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec(
"portal_link_minutes",
30,
"int",
"Gueltigkeit des Zugangslinks (Minuten)",
hint="Der Link ist zusaetzlich nur einmal verwendbar.",
section="portal",
minimum=5,
maximum=1440,
),
SettingSpec(
"portal_session_minutes",
60,
"int",
"Gueltigkeit der Portalsitzung (Minuten)",
hint="Zweites, unabhaengiges Zeitfenster: eines fuer das Anklicken des Links, "
"eines fuer die daraus entstehende Sitzung.",
section="portal",
minimum=5,
maximum=1440,
),
SettingSpec(
"portal_enabled", True, "bool", "Kundenportal aktiv", section="portal"
),
SettingSpec(
"self_service_enabled",
True,
"bool",
"Self-Service-Link je Ticket aktiv",
hint="Erlaubt dem Kunden, seine Lizenz selbst herunterzuladen, zu widerrufen und "
"neu auszustellen -- ohne Anmeldung, allein ueber den unratbaren Ticket-Link.",
section="portal",
),
)
DEFAULT_TICKET_SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec(
"default_valid_days",
365,
"int",
"Vorgeschlagene Laufzeit neuer Tickets (Tage)",
section="licensing",
minimum=1,
maximum=36500,
),
SettingSpec(
"allow_customer_revoke",
True,
"bool",
"Kunden duerfen ihre Lizenz selbst widerrufen",
hint="Notwendig fuer einen Systemwechsel ohne Support-Anruf.",
section="licensing",
),
)
LICENSE_SERVER_SETTINGS: tuple[SettingSpec, ...] = (
*VENDOR_SETTINGS,
*PORTAL_SETTINGS,
*DEFAULT_TICKET_SETTINGS,
*MAIL_SETTINGS,
)
@@ -0,0 +1,132 @@
{% 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 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',
'Passphrase') }}
{{ alert('Der Signaturschluessel (' ~ signing_key_path ~ ') ist bewusst
nicht Teil dieser Sicherung. Er ist der Kern des Vertrauens und gehoert 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') }}
<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>
</div>
<form method="post" action="{{ url_for('backup.export') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
<fieldset class="fieldset">
<legend>Kategorien</legend>
<div class="stack stack--tight">
{% for category in all_categories %}
{% set allowed = category in categories %}
<label class="check {{ 'check--disabled' if not allowed }}">
<input type="checkbox" name="categories" value="{{ category.key }}"
{% if allowed %}checked{% endif %} {% if not allowed %}disabled{% endif %}>
<span>
{{ category.label }}
{% if category.sensitive %}{{ pill('sensibel', 'danger') }}{% endif %}
{% if category.admin_only %}{{ pill('nur Admin', 'accent') }}{% endif %}
<div class="fs-xs faint">{{ category.hint }}</div>
{% if not allowed %}
<div class="fs-xs danger-text">Dafuer fehlt Ihnen das Recht.</div>
{% endif %}
</span>
</label>
{% endfor %}
</div>
</fieldset>
<div class="form-grid">
{{ field('passphrase', 'Passphrase', type='password', required=True,
autocomplete='new-password',
hint_text='Mindestens ' ~ min_passphrase ~ ' Zeichen.') }}
{{ field('passphrase_repeat', 'Passphrase wiederholen', type='password', required=True,
autocomplete='new-password') }}
</div>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Sicherung herunterladen</button>
</div>
</form>
</section>
<div class="stack">
{% if preview %}
<section class="card card--accent">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Geoeffnete Sicherung</div>
<div class="card__sub">Gueltig fuer {{ preview_ttl }} Minuten</div>
</div>
</div>
<form method="post" action="{{ url_for('backup.import_apply') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th></th><th>Kategorie</th><th class="numeric">Eintraege</th></tr></thead>
<tbody>
{% for entry in preview.summary %}
<tr>
<td><label class="check">
<input type="checkbox" name="categories" value="{{ entry.key }}" checked>
<span class="sr-only">{{ entry.label }}</span></label></td>
<td>{{ entry.label }}</td>
<td class="numeric">{{ entry.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{{ alert('Bereits vorhandene Lizenzen werden nie ueberschrieben -- ihr Inhalt ist
kryptographisch festgelegt. Kunden werden ueber ihren Namen zusammengefuehrt.',
'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>
</div>
</form>
</section>
{% else %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Sicherung einspielen</div>
<div class="card__sub">Erst oeffnen und pruefen, dann uebernehmen</div>
</div>
</div>
<form method="post" enctype="multipart/form-data" data-no-guard
action="{{ url_for('backup.import_preview') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
<input class="input" type="file" name="backup_file" accept=".json" required>
{{ field('passphrase', 'Passphrase der Datei', type='password', required=True,
autocomplete='off') }}
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit" data-busy-label="Oeffne ...">
Datei oeffnen
</button>
</div>
</form>
</section>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,219 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, textarea_field, pill, empty_state, alert %}
{% block page_title %}{{ customer.name }}{% endblock %}
{% block page_sub %}
{{ tickets | length }} Ticket(s) &middot; {{ licenses | length }} Lizenz(en)
{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('customers.overview') }}">Kunden</a></span><span>{{ customer.name }}</span>
</nav>
{% endblock %}
{% block topbar_extra %}
{% if current_user.has('tickets.create') %}
<a class="btn btn--primary btn--sm" href="{{ url_for('tickets.new', kunde=customer.id) }}">
{{ icon('plus', size=15) }} Ticket anlegen
</a>
{% endif %}
{% endblock %}
{% block content %}
<div class="grid grid--split">
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('ticket', size=17) }} Tickets</div>
<div class="card__sub">Verwaltung auf der eigenen Ticket-Seite</div>
</div>
</div>
{% if tickets %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Ticket</th><th>Typ</th><th>Rolle</th><th>Laufzeit</th><th>Zustand</th></tr></thead>
<tbody>
{% for ticket in tickets %}
<tr>
<td class="mono fs-sm">
<a href="{{ url_for('tickets.detail', ticket_id=ticket.id) }}">
{{ ticket.id[:12] }}&hellip;</a>
</td>
<td class="fs-sm">{{ ticket.type }}</td>
<td class="fs-sm">
{% if ticket.role != 'customer' %}{{ pill(ticket.role, 'accent') }}
{% else %}Kunde{% endif %}
</td>
<td class="fs-sm">
{% if ticket.lifetime %}unbefristet{% else %}{{ ticket.valid_days }} Tage{% endif %}
</td>
<td>
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
{% else %}{{ pill('offen', 'online') }}{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Keine Tickets', 'Ohne Ticket kann keine Lizenz ausgestellt werden.', 'ticket') }}
{% endif %}
</section>
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('shield', size=17) }} Lizenzen</div></div>
</div>
{% 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>
<tbody>
{% for entry in licenses %}
<tr>
<td class="mono fs-sm">
<a href="{{ url_for('licenses.detail', license_id=entry.license_id) }}">
{{ entry.license_id[:12] }}&hellip;</a>
</td>
<td class="fs-sm">{{ entry.type }}</td>
<td>
{% if entry.status == 'active' %}{{ pill('aktiv', 'online') }}
{% elif entry.status == 'issued' %}{{ pill('ausgestellt', 'info') }}
{% elif entry.status == 'revoked' %}{{ pill('widerrufen', 'danger') }}
{% else %}{{ pill('deaktiviert', '') }}{% endif %}
</td>
<td class="fs-sm">
{% if entry.lifetime %}unbefristet
{% else %}
{{ entry.expires_at | date }}
{% if entry.color == 'red' %}{{ pill('kritisch', 'danger') }}
{% elif entry.color == 'orange' %}{{ pill('bald', 'warning') }}{% endif %}
{% endif %}
</td>
<td class="fs-sm">{{ entry.hostname or '&ndash;' | safe }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Keine Lizenzen', '', 'shield') }}
{% endif %}
</section>
{% if reported.customers %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('share', size=17) }} Von diesem Lizenzserver verwaltete Kunden</div>
<div class="card__sub">
Gemeldeter Bestand &middot; Quelle {{ reported.source_license_id[:12] }}&hellip;
{% if reported.last_reported_at %}
&middot; <span data-relative-time="{{ reported.last_reported_at }}">{{ reported.last_reported_at | dt }}</span>
{% endif %}
</div>
</div>
</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') }}
{% for entry in reported.customers %}
<details class="card">
<summary class="card__header" style="cursor:pointer">
<div class="flex-1">
<div class="card__title">{{ entry.name }}</div>
<div class="card__sub">
{{ entry.city or 'ohne Ort' }} &middot; {{ entry.tickets | length }} Ticket(s)
</div>
</div>
{{ icon('chevron-down', size=15) }}
</summary>
<div class="card__body">
{% for ticket in entry.tickets %}
<div class="stack stack--tight mb-3">
<div class="row row--tight fs-sm">
<span class="mono">{{ ticket.remote_ref[:12] }}&hellip;</span>
<span>{{ ticket.type }}</span>
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
{% else %}{{ pill('offen', 'online') }}{% endif %}
</div>
{% for entry_license in ticket.licenses %}
<div class="row row--tight fs-xs faint" style="padding-left:var(--sp-4)">
<span class="mono">{{ entry_license.remote_ref[:12] }}&hellip;</span>
<span>{{ entry_license.status }}</span>
{% if entry_license.lifetime %}{{ pill('unbefristet', 'online') }}
{% elif entry_license.days_left is not none %}
{{ pill(entry_license.days_left ~ ' Tage',
'danger' if entry_license.color == 'red' else
('warning' if entry_license.color == 'orange' else 'online')) }}
{% endif %}
<span>{{ entry_license.hostname or '' }}</span>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</details>
{% endfor %}
</div>
</section>
{% endif %}
</div>
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('building', size=17) }} Stammdaten</div></div>
</div>
<form method="post" action="{{ url_for('customers.update', customer_id=customer.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ field('name', 'Firmenname', value=customer.name, required=True,
disabled=not current_user.has('customers.edit')) }}
{{ field('contact_email', 'E-Mail', value=customer.contact_email, type='email',
disabled=not current_user.has('customers.edit')) }}
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person,
disabled=not current_user.has('customers.edit')) }}
{{ field('contact_phone', 'Telefon', value=customer.contact_phone,
disabled=not current_user.has('customers.edit')) }}
<div class="form-grid">
{{ field('street', 'Strasse', value=customer.street,
disabled=not current_user.has('customers.edit')) }}
{{ field('house_number', 'Nr.', value=customer.house_number,
disabled=not current_user.has('customers.edit')) }}
{{ field('postal_code', 'PLZ', value=customer.postal_code,
disabled=not current_user.has('customers.edit')) }}
{{ field('city', 'Ort', value=customer.city,
disabled=not current_user.has('customers.edit')) }}
</div>
{{ textarea_field('notes', 'Notizen', value=customer.notes, rows=3,
extra_class='field--full') }}
<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>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 }}">
{{ icon('copy', size=13) }}</button></dd>
</dl>
</div>
{% if current_user.has('customers.edit') %}
<div class="card__footer modal__footer--split">
{% 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
</button>
{% else %}<span></span>{% endif %}
<button class="btn btn--primary" type="submit">Speichern</button>
</div>
{% endif %}
</form>
</section>
</div>
{% endblock %}
@@ -0,0 +1,86 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, textarea_field, pill, search_box, modal,
empty_state %}
{% macro customer_form(customer=None) %}
<div class="form-grid">
{{ 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.') }}
{{ 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 '') }}
{{ field('house_number', 'Hausnummer', value=customer.house_number if customer else '') }}
{{ field('postal_code', 'PLZ', value=customer.postal_code if customer else '') }}
{{ field('city', 'Ort', value=customer.city if customer else '') }}
{{ textarea_field('notes', 'Notizen', value=customer.notes if customer else '', rows=2,
extra_class='field--full') }}
</div>
{% endmacro %}
{% block page_title %}Kunden{% endblock %}
{% block page_sub %}{{ customers | length }} Kunden{% endblock %}
{% block topbar_extra %}
{{ search_box('customers-table', 'Firma, Ort, Ansprechpartner ...') }}
{% if current_user.has('customers.create') %}
<button class="btn btn--primary btn--sm" type="button" data-dialog-open="customer-new">
{{ icon('plus', size=15) }} Kunde anlegen
</button>
{% endif %}
{% endblock %}
{% block content %}
<section class="card">
<div class="table-wrap">
<table class="table" id="customers-table" data-behavior="sortable">
<thead>
<tr>
<th class="sortable">Firma</th>
<th class="sortable">Ansprechpartner</th>
<th class="sortable">E-Mail</th>
<th class="sortable">Ort</th>
<th class="sortable numeric">Tickets</th>
<th class="sortable numeric">Offene Lizenzen</th>
</tr>
</thead>
<tbody>
{% for customer in customers %}
<tr data-filter-text="{{ customer.name }} {{ customer.city }} {{ customer.contact_person }} {{ customer.contact_email }}">
<td>
<a class="fw-600" href="{{ url_for('customers.detail', customer_id=customer.id) }}">
{{ customer.name }}
</a>
{% if customer.license_server_id %}{{ pill('Lizenzserver', 'accent') }}{% endif %}
</td>
<td class="fs-sm">{{ customer.contact_person or '&ndash;' | safe }}</td>
<td class="fs-sm">{{ customer.contact_email or '&ndash;' | safe }}</td>
<td class="fs-sm">{{ customer.city or '&ndash;' | safe }}</td>
<td class="numeric">{{ customer.ticket_count }}</td>
<td class="numeric">{{ customer.open_licenses }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not customers %}
{{ empty_state('Keine Kunden', 'Legen Sie den ersten Kunden an.', 'building') }}
{% endif %}
</section>
{% endblock %}
{% block dialogs %}
{% if current_user.has('customers.create') %}
{% call modal('customer-new', 'Kunde anlegen', 'Adresse in Einzelfeldern -- fuer 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>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--primary" type="submit">Anlegen</button>
</div>
</form>
{% endcall %}
{% endif %}
{% endblock %}
@@ -0,0 +1,124 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import pill, empty_state, alert, stat %}
{% block page_title %}Uebersicht{% endblock %}
{% block page_sub %}
{% if summary %}
{{ summary.counts.customers }} Kunden &middot; {{ summary.counts.licenses_active }} aktive Lizenzen
{% else %}Willkommen{% endif %}
{% endblock %}
{% block content %}
{% if not summary %}
{{ empty_state('Keine Lizenzrechte',
'Ihrem Konto fehlt das Recht, Lizenzen zu sehen. Wenden Sie sich an die Administration.',
'lock') }}
{% else %}
<div class="grid grid--4">
{{ stat('Kunden', summary.counts.customers, href=url_for('customers.overview')) }}
{{ stat('Offene Tickets', summary.counts.tickets_open, href=url_for('tickets.overview')) }}
{{ stat('Aktive Lizenzen', summary.counts.licenses_active, kind='ok',
href=url_for('licenses.overview', status='active')) }}
{{ stat('Widerrufen', summary.counts.licenses_revoked, kind='muted',
href=url_for('licenses.overview', status='revoked')) }}
</div>
{% if summary.counts.tickets_blocked %}
{{ alert(summary.counts.tickets_blocked ~ ' Ticket(s) sind gesperrt -- fuer 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'),
('green', 'In Ordnung', 'ok', 'Mehr als 90 Tage oder unbefristet')] %}
{% set entries = summary.buckets[key] %}
{% if entries %}
<details class="card" data-persist="dash-{{ key }}" {% if key != 'green' %}open{% endif %}>
<summary class="card__header" style="cursor:pointer">
<div class="flex-1">
<div class="card__title">
{{ pill(entries | length, kind if kind != 'ok' else 'online') }} {{ label }}
</div>
<div class="card__sub">{{ hint }}</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
<div class="card__body">
<div class="grid grid--tiles">
{% for entry in entries %}
<a class="card" style="padding:var(--sp-4)" data-filter-item
href="{{ url_for('licenses.detail', license_id=entry.license_id) }}">
<div class="row row--between" style="align-items:flex-start">
<div class="flex-1" style="min-width:0">
<div class="fw-600 truncate">{{ entry.customer_name }}</div>
<div class="fs-xs faint truncate">
{{ entry.hostname or 'kein Host gemeldet' }}
</div>
</div>
{% if entry.lifetime %}{{ pill('unbefristet', 'online') }}
{% elif entry.is_expired %}{{ pill('abgelaufen', 'danger') }}
{% else %}{{ pill(entry.days_left ~ ' Tage', kind if kind != 'ok' else 'online') }}
{% endif %}
</div>
<dl class="kv fs-xs mt-3">
<dt>Typ</dt><dd>{{ entry.type }}{% if entry.role != 'customer' %}
&middot; {{ entry.role }}{% endif %}</dd>
<dt>Zustand</dt><dd>{{ entry.status }}</dd>
{% if entry.last_heartbeat_at %}
<dt>Heartbeat</dt>
<dd><span data-relative-time="{{ entry.last_heartbeat_at }}">{{ entry.last_heartbeat_at | dt }}</span></dd>
{% endif %}
</dl>
</a>
{% endfor %}
</div>
</div>
</details>
{% endif %}
{% endfor %}
{% if not summary.buckets.red and not summary.buckets.orange and not summary.buckets.green %}
{{ empty_state('Noch keine Lizenzen',
'Legen Sie einen Kunden an, erstellen Sie ein Ticket und stellen Sie daraus eine Lizenz aus.',
'shield') }}
{% endif %}
{% if sources %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('share', size=17) }} Sub-Lizenzserver</div>
<div class="card__sub">Von untergeordneten Lizenzservern gemeldeter Bestand</div>
</div>
<a class="btn btn--sm" href="{{ url_for('licenses.reports') }}">Alle anzeigen</a>
</div>
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Kunde</th><th>Host</th><th class="numeric">Kunden</th>
<th class="numeric">Tickets</th><th class="numeric">Lizenzen</th><th>Gemeldet</th></tr></thead>
<tbody>
{% for source in sources %}
<tr>
<td class="fw-600">
<a href="{{ url_for('licenses.report_detail', license_id=source.license_id) }}">
{{ source.customer_name }}</a>
</td>
<td class="fs-sm">{{ source.hostname or '&ndash;' | safe }}</td>
<td class="numeric">{{ source.customers }}</td>
<td class="numeric">{{ source.tickets }}</td>
<td class="numeric">{{ source.licenses }}</td>
<td class="fs-sm">
{% if source.last_reported_at %}
<span data-relative-time="{{ source.last_reported_at }}">{{ source.last_reported_at | dt }}</span>
{% else %}<span class="faint">noch nie</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
{% endif %}
{% endblock %}
@@ -0,0 +1,258 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, checkbox, pill, alert, empty_state, modal %}
{% block page_title %}Lizenz {{ license.license_id[:12] }}&hellip;{% endblock %}
{% block page_sub %}
{{ license.customer_name }} &middot; {{ type_labels.get(license.type, license.type) }}
{% if license.role != 'customer' %}&middot; {{ role_labels.get(license.role, license.role) }}{% endif %}
{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('licenses.overview') }}">Lizenzen</a></span>
<span><a href="{{ url_for('customers.detail', customer_id=license.customer_id) }}">{{ license.customer_name }}</a></span>
<span>Lizenz</span>
</nav>
{% endblock %}
{% block topbar_extra %}
{% if current_user.has('licenses.export') %}
<a class="btn btn--sm btn--primary" href="{{ url_for('licenses.download', license_id=license.license_id) }}">
{{ icon('download', size=15) }} Datei
</a>
{% endif %}
{% endblock %}
{% 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.',
'warning', 'Revision ausstehend') }}
{% endif %}
{% if license.status == 'revoked' %}
{{ alert('Diese Lizenz ist widerrufen. Der Client erfaehrt das beim naechsten Heartbeat und
schaltet seine lizenzpflichtigen Funktionen ab.' ~ ((' Grund: ' ~ license.revoke_reason) if
license.revoke_reason else ''), 'danger', 'Widerrufen') }}
{% endif %}
<div class="grid grid--split">
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('shield', size=17) }} Zustand
{% if license.status == 'active' %}{{ pill('aktiv', 'online') }}
{% elif license.status == 'issued' %}{{ pill('ausgestellt', 'info') }}
{% elif license.status == 'revoked' %}{{ pill('widerrufen', 'danger') }}
{% else %}{{ pill('deaktiviert', '') }}{% endif %}
{% if state.revision_pending %}
{{ pill('Revision ausstehend', 'warning') }}
{% endif %}
</div>
</div>
</div>
<div class="card__body">
<dl class="kv fs-sm">
<dt>Lizenz-ID</dt><dd class="mono break-all">{{ license.license_id }}</dd>
<dt>Revision</dt><dd>{{ license.seq }}</dd>
<dt>Ticket</dt>
<dd>
{% if license.ticket_id %}
<a class="mono" href="{{ url_for('tickets.detail', ticket_id=license.ticket_id) }}">
{{ license.ticket_id[:12] }}&hellip;</a>
{% else %}<span class="faint">kein Ticket (importiert)</span>{% endif %}
</dd>
<dt>Module</dt>
<dd>
{% if modules %}
{% for module in modules %}{{ pill(module_labels.get(module, module), 'online') }}{% endfor %}
{% 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>
<dd>
{% if license.lifetime %}{{ pill('unbefristet', 'online') }}
{% else %}
{{ license.expires_at | dt }}
{% if state.is_expired %}{{ pill('abgelaufen', 'danger') }}
{% elif state.color == 'red' %}{{ pill(state.days_left ~ ' Tage', 'danger') }}
{% elif state.color == 'orange' %}{{ pill(state.days_left ~ ' Tage', 'warning') }}
{% else %}{{ pill(state.days_left ~ ' Tage', 'online') }}{% endif %}
{% endif %}
</dd>
<dt>Maschine</dt>
<dd>
{% if license.fingerprint %}
<span class="mono fs-xs break-all">{{ license.fingerprint }}</span>
{% else %}<span class="faint">noch nicht gebunden</span>{% endif %}
</dd>
<dt>Host (gemeldet)</dt><dd>{{ license.hostname or '&ndash;' | safe }}</dd>
<dt>Aktiviert</dt><dd>{{ license.activated_at | dt }}</dd>
<dt>Letzter Heartbeat</dt>
<dd>
{% if license.last_heartbeat_at %}
<span data-relative-time="{{ license.last_heartbeat_at }}">{{ license.last_heartbeat_at | dt }}</span>
{% else %}<span class="faint">nie</span>{% endif %}
</dd>
</dl>
</div>
</section>
{% if current_user.has('licenses.edit') and license.status != 'revoked' %}
<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__sub">
Signiert eine neue Revision. Der Client erhaelt sie beim naechsten Heartbeat --
ohne erneute Aktivierung.
</div>
</div>
</div>
<form method="post" action="{{ url_for('licenses.resign', license_id=license.license_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{% if license.role == 'customer' %}
<fieldset class="fieldset">
<legend>Module</legend>
<div class="stack stack--tight">
{% for module in all_modules %}
{{ checkbox('modules', module_labels[module], value=module,
checked=module in modules) }}
{% endfor %}
</div>
</fieldset>
{% endif %}
{{ field('extend_days', 'Laufzeit verlaengern (Tage)', type='number',
placeholder='0',
hint_text='Wird auf das aktuelle Ablaufdatum aufgeschlagen.') }}
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Neue Revision signieren</button>
</div>
</form>
</section>
{% endif %}
<section class="card">
<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>
</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>
<tbody>
{% for entry in deliveries %}
<tr>
<td class="fs-sm nowrap">{{ entry.delivered_at | dt }}</td>
<td class="fs-sm">{{ entry.channel }}</td>
<td class="fs-sm">{{ entry.recipient or '&ndash;' | safe }}</td>
<td class="fs-sm">{{ entry.delivered_by }}</td>
<td class="fs-xs muted">{{ entry.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Noch nicht zugestellt', 'Die Datei wurde bisher nicht herausgegeben.', 'transfer') }}
{% endif %}
</section>
<details class="card" data-persist="license-document">
<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>
{{ icon('chevron-down', size=16) }}
</summary>
<pre class="log-view">{{ document_json }}</pre>
</details>
</div>
<div class="stack">
{% if current_user.has('licenses.export') %}
<section class="card">
<div class="card__header">
<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 %}
</div>
</div>
</div>
<form method="post" action="{{ url_for('licenses.send_mail', license_id=license.license_id) }}"
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',
disabled=not mail_ready) }}
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit" {% if not mail_ready %}disabled{% endif %}
data-busy-label="Sende ...">Versenden</button>
</div>
</form>
</section>
{% endif %}
{% if current_user.has('licenses.edit') or current_user.has('licenses.delete') %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('alert', size=17) }} Lebenszyklus</div></div>
</div>
<div class="card__body stack stack--tight">
{% if current_user.has('licenses.edit') and license.status != 'revoked' %}
<button class="btn btn--sm btn--danger btn--block" type="button"
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.
</p>
{% endif %}
{% if current_user.has('licenses.delete') %}
<form method="post" action="{{ url_for('licenses.delete', license_id=license.license_id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<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
</button>
</form>
{% endif %}
</div>
</section>
{% endif %}
</div>
</div>
{% endblock %}
{% block dialogs %}
{% if current_user.has('licenses.edit') and license.status != 'revoked' %}
{% call modal('revoke-dialog', 'Lizenz widerrufen', license.customer_name, 'narrow') %}
<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
lizenzpflichtige Funktionen ab. Das Ticket bleibt offen.', 'warning') }}
{{ field('reason', 'Grund (erscheint im Protokoll)', placeholder='Systemwechsel') }}
</div>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--danger" type="submit">Widerrufen</button>
</div>
</form>
{% endcall %}
{% endif %}
{% endblock %}
@@ -0,0 +1,77 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import pill, search_box, empty_state %}
{% block page_title %}Lizenzen{% endblock %}
{% block page_sub %}{{ licenses | length }} Lizenzen{% if active_filter %} &middot; Filter: {{ active_filter }}{% endif %}{% endblock %}
{% block topbar_extra %}
{{ search_box('licenses-table', 'Kunde, Lizenz-ID, Host ...') }}
{% if current_user.has('licenses.execute') %}
<a class="btn btn--sm" href="{{ url_for('licenses.manual_code') }}">
{{ icon('transfer', size=15) }} Offline-Code
</a>
{% endif %}
{% endblock %}
{% block content %}
<section class="card">
<div class="table-toolbar">
<div class="btn-group">
<a class="btn btn--sm {{ 'btn--subtle' if not active_filter }}" href="{{ url_for('licenses.overview') }}">alle</a>
{% for state in states %}
<a class="btn btn--sm {{ 'btn--subtle' if active_filter == state }}"
href="{{ url_for('licenses.overview', status=state) }}">{{ state }}</a>
{% endfor %}
</div>
</div>
<div class="table-wrap">
<table class="table" id="licenses-table" data-behavior="sortable">
<thead>
<tr>
<th class="sortable">Kunde</th>
<th class="sortable">Lizenz</th>
<th class="sortable">Typ</th>
<th class="sortable">Zustand</th>
<th class="sortable">Restlaufzeit</th>
<th class="sortable">Host</th>
<th class="sortable">Heartbeat</th>
</tr>
</thead>
<tbody>
{% for entry in licenses %}
<tr data-filter-text="{{ entry.customer_name }} {{ entry.license_id }} {{ entry.hostname }} {{ entry.type }}">
<td class="fw-600">
<a href="{{ url_for('customers.detail', customer_id=entry.customer_id) }}">{{ entry.customer_name }}</a>
{% if entry.role != 'customer' %}{{ pill(role_labels.get(entry.role, entry.role), 'accent') }}{% endif %}
</td>
<td class="mono fs-sm">
<a href="{{ url_for('licenses.detail', license_id=entry.license_id) }}">{{ entry.license_id[:12] }}&hellip;</a>
</td>
<td class="fs-sm">{{ type_labels.get(entry.type, entry.type) }}</td>
<td>
{% if entry.status == 'active' %}{{ pill('aktiv', 'online') }}
{% elif entry.status == 'issued' %}{{ pill('ausgestellt', 'info') }}
{% elif entry.status == 'revoked' %}{{ pill('widerrufen', 'danger') }}
{% else %}{{ pill('deaktiviert', '') }}{% endif %}
</td>
<td data-sort-value="{{ entry.days_left if entry.days_left is not none else 99999 }}">
{% if entry.lifetime %}{{ pill('unbefristet', 'online') }}
{% elif entry.is_expired %}{{ pill('abgelaufen', 'danger') }}
{% elif entry.color == 'red' %}{{ pill(entry.days_left ~ ' Tage', 'danger') }}
{% elif entry.color == 'orange' %}{{ pill(entry.days_left ~ ' Tage', 'warning') }}
{% else %}{{ pill(entry.days_left ~ ' Tage', 'online') }}{% endif %}
</td>
<td class="fs-sm">{{ entry.hostname or '&ndash;' | safe }}</td>
<td class="fs-sm" data-sort-value="{{ entry.last_heartbeat_at or '' }}">
{% if entry.last_heartbeat_at %}
<span data-relative-time="{{ entry.last_heartbeat_at }}">{{ entry.last_heartbeat_at | dt }}</span>
{% else %}<span class="faint">nie</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not licenses %}{{ empty_state('Keine Lizenzen', 'Zu diesem Filter gibt es nichts.', 'shield') }}{% endif %}
</section>
{% endblock %}
@@ -0,0 +1,68 @@
{% extends "tesm_core/base.html" %}
{% 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 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') }}
<div class="grid grid--split">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('upload', size=17) }} Code des Kunden</div></div>
</div>
<form method="post">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<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>
</label>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit" data-busy-label="Verarbeite ...">
Verarbeiten
</button>
</div>
</form>
</section>
<div class="stack">
{% if summary %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Inhalt der Anfrage</div></div>
</div>
<div class="card__body">
<dl class="kv fs-sm">
{% for key, value in summary.items() %}
<dt>{{ key }}</dt><dd class="mono break-all">{{ value }}</dd>
{% endfor %}
</dl>
</div>
</section>
{% endif %}
{% if response_code %}
<section class="card card--accent">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('download', size=17) }} Antwortcode</div>
<div class="card__sub">{{ message }}</div>
</div>
<button class="btn btn--sm" type="button" data-copy="#response-code">
{{ icon('copy', size=14) }} Kopieren
</button>
</div>
<div class="card__body">
<pre class="code-block code-block--wrap" id="response-code">{{ response_code }}</pre>
</div>
</section>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,241 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, checkbox, alert, pill, empty_state %}
{% block page_title %}Eigene Lizenz{% endblock %}
{% 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
{% 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
</a>
{% endblock %}
{% block content %}
{% if signing_key_health.problems %}
{% for problem in signing_key_health.problems %}{{ alert(problem, 'danger') }}{% endfor %}
{% endif %}
{% 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
Lizenzdatei, nicht an einem Gruppenrecht. Ein lokaler Administrator eines Sub-Lizenzservers
kann sie sich damit nicht selbst einraeumen.', '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',
'Rolle: Lizenzserver') }}
{% endif %}
<div class="grid grid--split">
<div class="stack">
{% if state and state.valid and state.status %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('key', size=17) }} Installierte Lizenz
{% if state.activated %}{{ pill('aktiviert', 'online') }}
{% else %}{{ pill('nicht aktiviert', 'warning') }}{% endif %}
{% if state.issuer_pinned %}{{ pill('Aussteller gepinnt', 'info') }}{% endif %}
</div>
<div class="card__sub">
{{ type_labels.get(state.status.type, state.status.type) }} &middot;
{{ role_labels.get(state.role, state.role) }}
</div>
</div>
</div>
<div class="card__body">
<dl class="kv fs-sm">
<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>
<dd>
{% if state.status.lifetime %}{{ pill('unbefristet', 'online') }}
{% else %}
{{ state.status.expires_at | dt }}
{% if state.status.expired %}
{{ pill('abgelaufen', 'danger') }}
{% if state.status.grace_active %}
<div class="fs-xs warning-text">
Kulanzfrist laeuft ({{ grace_days }} Tage).
</div>
{% endif %}
{% elif state.status.expiring_soon %}
{{ pill('in ' ~ state.status.days_left ~ ' Tagen', 'warning') }}
{% else %}
{{ pill('in ' ~ state.status.days_left ~ ' Tagen', 'online') }}
{% endif %}
{% endif %}
</dd>
<dt>Uebergeordneter Server</dt>
<dd class="break-all">
{{ state.status.master_endpoint or 'keiner (Wurzelinstanz oder Offline-Betrieb)' }}
</dd>
<dt>Letzter Heartbeat</dt>
<dd>
{{ state.last_heartbeat_at | dt }}
{% if state.heartbeat_error %}
<div class="fs-xs danger-text">{{ state.heartbeat_error }}</div>
{% endif %}
</dd>
</dl>
</div>
{% if current_user.has('settings_license.edit') %}
<div class="card__footer">
{% if not state.activated %}
<form method="post" action="{{ url_for('own_license.activate') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--primary" type="submit">Aktivieren</button>
</form>
{% elif state.status.master_endpoint %}
<form method="post" action="{{ url_for('own_license.heartbeat') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit">
{{ icon('refresh', size=14) }} Jetzt melden
</button>
</form>
<form method="post" action="{{ url_for('own_license.deactivate') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Eigene Lizenz deaktivieren? Lizenzpflichtige Funktionen werden abgeschaltet."
data-confirm-title="Deaktivieren" data-confirm-ok="Deaktivieren">
Deaktivieren
</button>
</form>
{% endif %}
</div>
{% endif %}
</section>
{% else %}
{{ empty_state('Keine gueltige Lizenz',
'Spielen Sie eine Lizenzdatei ein oder erzeugen Sie mit "tesm-license-admin bootstrap"
die eigene Lizenz der Wurzelinstanz.', 'key') }}
{% endif %}
{% if pending %}
<section class="card card--accent">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('transfer', size=17) }} Offene Anfrage (Offline-Weg)</div>
<div class="card__sub">Aktion: {{ pending.action }}</div>
</div>
</div>
<form method="post" action="{{ url_for('own_license.offline_response') }}">
<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>
<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>
</div>
</form>
</section>
{% endif %}
{% if current_user.has('settings_license.edit') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Lizenzdatei einspielen</div>
<div class="card__sub">
Fuer einen Sub-Lizenzserver: die Datei, die der Master ausgestellt hat
</div>
</div>
</div>
<form method="post" enctype="multipart/form-data" data-no-guard
action="{{ url_for('own_license.upload') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
<input class="input" type="file" name="license_file" accept=".json,.lic" required>
{% if trusted_keys and current_user.is_admin %}
{{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdruecklich zulassen') }}
{% endif %}
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Einspielen</button>
</div>
</form>
</section>
{% endif %}
</div>
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('hard-drive', size=17) }} Diese Maschine</div>
</div>
</div>
<div class="card__body">
<dl class="kv fs-sm">
<dt>Hostname</dt><dd class="mono">{{ hostname }}</dd>
<dt>Fingerprint</dt><dd class="mono fs-xs break-all">{{ fingerprint }}</dd>
<dt>Signaturschluessel</dt>
<dd class="mono">{{ signing_key_health.get('key_id') or 'fehlt' }}</dd>
</dl>
</div>
</section>
<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>
</div>
<div class="card__body">
{% if trusted_keys %}
<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>
{% endfor %}
</dl>
{% else %}
<p class="muted fs-sm">Noch kein Aussteller gepinnt.</p>
{% endif %}
</div>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('puzzle', size=17) }} Was die Lizenz freischaltet</div>
</div>
</div>
<div class="card__body">
<ul class="stack stack--tight fs-sm">
{% for key, label in features.items() %}
<li class="row row--tight">
{% if feature_allowed(key) %}
<span class="success-text">{{ icon('check', size=14) }}</span>
{% else %}
<span class="faint">{{ icon('lock', size=14) }}</span>
{% endif %}
<span>{{ label }}</span>
</li>
{% endfor %}
</ul>
{% if may_issue_license_server %}
<div class="divider"></div>
<p class="fs-sm success-text">
{{ icon('check', size=14) }} Diese Instanz darf Lizenzserver-Lizenzen ausstellen.
</p>
{% endif %}
</div>
</section>
</div>
</div>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "tesm_core/standalone.html" %}
{% block heading %}Kundenportal{% endblock %}
{% block content %}
<div class="alert alert--info">
{{ icon('info', size=18) }}
<div class="alert__body">
Das Kundenportal ist bei diesem Anbieter derzeit nicht aktiv. Bitte wenden Sie sich an
Ihren Ansprechpartner.
</div>
</div>
{% endblock %}
@@ -0,0 +1,96 @@
{% extends "tesm_core/standalone.html" %}
{% from "tesm_core/macros.html" import field, pill %}
{% block card_class %}auth-card--wide{% endblock %}
{% block heading %}{{ customer.name }}{% endblock %}
{% block subheading %}Ihre Lizenzen und Stammdaten{% endblock %}
{% block content %}
<div class="alert alert--info">
{{ icon('clock', size=18) }}
<div class="alert__body">
Diese Sitzung endet
<span data-relative-time="{{ expires_at }}">{{ expires_at | dt }}</span>. Danach fordern Sie
einfach einen neuen Zugangslink an.
</div>
</div>
<section class="card">
<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>
</div>
<div class="card__body stack stack--tight">
{% for ticket in tickets %}
{% set entry = licenses.get(ticket.id) %}
<div class="card" style="padding:var(--sp-4)">
<div class="row row--between">
<div>
<div class="fw-600">{{ ticket.type | capitalize }}</div>
<div class="fs-xs faint">
{% if ticket.lifetime %}unbefristet{% else %}{{ ticket.valid_days }} Tage Laufzeit{% endif %}
</div>
</div>
{% if ticket.status == 'blocked' %}
{{ pill('gesperrt', 'danger') }}
{% elif entry %}
{% if entry.lifetime %}{{ pill('unbefristet', 'online') }}
{% elif entry.is_expired %}{{ pill('abgelaufen', 'danger') }}
{% elif entry.color == 'red' %}{{ pill('noch ' ~ entry.days_left ~ ' Tage', 'danger') }}
{% elif entry.color == 'orange' %}{{ pill('noch ' ~ entry.days_left ~ ' Tage', 'warning') }}
{% else %}{{ pill('noch ' ~ entry.days_left ~ ' Tage', 'online') }}{% endif %}
{% else %}
{{ pill('keine Lizenz', 'warning') }}
{% endif %}
</div>
{% if self_service_enabled and ticket.role == 'customer' %}
<a class="btn btn--sm mt-3" href="{{ url_for('selfservice.overview', ticket_id=ticket.id) }}">
Lizenz verwalten
</a>
{% endif %}
</div>
{% else %}
<p class="muted fs-sm">Zu Ihrem Konto ist noch kein Vertrag hinterlegt.</p>
{% endfor %}
</div>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('building', size=17) }} Ihre Angaben</div></div>
</div>
<form method="post">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
<div class="form-grid">
{{ 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.') }}
{{ field('street', 'Strasse', value=customer.street) }}
{{ field('house_number', 'Hausnummer', value=customer.house_number) }}
{{ field('postal_code', 'PLZ', value=customer.postal_code) }}
{{ field('city', 'Ort', value=customer.city) }}
</div>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Speichern</button>
</div>
</form>
</section>
<form method="post" action="{{ url_for('portal.logout') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--block" type="submit">Abmelden</button>
</form>
{% if vendor.name %}
<p class="auth-footnote">
Fragen? {{ vendor.name }}
{% if vendor.phone %} &middot; {{ vendor.phone }}{% endif %}
{% if vendor.email %} &middot; {{ vendor.email }}{% endif %}
</p>
{% endif %}
{% endblock %}
@@ -0,0 +1,28 @@
{% extends "tesm_core/standalone.html" %}
{% block heading %}Kundenportal{% endblock %}
{% block subheading %}Zugangslink anfordern{% endblock %}
{% 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.
</p>
<form method="post" class="stack" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<label class="field">
<span class="field__label">E-Mail-Adresse</span>
<input class="input" type="email" name="email" required autofocus autocomplete="email"
placeholder="ihre.adresse@firma.de">
</label>
<button class="btn btn--primary btn--lg btn--block" type="submit" data-busy-label="Sende ...">
Zugangslink anfordern
</button>
</form>
<p class="auth-footnote">
Aus Datenschutzgruenden bekommen Sie immer dieselbe Rueckmeldung -- unabhaengig davon, ob die
Adresse bei uns bekannt ist.
</p>
{% endblock %}
@@ -0,0 +1,77 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import empty_state, pill, alert %}
{% block page_title %}{{ license.customer_name }}{% endblock %}
{% block page_sub %}Gemeldeter Bestand &middot; Lizenz {{ license.license_id[:12] }}&hellip;{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('licenses.reports') }}">Sub-Lizenzserver</a></span>
<span>{{ license.customer_name }}</span>
</nav>
{% endblock %}
{% block content %}
{{ alert('Diese Daten verwaltet der Sub-Lizenzserver selbst. Sie sind hier rein informativ und
werden bei jedem Bericht vollstaendig ersetzt.', 'info') }}
{% if reported.customers %}
<div class="stack">
{% for entry in reported.customers %}
<details class="card" open>
<summary class="card__header" style="cursor:pointer">
<div class="flex-1">
<div class="card__title">{{ entry.name }}</div>
<div class="card__sub">
{{ entry.contact_email or 'ohne E-Mail' }}
{% if entry.city %}&middot; {{ entry.city }}{% endif %}
&middot; {{ entry.tickets | length }} Ticket(s)
</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Ticket</th><th>Typ</th><th>Zustand</th><th>Lizenzen</th></tr></thead>
<tbody>
{% for ticket in entry.tickets %}
<tr>
<td class="mono fs-sm">{{ ticket.remote_ref[:12] }}&hellip;</td>
<td class="fs-sm">{{ ticket.type }}</td>
<td>
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
{% else %}{{ pill('offen', 'online') }}{% endif %}
</td>
<td class="fs-sm">
{% for entry_license in ticket.licenses %}
<div class="row row--tight">
<span class="mono fs-xs">{{ entry_license.remote_ref[:10] }}&hellip;</span>
<span>{{ entry_license.status }}</span>
{% if entry_license.lifetime %}{{ pill('unbefristet', 'online') }}
{% elif entry_license.days_left is not none %}
{{ pill(entry_license.days_left ~ ' Tage',
'danger' if entry_license.color == 'red' else
('warning' if entry_license.color == 'orange' else 'online')) }}
{% endif %}
{% if entry_license.hostname %}
<span class="fs-xs faint">{{ entry_license.hostname }}</span>
{% endif %}
</div>
{% else %}
<span class="faint">keine</span>
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% endfor %}
</div>
{% else %}
{{ empty_state('Noch kein Bericht',
'Dieser Lizenzserver hat noch nichts gemeldet. Der erste Bericht kommt mit dem naechsten
Heartbeat.', 'share') }}
{% endif %}
{% endblock %}
@@ -0,0 +1,49 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import empty_state, alert, pill %}
{% block page_title %}Sub-Lizenzserver{% endblock %}
{% block page_sub %}{{ sources | length }} untergeordnete Lizenzserver{% endblock %}
{% 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.',
'info') }}
{% if sources %}
<section class="card">
<div class="table-wrap">
<table class="table" data-behavior="sortable">
<thead><tr>
<th class="sortable">Kunde</th><th class="sortable">Lizenz</th><th class="sortable">Host</th>
<th class="sortable numeric">Kunden</th><th class="sortable numeric">Tickets</th>
<th class="sortable numeric">Lizenzen</th><th class="sortable">Gemeldet</th>
</tr></thead>
<tbody>
{% for source in sources %}
<tr>
<td class="fw-600">
<a href="{{ url_for('licenses.report_detail', license_id=source.license_id) }}">
{{ source.customer_name }}</a>
</td>
<td class="mono fs-sm">{{ source.license_id[:12] }}&hellip;</td>
<td class="fs-sm">{{ source.hostname or '&ndash;' | safe }}</td>
<td class="numeric">{{ source.customers }}</td>
<td class="numeric">{{ source.tickets }}</td>
<td class="numeric">{{ source.licenses }}</td>
<td class="fs-sm" data-sort-value="{{ source.last_reported_at or '' }}">
{% if source.last_reported_at %}
<span data-relative-time="{{ source.last_reported_at }}">{{ source.last_reported_at | dt }}</span>
{% else %}{{ pill('noch nie', 'warning') }}{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% else %}
{{ empty_state('Keine Sub-Lizenzserver',
'Es wurde noch keine Lizenz mit der Rolle "Lizenzserver" ausgestellt.', 'share') }}
{% endif %}
{% endblock %}
@@ -0,0 +1,135 @@
{% extends "tesm_core/standalone.html" %}
{% from "tesm_core/macros.html" import pill %}
{% block card_class %}auth-card--wide{% endblock %}
{% block heading %}Ihre TESM-Lizenz{% endblock %}
{% block subheading %}{{ ticket.customer_name }}{% endblock %}
{% block content %}
{% if blocked %}
<div class="alert alert--danger">
{{ icon('alert', size=18) }}
<div class="alert__body">
Dieser Vertrag ist derzeit gesperrt. Bitte wenden Sie sich an Ihren Ansprechpartner.
</div>
</div>
{% endif %}
{% if current %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('shield', size=17) }} Aktuelle Lizenz
{% if current.status == 'active' %}{{ pill('aktiv', 'online') }}
{% else %}{{ pill('bereit zum Einspielen', 'info') }}{% endif %}
</div>
<div class="card__sub">
{{ type_labels.get(current.type, current.type) }} &middot;
{% if current.lifetime %}unbefristet
{% elif current.is_expired %}abgelaufen
{% else %}noch {{ current.days_left }} Tage{% endif %}
</div>
</div>
</div>
<div class="card__body stack">
<dl class="kv fs-sm">
<dt>Gueltig 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>
</dl>
<a class="btn btn--primary btn--block"
href="{{ url_for('selfservice.download', ticket_id=ticket.id, license_id=current.license_id) }}">
{{ icon('download', size=16) }} Lizenzdatei herunterladen
</a>
{% if offline_needed %}
<a class="btn btn--block mt-2"
href="{{ url_for('selfservice.offline', ticket_id=ticket.id) }}">
{{ icon('transfer', size=16) }} Offline aktivieren
</a>
<p class="fs-xs faint mt-2">
Nutzen Sie das, wenn Ihre TESM-Installation uns nicht direkt erreicht.
</p>
{% endif %}
<p class="fs-xs faint">
Spielen Sie die Datei in Ihrer TESM-Installation unter
<strong>Verwaltung &rarr; Lizenz</strong> ein und aktivieren Sie sie dort.
</p>
{% if allow_revoke %}
<div class="divider"></div>
<form method="post" data-no-guard
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-title="Lizenz freigeben" data-confirm-ok="Freigeben">
Lizenz freigeben (Systemwechsel)
</button>
</form>
<p class="fs-xs faint">
Nutzen Sie das, wenn Sie TESM auf einem anderen Server neu aufsetzen. Ihr Vertrag
bleibt bestehen.
</p>
{% endif %}
</div>
</section>
{% else %}
<section class="card">
<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>
</div>
<div class="card__body">
<form method="post" action="{{ url_for('selfservice.issue', ticket_id=ticket.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--primary btn--block" type="submit" {% if blocked %}disabled{% endif %}
data-busy-label="Erstelle ...">
Lizenz erstellen
</button>
</form>
</div>
</section>
{% endif %}
{% if history %}
<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>
{{ icon('chevron-down', size=16) }}
</summary>
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Ausgestellt</th><th>Zustand</th><th>System</th></tr></thead>
<tbody>
{% for entry in history %}
<tr>
<td class="fs-sm">{{ entry.issued_at | date }}</td>
<td class="fs-sm">
{% if entry.status == 'revoked' %}freigegeben
{% elif entry.status == 'deactivated' %}abgeloest
{% else %}{{ entry.status }}{% endif %}
</td>
<td class="fs-sm">{{ entry.hostname or '&ndash;' | safe }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% endif %}
<p class="auth-footnote">
<a href="{{ portal_url }}">Stammdaten pflegen</a>
{% if vendor.name %} &middot; {{ vendor.name }}{% endif %}
{% if vendor.phone %} &middot; {{ vendor.phone }}{% endif %}
</p>
{% endblock %}
@@ -0,0 +1,51 @@
{% extends "tesm_core/standalone.html" %}
{% from "tesm_core/macros.html" import alert %}
{% block card_class %}auth-card--wide{% endblock %}
{% block heading %}Offline aktivieren{% endblock %}
{% block subheading %}{{ ticket.customer_name }}{% endblock %}
{% block content %}
<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') }}
{% if response_code %}
<div class="field">
<span class="field__label">Antwortcode -- in TESM eintragen</span>
<pre class="log-view" id="offline-answer" style="max-height:16dvh">{{ response_code }}</pre>
<div class="row row--end mt-2">
<button class="btn btn--sm" type="button" data-copy="#offline-answer">
Code kopieren
</button>
</div>
<p class="fs-sm muted mt-2">
Tragen Sie ihn in TESM unter Verwaltung -- Lizenz bei
&bdquo;Antwortcode des Lizenzservers&ldquo; ein.
{% if summary.hostname %}
Die Anfrage kam von <span class="mono">{{ summary.hostname }}</span>.
{% endif %}
</p>
</div>
{% else %}
<form method="post">
<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>
</label>
<div class="row row--end mt-3">
<button class="btn btn--primary" type="submit">Antwortcode erzeugen</button>
</div>
</form>
{% endif %}
<p class="fs-xs faint">
<a href="{{ url_for('selfservice.overview', ticket_id=ticket.id) }}">Zurueck zur Uebersicht</a>
</p>
</div>
</section>
{% endblock %}
@@ -0,0 +1,81 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, checkbox, alert, pill %}
{% block page_title %}E-Mail-Versand{% endblock %}
{% block page_sub %}
Microsoft Graph (App-only) &middot;
{% if ready %}einsatzbereit{% else %}nicht vollstaendig konfiguriert{% endif %}
{% endblock %}
{% block topbar_extra %}
{% if current_user.has('settings_mail.execute') %}
<form method="post" action="{{ url_for('settings.mail_test') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit" data-busy-label="Teste ...">
{{ icon('activity', size=15) }} Verbindung testen
</button>
</form>
{% endif %}
{% 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
Mail.Send. Der Verbindungstest holt nur ein Token und verschickt
bewusst keine echte Nachricht.', 'info') }}
<div class="grid grid--split">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('mail', size=17) }} Zugang</div></div>
</div>
<form method="post" action="{{ url_for('settings.mail_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ checkbox('graph_enabled', 'E-Mail-Versand aktiv', checked=values.graph_enabled,
disabled=not current_user.has('settings_mail.edit')) }}
<div class="form-grid">
{{ field('graph_tenant_id', 'Verzeichnis-ID (Tenant)', value=values.graph_tenant_id,
disabled=not current_user.has('settings_mail.edit')) }}
{{ field('graph_client_id', 'Anwendungs-ID (Client)', value=values.graph_client_id,
disabled=not current_user.has('settings_mail.edit')) }}
{{ field('graph_sender', 'Absender-Postfach', value=values.graph_sender, type='email',
disabled=not current_user.has('settings_mail.edit')) }}
{{ field('graph_reply_to', 'Antwortadresse', value=values.graph_reply_to, type='email',
disabled=not current_user.has('settings_mail.edit')) }}
</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',
autocomplete='new-password', disabled=not can_secrets) }}
{% if not can_secrets %}
<p class="fs-xs faint mt-2">
Zum Setzen des Secrets fehlt Ihnen das Recht &bdquo;Geheimnisse&ldquo;.
</p>
{% endif %}
</fieldset>
</div>
{% if current_user.has('settings_mail.edit') %}
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
{% endif %}
</form>
</section>
<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>
<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>
</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.
</p>
</div>
</section>
</div>
{% endblock %}
@@ -0,0 +1,134 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert %}
{% block page_title %}Systemeinstellungen{% endblock %}
{% block page_sub %}Portal, Self-Service, Protokolle, Host{% endblock %}
{% block content %}
{% if not sysops_available %}
{{ alert('Systemhelfer nicht verfuegbar -- Hostname und Zeitzone koennen hier nur angezeigt,
nicht geaendert werden.', 'info') }}
{% endif %}
<div class="grid grid--split">
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('users', size=17) }} Kundenportal und Self-Service</div>
<div class="card__sub">Zwei unabhaengige Zeitfenster: Link und Sitzung</div>
</div>
</div>
<form method="post" action="{{ url_for('settings.system_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ checkbox('portal_enabled', 'Kundenportal aktiv', checked=values.portal_enabled) }}
<div class="form-grid">
{{ field('portal_link_minutes', 'Zugangslink gueltig (Minuten)',
value=values.portal_link_minutes, type='number',
hint_text=specs['portal_link_minutes'].hint) }}
{{ field('portal_session_minutes', 'Portalsitzung gueltig (Minuten)',
value=values.portal_session_minutes, type='number',
hint_text=specs['portal_session_minutes'].hint) }}
</div>
<div class="divider"></div>
{{ 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',
checked=values.allow_customer_revoke,
hint_text=specs['allow_customer_revoke'].hint) }}
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('ticket', size=17) }} Vorgaben und Protokolle</div>
</div>
</div>
<form method="post" action="{{ url_for('settings.system_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="form-grid">
{{ field('default_valid_days', 'Vorgeschlagene Laufzeit neuer Tickets (Tage)',
value=values.default_valid_days, type='number') }}
{{ select_field('log_rotate_interval', 'Rotationsintervall',
specs['log_rotate_interval'].choices,
selected=values.log_rotate_interval) }}
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
type='number') }}
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
value=values.trash_retention_days, type='number') }}
</div>
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
</section>
</div>
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Host</div></div>
</div>
<div class="card__body stack">
<form method="post" action="{{ url_for('settings.set_hostname') }}" class="stack stack--tight">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
{{ field('hostname', 'Hostname', value=hostname, disabled=not sysops_available) }}
<button class="btn btn--sm" type="submit" {% if not sysops_available %}disabled{% endif %}>
Hostname setzen
</button>
</form>
<div class="divider"></div>
<form method="post" action="{{ url_for('settings.set_timezone') }}" class="stack stack--tight">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<label class="field">
<span class="field__label">Zeitzone</span>
<select class="select" name="timezone" {% if not sysops_available %}disabled{% endif %}>
{% for zone in timezones %}
<option value="{{ zone }}" {% if zone == timezone %}selected{% endif %}>{{ zone }}</option>
{% endfor %}
</select>
</label>
<button class="btn btn--sm" type="submit" {% if not sysops_available %}disabled{% endif %}>
Zeitzone setzen
</button>
</form>
</div>
</section>
{% if current_user.is_admin %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
<div class="card__sub">Gilt fuer alle Benutzer</div>
</div>
</div>
<form method="post" action="{{ url_for('admin.nav_order') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<ol class="stack stack--tight">
{% for key in nav_order %}
{% set item = nav_items | selectattr('key', 'equalto', key) | list | first %}
{% if item %}
<li class="row row--tight">
<input type="hidden" name="order" value="{{ item.key }}">
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
</li>
{% endif %}
{% endfor %}
</ol>
</div>
<div class="card__footer">
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
</div>
</form>
</section>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,84 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, textarea_field, alert %}
{% block page_title %}Anbieterdaten{% endblock %}
{% block page_sub %}Reisen in jeder ausgestellten Lizenz mit{% endblock %}
{% block content %}
{{ alert('Diese Angaben werden in jede Lizenzdatei eingebettet und beim Kunden auf der
Lizenzseite angezeigt -- inklusive Logo. Der oeffentliche Endpunkt entscheidet zusaetzlich,
welche Basis-URL in kundengerichteten Links steht.', 'info') }}
<div class="grid grid--split">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('building', size=17) }} Kontakt</div></div>
</div>
<form method="post" action="{{ url_for('settings.vendor_save') }}" enctype="multipart/form-data">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
<div class="form-grid">
{{ field('vendor_name', 'Anbietername', value=values.vendor_name,
disabled=not current_user.has('settings_vendor.edit')) }}
{{ field('vendor_email', 'E-Mail', value=values.vendor_email, type='email',
disabled=not current_user.has('settings_vendor.edit')) }}
{{ field('vendor_phone', 'Telefon', value=values.vendor_phone,
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,
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.',
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"
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>
{% endif %}
<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.
</span>
</div>
</fieldset>
</div>
{% if current_user.has('settings_vendor.edit') %}
<div class="card__footer modal__footer--split">
{% if values.vendor_logo_base64 %}
<button class="btn btn--sm btn--ghost" type="submit"
formaction="{{ url_for('settings.vendor_logo_remove') }}">Logo entfernen</button>
{% else %}<span></span>{% endif %}
<button class="btn btn--primary" type="submit">Speichern</button>
</div>
{% endif %}
</form>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Wo das erscheint</div></div>
</div>
<div class="card__body">
<ul class="stack stack--tight fs-sm muted">
<li>Auf der Lizenzseite der Kundeninstanz (Name, Kontakt, Logo).</li>
<li>In der E-Mail, die eine Lizenz ausliefert.</li>
<li>Im Self-Service und im Kundenportal.</li>
</ul>
<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
Revision signiert wird.
</p>
</div>
</section>
</div>
{% endblock %}
@@ -0,0 +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 breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('own_license.overview') }}">Eigene Lizenz</a></span><span>Signaturschluessel</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') }}
{% 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="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>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') }}
</div>
</section>
{% endblock %}
@@ -0,0 +1,259 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, checkbox, textarea_field, pill, alert, empty_state %}
{% block page_title %}Ticket {{ ticket.id[:12] }}&hellip;{% endblock %}
{% block page_sub %}
{{ ticket.customer_name }} &middot; {{ type_labels.get(ticket.type, ticket.type) }}
{% if ticket.role != 'customer' %}&middot; {{ role_labels.get(ticket.role, ticket.role) }}{% endif %}
{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('tickets.overview') }}">Tickets</a></span>
<span><a href="{{ url_for('customers.detail', customer_id=ticket.customer_id) }}">{{ ticket.customer_name }}</a></span>
<span>Ticket</span>
</nav>
{% endblock %}
{% block content %}
{% if endpoint_missing %}
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung moeglich') }}
{% 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.',
'danger', 'Gesperrt') }}
{% endif %}
<div class="grid grid--split">
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Lizenz ausstellen</div>
<div class="card__sub">
{% if has_open_license %}
Es existiert bereits eine offene Lizenz -- widerrufen Sie sie zuerst.
{% else %}
Erzeugt eine signierte Lizenzdatei mit den Werten dieses Tickets.
{% endif %}
</div>
</div>
</div>
<div class="card__body row row--tight">
{% if current_user.has('licenses.create') %}
<form method="post" action="{{ url_for('licenses.issue', ticket_id=ticket.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--primary" type="submit"
{% if has_open_license or ticket.status == 'blocked' %}disabled{% endif %}
data-busy-label="Stelle aus ...">
{{ icon('plus', size=15) }} Lizenz ausstellen
</button>
</form>
{% if ticket.role == 'customer' %}
<form method="post" action="{{ url_for('licenses.issue', ticket_id=ticket.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<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-title="Testlizenz" data-confirm-ok="Ausstellen"
data-confirm-style="primary">
Als Testlizenz ({{ trial_days }} Tage, alle Module)
</button>
</form>
{% endif %}
{% else %}
<p class="muted fs-sm">Ihnen fehlt das Recht, Lizenzen auszustellen.</p>
{% endif %}
</div>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('history', size=17) }} Lizenzen dieses Tickets</div>
<div class="card__sub">{{ licenses | length }} insgesamt</div>
</div>
</div>
{% 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>
<tbody>
{% for entry in licenses %}
<tr>
<td class="mono fs-sm">
<a href="{{ url_for('licenses.detail', license_id=entry.license_id) }}">
{{ entry.license_id[:12] }}&hellip;</a>
{% if entry.type != ticket.type %}{{ pill(entry.type, 'accent') }}{% endif %}
</td>
<td class="fs-sm">{{ entry.issued_at | dt }}</td>
<td>
{% if entry.status == 'active' %}{{ pill('aktiv', 'online') }}
{% elif entry.status == 'issued' %}{{ pill('ausgestellt', 'info') }}
{% elif entry.status == 'revoked' %}{{ pill('widerrufen', 'danger') }}
{% else %}{{ pill('deaktiviert', '') }}{% endif %}
</td>
<td class="fs-sm">
{% if entry.lifetime %}unbefristet{% else %}{{ entry.expires_at | date }}{% endif %}
</td>
<td class="fs-sm">{{ entry.hostname or '&ndash;' | safe }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Noch keine Lizenz', 'Stellen Sie die erste Lizenz aus.', 'shield') }}
{% endif %}
</section>
{% if self_service_url %}
<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__sub">
Ohne Anmeldung nutzbar &ndash; die Berechtigung ist der Besitz dieses Links
</div>
</div>
</div>
<div class="card__body stack">
<div class="input-group">
<input class="input input--mono" id="ss-url" value="{{ self_service_url }}" readonly>
<button class="btn" type="button" data-copy="#ss-url">{{ icon('copy', size=15) }}</button>
</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
ihn wie ein Passwort.
</p>
<div class="divider"></div>
<div class="input-group">
<input class="input input--mono" id="portal-url" value="{{ portal_url }}" readonly>
<button class="btn" type="button" data-copy="#portal-url">{{ icon('copy', size=15) }}</button>
</div>
<p class="fs-xs faint">
Kundenportal: der Kunde fordert dort selbst einen zeitlich begrenzten Zugangslink an
und pflegt seine Stammdaten.
</p>
</div>
</section>
{% elif ticket.role != 'customer' %}
{{ alert('Fuer Lizenzserver-Tickets ist der Self-Service vollstaendig gesperrt -- diese
Lizenzen werden ausschliesslich hier verwaltet.', 'info') }}
{% endif %}
</div>
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Ticketdaten</div></div>
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
{% else %}{{ pill('offen', 'online') }}{% endif %}
</div>
<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') }}
<label class="field">
<span class="field__label">Lizenztyp</span>
<select class="select" name="type" id="ticket-type"
{% if not current_user.has('tickets.edit') %}disabled{% endif %}>
{% for value in types %}
<option value="{{ value }}" {% if value == ticket.type %}selected{% endif %}>
{{ type_labels.get(value, value) }}
</option>
{% endfor %}
</select>
</label>
{% if ticket.role == 'customer' %}
<fieldset class="fieldset" id="module-box">
<legend>Module</legend>
<div class="stack stack--tight">
{% for module in modules %}
{{ checkbox('modules', module_labels[module], value=module,
checked=module in ticket_modules,
disabled=not current_user.has('tickets.edit')) }}
{% endfor %}
</div>
</fieldset>
{% endif %}
{{ field('valid_days', 'Laufzeit (Tage)', value=ticket.valid_days, type='number',
disabled=not current_user.has('tickets.edit')) }}
{{ checkbox('lifetime', 'Unbefristet', checked=ticket.lifetime,
disabled=not current_user.has('tickets.edit')) }}
{{ textarea_field('notes', 'Interne Notiz', value=ticket.notes, rows=2) }}
<div class="divider"></div>
<dl class="kv fs-xs">
<dt>Ticket-ID</dt><dd class="mono break-all">{{ ticket.id }}</dd>
<dt>Rolle</dt><dd>{{ role_labels.get(ticket.role, ticket.role) }}</dd>
<dt>Angelegt</dt><dd>{{ ticket.created_at | dt }} von {{ ticket.created_by or '-' }}</dd>
</dl>
</div>
{% if current_user.has('tickets.edit') %}
<div class="card__footer">
<button class="btn btn--primary" type="submit">Speichern</button>
</div>
{% endif %}
</form>
</section>
{% 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>
<div class="card__body stack stack--tight">
{% if current_user.has('tickets.edit') %}
<form method="post" action="{{ url_for('tickets.toggle_block', ticket_id=ticket.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<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-style="{{ 'primary' if ticket.status == 'blocked' else 'danger' }}">
{{ 'Entsperren' if ticket.status == 'blocked' else 'Sperren' }}
</button>
</form>
{% endif %}
{% if current_user.has('tickets.delete') %}
<form method="post" action="{{ url_for('tickets.delete', ticket_id=ticket.id) }}"
data-no-guard>
<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
</button>
</form>
{% endif %}
</div>
</section>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
{% if ticket.role == 'customer' %}
<script type="module" nonce="{{ csp_nonce }}">
const type = document.getElementById("ticket-type");
const moduleBox = document.getElementById("module-box");
if (type && moduleBox) {
const sync = () => {
const custom = type.value === "custom";
moduleBox.hidden = !custom;
};
type.addEventListener("change", sync);
sync();
}
</script>
{% endif %}
{% endblock %}
@@ -0,0 +1,139 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, checkbox, textarea_field, alert, pill %}
{% block page_title %}Ticket anlegen{% endblock %}
{% block page_sub %}Kunde, Typ, Module und Laufzeit{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('tickets.overview') }}">Tickets</a></span><span>Neu</span>
</nav>
{% endblock %}
{% block content %}
<form method="post" action="{{ url_for('tickets.create') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="grid grid--split">
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('ticket', size=17) }} Grunddaten</div></div>
</div>
<div class="card__body stack">
<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>
{% for customer in customers %}
<option value="{{ customer.id }}"
{% if preselected_customer == customer.id %}selected{% endif %}>
{{ customer.name }}{% if customer.city %} &middot; {{ customer.city }}{% endif %}
</option>
{% endfor %}
</select>
<span class="field__hint">
Der Kunde ist nach dem Anlegen unveraenderlich -- der Self-Service-Link ist an ihn
gebunden.
</span>
</label>
<label class="field">
<span class="field__label">Lizenztyp</span>
<select class="select" name="type" id="ticket-type">
{% for value in types %}
<option value="{{ value }}" {% if value == 'standard' %}selected{% endif %}>
{{ type_labels.get(value, value) }}
</option>
{% endfor %}
</select>
<span class="field__hint">
Standard: Grundfunktionen. Individuell: Module frei waehlbar. Enterprise: alle
Module. Testlizenz: 30 Tage.
</span>
</label>
<fieldset class="fieldset" id="module-box">
<legend>Module (nur bei &bdquo;Individuell&ldquo;)</legend>
<div class="stack stack--tight">
{% for module in modules %}
{{ checkbox('modules', module_labels[module], value=module) }}
{% endfor %}
</div>
</fieldset>
<div class="form-grid">
{{ 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
permanente Lizenz ohne Module.') }}
{{ textarea_field('notes', 'Interne Notiz', rows=2) }}
</div>
</section>
{% if may_create_license_server %}
<section class="card card--accent">
<div class="card__header">
<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
</div>
</div>
</div>
<div class="card__body stack">
{{ alert('Ein so lizenzierter Lizenzserver darf alles, was ein Lizenzserver koennen
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.',
'warning') }}
<label class="check">
<input type="checkbox" name="role" value="license_server">
<span>Dies ist eine Lizenzserver-Lizenz</span>
</label>
</div>
</section>
{% endif %}
</div>
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Wie es weitergeht</div></div>
</div>
<div class="card__body">
<ol class="stack stack--tight fs-sm muted" style="list-style:decimal;padding-left:1.2em">
<li>Ticket anlegen (dieser Schritt).</li>
<li>Auf der Ticketseite eine Lizenz ausstellen -- optional als 30-Tage-Testlizenz mit
allen Modulen.</li>
<li>Lizenzdatei herunterladen oder per E-Mail versenden.</li>
<li>Der Kunde spielt sie ein und aktiviert sie; die Aktivierung bindet sie an seine
Maschine.</li>
</ol>
<div class="divider"></div>
<p class="fs-sm muted">
Der Self-Service-Link des Tickets erlaubt dem Kunden, seine Lizenz selbst
herunterzuladen und bei einem Systemwechsel freizugeben -- ohne Support-Anruf.
</p>
</div>
<div class="card__footer">
<a class="btn" href="{{ url_for('tickets.overview') }}">Abbrechen</a>
<button class="btn btn--primary" type="submit">Ticket anlegen</button>
</div>
</section>
</div>
</form>
{% endblock %}
{% block scripts %}
<script type="module" nonce="{{ csp_nonce }}">
const type = document.getElementById("ticket-type");
const moduleBox = document.getElementById("module-box");
const sync = () => {
const custom = type.value === "custom";
moduleBox.hidden = !custom;
moduleBox.querySelectorAll("input").forEach((box) => (box.disabled = !custom));
};
type.addEventListener("change", sync);
sync();
</script>
{% endblock %}
@@ -0,0 +1,91 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import pill, search_box, empty_state, alert %}
{% block page_title %}Tickets{% endblock %}
{% block page_sub %}
{{ tickets | length }} Tickets &middot; ein Ticket ist der dauerhafte Anspruch auf eine Lizenz
{% endblock %}
{% block topbar_extra %}
{{ search_box('tickets-table', 'Kunde, Typ, ID ...') }}
{% if current_user.has('tickets.create') %}
<a class="btn btn--primary btn--sm" href="{{ url_for('tickets.new') }}">
{{ icon('plus', size=15) }} Ticket anlegen
</a>
{% endif %}
{% endblock %}
{% block content %}
{% if endpoint_missing %}
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung moeglich') }}
{% 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') }}
<section class="card">
<div class="table-wrap">
<table class="table" id="tickets-table" data-behavior="sortable">
<thead>
<tr>
<th class="sortable">Kunde</th>
<th class="sortable">Ticket</th>
<th class="sortable">Typ</th>
<th>Module</th>
<th class="sortable">Laufzeit</th>
<th class="sortable">Zustand</th>
<th>Offene Lizenz</th>
</tr>
</thead>
<tbody>
{% for ticket in tickets %}
<tr data-filter-text="{{ ticket.customer_name }} {{ ticket.type }} {{ ticket.id }} {{ ticket.role }}">
<td>
<a class="fw-600" href="{{ url_for('customers.detail', customer_id=ticket.customer_id) }}">
{{ ticket.customer_name }}
</a>
{% if ticket.role != 'customer' %}
{{ pill(role_labels.get(ticket.role, ticket.role), 'accent') }}
{% endif %}
</td>
<td class="mono fs-sm">
<a href="{{ url_for('tickets.detail', ticket_id=ticket.id) }}">{{ ticket.id[:12] }}&hellip;</a>
</td>
<td class="fs-sm">{{ type_labels.get(ticket.type, ticket.type) }}</td>
<td class="fs-xs">
{% set mods = ticket.modules | from_json %}
{% if mods %}{{ mods | map('replace', '_', ' ') | join(', ') }}
{% else %}<span class="faint">keine</span>{% endif %}
</td>
<td class="fs-sm">
{% if ticket.lifetime %}{{ pill('unbefristet', 'online') }}
{% else %}{{ ticket.valid_days }} Tage{% endif %}
</td>
<td>
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
{% else %}{{ pill('offen', 'online') }}{% endif %}
</td>
<td class="fs-sm">
{% set entry = open_licenses.get(ticket.id) %}
{% if entry %}
<a href="{{ url_for('licenses.detail', license_id=entry.license_id) }}">
{{ entry.status }}
</a>
{% if entry.lifetime %}{{ pill('unbefristet', 'online') }}
{% elif entry.color == 'red' %}{{ pill(entry.days_left ~ ' Tage', 'danger') }}
{% elif entry.color == 'orange' %}{{ pill(entry.days_left ~ ' Tage', 'warning') }}
{% else %}{{ pill(entry.days_left ~ ' Tage', 'online') }}{% endif %}
{% else %}
<span class="faint">keine</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not tickets %}
{{ empty_state('Keine Tickets', 'Legen Sie das erste Ticket fuer einen Kunden an.', 'ticket') }}
{% endif %}
</section>
{% endblock %}
@@ -0,0 +1,7 @@
"""WSGI-Einstiegspunkt: ``gunicorn tesm_license.wsgi:app``."""
from __future__ import annotations
from . import create_app
app = create_app()
+563
View File
@@ -0,0 +1,563 @@
#!/usr/bin/env bash
# =============================================================================
# Installation und Aktualisierung von TESM und TESM-Lizenzserver.
#
# Aufruf (aus dem entpackten Release-Verzeichnis):
# sudo bash deploy/install.sh --app tesm
# sudo bash deploy/install.sh --app tesm-license
# sudo bash deploy/install.sh --app tesm --instance test --port 5100
#
# Die im Vorgaengerprojekt real aufgetretenen Fallen sind hier von vornherein
# vermieden -- jeweils mit Kommentar an der Stelle, wo es zaehlt:
#
# * Die nginx-Site-Datei wird bei jedem Lauf **inhaltlich verglichen** und bei
# Abweichung erneuert. Der Vorgaenger schrieb sie nur, wenn sie fehlte -- ein
# einmal falscher alias-Pfad blieb dadurch ueber Jahre stehen und lieferte
# alle statischen Dateien als 404.
# * pip laeuft immer ueber "python3 -m pip", nie ueber den venv-Shim: dessen
# Shebang baeckt einen absoluten Pfad ein, der bei einer Verzeichnis-
# umbenennung bricht (live auf POETEST reproduziert).
# * Vor jedem Update ein vollstaendiges Backup -- unabhaengig davon, ob sich
# das Schema geaendert hat. Ein reiner Code-Fehler soll durch dasselbe Netz
# laufen.
# * Nach dem Update ein Health-Check mit automatischem Rollback.
# * Instanzdaten (Datenbank, Schluessel, Lizenz) sind vom Abgleich
# ausgeschlossen und werden nie ueberschrieben.
# =============================================================================
set -euo pipefail
APP=""
INSTANCE=""
PORT=""
HTTP_PORT=""
SKIP_PACKAGES=0
ASSUME_YES=0
HTTPS=0
HTTPS_PORT=""
DOMAIN=""
ACME_EMAIL=""
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
info() { printf ' %s\n' "$*"; }
usage() {
cat <<'USAGE'
Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
--app <name> Welche Anwendung installiert wird (Pflicht)
--instance <name> Zusaetzliche Instanz neben der Hauptinstallation.
Erzeugt /srv/<app>-<name>, <app>-<name>.service usw.
Damit laesst sich eine neue Version testen, ohne die
laufende anzufassen.
--port <nummer> Port des Anwendungsservers (Standard: 5000 bzw. 5001)
--http-port <nr> Port, auf dem nginx diese Instanz anbietet (Standard 80).
Fuer eine Testinstanz neben einer laufenden Produktion:
auf Port 80 kann nur eine Site die Vorgabe sein.
--https HTTPS einrichten: Port 443 mit TLS, Port 80 leitet
dorthin um (ausser dem ACME-Pfad), HSTS aktiv,
Sitzungscookie mit Secure-Flag. Ohne --domain wird ein
selbstsigniertes Zertifikat erzeugt (geschlossene Netze);
mit --domain wird zusaetzlich versucht, ein
Let's-Encrypt-Zertifikat zu holen.
--https-port <nr> Wie --https, aber auf abweichendem Port (Testinstanz).
--domain <name> Oeffentlicher Name dieser Installation.
--acme-email <a> Kontaktadresse fuer Let's Encrypt.
--skip-packages Keine Systempakete installieren
--yes Rueckfragen ueberspringen
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--app) APP="${2:?}"; shift 2 ;;
--instance) INSTANCE="${2:?}"; shift 2 ;;
--port) PORT="${2:?}"; shift 2 ;;
--http-port) HTTP_PORT="${2:?}"; shift 2 ;;
--https) HTTPS=1; shift ;;
--https-port) HTTPS_PORT="${2:?}"; HTTPS=1; shift 2 ;;
--domain) DOMAIN="${2:?}"; shift 2 ;;
--acme-email) ACME_EMAIL="${2:?}"; shift 2 ;;
--skip-packages) SKIP_PACKAGES=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) fail "unbekannte Option: $1" ;;
esac
done
[[ -n "$APP" ]] || { usage; fail "--app fehlt"; }
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || fail "--app muss tesm oder tesm-license sein"
# Seit die beiden Anwendungen in getrennten Repositorien liegen, bringt ein
# Auscheckout nur die eigene mit. Ohne diese Pruefung waere die Meldung ein
# Folgefehler tief im Installationslauf statt eines klaren Satzes hier.
[[ -d "$REPO_DIR/apps/$APP" ]] || fail "In diesem Verzeichnis liegt '$APP' nicht ($REPO_DIR/apps/$APP fehlt). Vorhanden: $(
ls -1 "$REPO_DIR/apps" 2>/dev/null | tr '
' ' ' || echo 'nichts')"
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
[[ -z "$INSTANCE" || "$INSTANCE" =~ ^[a-z0-9-]{1,16}$ ]] || fail "ungueltiger Instanzname"
# -- Namensraum --------------------------------------------------------------
NAME="$APP"
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
ROOT="/srv/$NAME"
LOG_DIR="/var/log/$NAME"
SERVICE_USER="$APP"
UNIT="$NAME.service"
MONITOR_UNIT="$NAME-monitor.service"
NGINX_SITE="/etc/nginx/sites-available/$NAME"
ENV_PREFIX="TESM_"
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
if [[ -z "$PORT" ]]; then
PORT=5000
[[ "$APP" == "tesm-license" ]] && PORT=5001
[[ -n "$INSTANCE" ]] && PORT=$((PORT + 100))
fi
if [[ -z "$HTTP_PORT" ]]; then
HTTP_PORT=80
[[ -n "$INSTANCE" ]] && HTTP_PORT=8080
fi
if [[ -z "$HTTPS_PORT" ]]; then
HTTPS_PORT=443
[[ -n "$INSTANCE" ]] && HTTPS_PORT=8443
fi
# Das Secure-Flag am Sitzungscookie darf erst gesetzt werden, wenn es
# tatsaechlich HTTPS gibt -- sonst kommt das Cookie nie beim Browser an und die
# Anmeldung schlaegt ohne erkennbaren Grund fehl.
# Bei einer Aktualisierung zaehlt, was schon gilt: wer HTTPS eingerichtet hat,
# darf es durch ein Update ohne --https nicht verlieren. Ein zurueckgesetztes
# Secure-Flag wuerde die Anmeldung stillschweigend unmoeglich machen -- der
# Browser sendet das Cookie dann weiter nur ueber HTTPS, die Anwendung setzt es
# aber ohne das Flag neu, und beide reden aneinander vorbei.
COOKIE_SECURE=0
if [[ -f "/etc/tesm/$NAME.env" ]] && grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "/etc/tesm/$NAME.env"; then
COOKIE_SECURE=1
fi
[[ $HTTPS -eq 1 ]] && COOKIE_SECURE=1
[[ -z "$DOMAIN" || "$DOMAIN" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] || fail "ungueltige Domain: $DOMAIN"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP_DIR="/srv/$NAME-backup-$TIMESTAMP"
FAILED_DIR="/srv/$NAME-failed-$TIMESTAMP"
# Einziges Signal fuer "schon installiert": die Datenbank ist da.
IS_UPDATE=0
[[ -f "$ROOT/data/app.db" ]] && IS_UPDATE=1
step "TESM-Installation"
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
info "Ziel : $ROOT"
info "Dienst : $UNIT auf 127.0.0.1:$PORT"
if [[ $HTTPS -eq 1 ]]; then
info "nginx : Port $HTTPS_PORT (HTTPS), Port $HTTP_PORT leitet um"
elif [[ "$COOKIE_SECURE" == "1" ]]; then
info "nginx : HTTPS ist bereits eingerichtet und bleibt unveraendert"
if [[ -n "$DOMAIN" ]]; then
info "Zertifikat : Lets Encrypt fuer $DOMAIN, sonst selbstsigniert"
else
info "Zertifikat : selbstsigniert (keine Domain angegeben)"
fi
else
info "nginx : Port $HTTP_PORT (nur HTTP)"
fi
info "Modus : $([[ $IS_UPDATE -eq 1 ]] && echo Aktualisierung || echo Neuinstallation)"
if [[ $ASSUME_YES -eq 0 ]]; then
read -r -p "Fortfahren? [j/N] " answer
[[ "$answer" =~ ^[jJyY]$ ]] || fail "abgebrochen"
fi
# -- Systempakete ------------------------------------------------------------
if [[ $SKIP_PACKAGES -eq 0 ]]; then
step "Systempakete"
PACKAGES=(python3 python3-venv python3-pip nginx sqlite3 rsync logrotate ca-certificates curl)
if [[ "$APP" == "tesm" ]]; then
PACKAGES+=(iputils-ping openssh-client cifs-utils)
fi
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq "${PACKAGES[@]}"
info "${#PACKAGES[@]} Pakete geprueft"
else
info "Systempakete uebersprungen"
fi
# -- Dienstbenutzer ----------------------------------------------------------
step "Dienstbenutzer"
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
useradd --system --home-dir "$ROOT" --shell /usr/sbin/nologin "$SERVICE_USER"
info "Benutzer $SERVICE_USER angelegt"
else
info "Benutzer $SERVICE_USER existiert"
fi
# Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte
# gibt es ausschliesslich ueber den Helfer.
# -- Backup ------------------------------------------------------------------
if [[ $IS_UPDATE -eq 1 ]]; then
step "Sicherung vor der Aktualisierung"
systemctl stop "$UNIT" 2>/dev/null || true
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
cp -a "$ROOT" "$BACKUP_DIR"
info "Sicherung: $BACKUP_DIR"
fi
# -- Dateien abgleichen ------------------------------------------------------
step "Anwendungsdateien"
mkdir -p "$ROOT"
rsync -a --delete \
--exclude 'venv' \
--exclude 'data' \
--exclude '*.pyc' \
--exclude '__pycache__' \
--exclude '.git' \
--exclude 'tests' \
"$REPO_DIR/" "$ROOT/"
mkdir -p "$ROOT/data" "$LOG_DIR"
info "Dateien abgeglichen (Instanzdaten unter data/ bleiben unangetastet)"
# -- venv --------------------------------------------------------------------
step "Python-Umgebung"
if [[ ! -x "$ROOT/venv/bin/python3" ]]; then
python3 -m venv "$ROOT/venv"
info "venv erzeugt"
fi
# Immer ueber "python3 -m pip" -- der pip-Shim im venv baeckt einen absoluten
# Pfad in seinen Shebang, der nach einer Umbenennung des Verzeichnisses bricht.
"$ROOT/venv/bin/python3" -m pip install --quiet --upgrade pip setuptools wheel
PAKETE=("$ROOT/packages/tesm-licensing" "$ROOT/packages/tesm-core")
# Die Serverhaelfte des Lizenzprotokolls -- ausstellen, erneuern, antworten --
# gehoert ausschliesslich zum Lizenzserver. Ein Client verifiziert Lizenzen und
# stellt Anfragen; den Code zum Ausstellen soll er nicht einmal mitbringen.
if [[ "$APP" == "tesm-license" ]]; then
[[ -d "$ROOT/packages/tesm-licensing-server" ]] ||
fail "packages/tesm-licensing-server fehlt -- ohne sie kann der Lizenzserver nichts ausstellen"
PAKETE+=("$ROOT/packages/tesm-licensing-server")
fi
"$ROOT/venv/bin/python3" -m pip install --quiet "${PAKETE[@]}" "$ROOT/apps/$APP" gunicorn
if [[ "$APP" == "tesm" ]]; then
"$ROOT/venv/bin/python3" -m pip install --quiet flask-sock
fi
info "Abhaengigkeiten installiert"
# -- Statische Dateien fuer nginx --------------------------------------------
step "Statische Dateien"
rm -rf "$ROOT/static" "$ROOT/static-core"
CORE_STATIC="$("$ROOT/venv/bin/python3" -c \
'import tesm_core, pathlib; print(pathlib.Path(tesm_core.__file__).parent / "static" / "tesm_core")')"
APP_MODULE="$APP"
[[ "$APP" == "tesm-license" ]] && APP_MODULE="tesm_license"
APP_STATIC="$("$ROOT/venv/bin/python3" -c \
"import $APP_MODULE, pathlib; print(pathlib.Path($APP_MODULE.__file__).parent / 'static')")"
cp -a "$CORE_STATIC" "$ROOT/static-core"
[[ -d "$APP_STATIC" ]] && cp -a "$APP_STATIC" "$ROOT/static" || mkdir -p "$ROOT/static"
info "nginx liefert /core-assets/ und /assets/ direkt aus"
# -- Privilegierter Helfer ---------------------------------------------------
step "Privilegierter Helfer und sudoers"
install -d -o root -g root -m 0755 /usr/local/lib/tesm
install_if_changed() {
local src="$1" dst="$2" mode="$3" owner="${4:-root:root}"
if [[ -f "$dst" ]] && cmp -s "$src" "$dst"; then
return 1
fi
install -o "${owner%%:*}" -g "${owner##*:}" -m "$mode" "$src" "$dst"
return 0
}
install_if_changed "$ROOT/deploy/tesm-helper" /usr/local/lib/tesm/tesm-helper 0755 && \
info "Helfer aktualisiert" || info "Helfer unveraendert"
# visudo -c prueft die Datei, bevor sie aktiv wird -- eine kaputte sudoers-Datei
# waere sonst ein Totalausfall der Administration.
TMP_SUDO="$(mktemp)"
cp "$ROOT/deploy/sudoers/$APP" "$TMP_SUDO"
visudo -c -f "$TMP_SUDO" >/dev/null || fail "sudoers-Datei ist fehlerhaft"
install -o root -g root -m 0440 "$TMP_SUDO" "/etc/sudoers.d/$APP"
rm -f "$TMP_SUDO"
info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen"
# -- Rechte ------------------------------------------------------------------
step "Dateirechte"
chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR"
chown -R root:root "$ROOT/packages" "$ROOT/apps" "$ROOT/deploy" 2>/dev/null || true
chmod 0750 "$ROOT/data"
chmod 0755 "$LOG_DIR"
find "$ROOT/data" -maxdepth 1 -name '*.key*' -o -name 'data.keys' -o -name '*signing_key*' \
| xargs -r chmod 0600
info "Datenverzeichnis 0750, Schluesseldateien 0600"
# -- Umgebungsdatei ----------------------------------------------------------
step "Umgebung"
# Eine Quelle fuer beide Seiten: die systemd-Unit liest sie ueber
# EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das
# wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere
# Datenbank im Arbeitsverzeichnis anlegen.
install -d -o root -g root -m 0755 /etc/tesm
ENV_FILE="/etc/tesm/$NAME.env"
TMP_ENV="$(mktemp)"
{
echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten."
echo "${ENV_PREFIX}BASE_DIR=$ROOT"
echo "${ENV_PREFIX}DATA_DIR=$ROOT/data"
echo "${ENV_PREFIX}LOG_DIR=$LOG_DIR"
# Name dieser Installation auf dem Host. Alles ausserhalb von /srv/<name>
# -- nginx-Site, netplan-Datei, logrotate-Regel, Zertifikatsverzeichnis,
# Sitzungscookie -- laeuft darueber. Ohne das wuerde eine Testinstanz beim
# Speichern der Webserver-Einstellungen die Site der Produktion
# ueberschreiben und beide teilten sich dasselbe Cookie.
echo "${ENV_PREFIX}SITE_KEY=$NAME"
# Genau die Adresse, auf die gunicorn hoert. nginx nimmt sie von hier als
# proxy_pass-Ziel -- so koennen Dienst und Reverse Proxy nicht auseinander
# laufen. Vorher stand die 5000 fest im Code: eine Instanz auf 5100 erzeugte
# damit eine Site, die auf die andere Installation zeigte.
echo "${ENV_PREFIX}BIND=127.0.0.1:$PORT"
echo "${ENV_PREFIX}COOKIE_SECURE=$COOKIE_SECURE"
echo "${ENV_PREFIX}TRUSTED_PROXY_HOPS=1"
echo "PYTHONUNBUFFERED=1"
} > "$TMP_ENV"
install_if_changed "$TMP_ENV" "$ENV_FILE" 0640 "root:$SERVICE_USER" >/dev/null && info "Umgebungsdatei $ENV_FILE geschrieben" || info "Umgebungsdatei unveraendert"
rm -f "$TMP_ENV"
if [[ "$COOKIE_SECURE" == "1" ]]; then
info "COOKIE_SECURE=1 -- die Anmeldung funktioniert damit nur ueber HTTPS."
else
info "Hinweis: COOKIE_SECURE=0 bis HTTPS eingerichtet ist. Danach: install.sh --https"
info " erneut aufrufen oder in $ENV_FILE auf 1 setzen und Dienst neu starten."
fi
# Wrapper, damit die Werkzeuge dieselbe Umgebung sehen wie der Dienst.
ADMIN_BIN="tesm-admin"
[[ "$APP" == "tesm-license" ]] && ADMIN_BIN="tesm-license-admin"
TMP_WRAPPER="$(mktemp)"
cat > "$TMP_WRAPPER" <<WRAPPER
#!/bin/sh
# Von install.sh erzeugt: laedt die Instanzumgebung und ruft das Werkzeug auf.
set -eu
set -a
. "$ENV_FILE"
set +a
exec "$ROOT/venv/bin/$ADMIN_BIN" "\$@"
WRAPPER
install_if_changed "$TMP_WRAPPER" "/usr/local/bin/$NAME-admin" 0755 >/dev/null && info "Werkzeug: $NAME-admin" || true
rm -f "$TMP_WRAPPER"
# -- systemd -----------------------------------------------------------------
step "systemd"
UNITS_CHANGED=0
render_unit() {
local src="$1" dst="$2"
local tmp; tmp="$(mktemp)"
sed -e "s#/srv/$APP#$ROOT#g" \
-e "s#/var/log/$APP#$LOG_DIR#g" \
-e "s#127.0.0.1:5000#127.0.0.1:$PORT#" \
-e "s#127.0.0.1:5001#127.0.0.1:$PORT#" \
-e "/^WorkingDirectory=/a EnvironmentFile=$ENV_FILE" \
"$src" > "$tmp"
install_if_changed "$tmp" "$dst" 0644 && UNITS_CHANGED=1
rm -f "$tmp"
}
render_unit "$ROOT/deploy/systemd/$APP.service" "/etc/systemd/system/$UNIT"
if [[ "$APP" == "tesm" ]]; then
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
fi
[[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \
|| info "Units unveraendert"
# -- logrotate ---------------------------------------------------------------
TMP_ROTATE="$(mktemp)"
sed -e "s#/var/log/$APP/#$LOG_DIR/#" "$ROOT/deploy/logrotate/$APP" > "$TMP_ROTATE"
install_if_changed "$TMP_ROTATE" "/etc/logrotate.d/$NAME" 0644 >/dev/null && info "logrotate aktualisiert"
rm -f "$TMP_ROTATE"
# -- nginx -------------------------------------------------------------------
step "nginx"
TMP_SITE="$(mktemp)"
sed -e "s#/srv/$APP/#$ROOT/#g" \
-e "s#127.0.0.1:5000#127.0.0.1:$PORT#g" \
-e "s#127.0.0.1:5001#127.0.0.1:$PORT#g" \
-e "s#listen 80;#listen $HTTP_PORT;#" \
-e "s#listen \[::\]:80;#listen [::]:$HTTP_PORT;#" \
-e "s#/var/www/$APP-acme#/var/www/$NAME-acme#g" \
"$ROOT/deploy/nginx/$APP.conf" > "$TMP_SITE"
# Auf Port 80 kann nur eine Site die Vorgabe sein. Eine Instanz neben der
# Produktion bekommt darum ueber --http-port einen eigenen Port -- sonst
# verdeckt sie die laufende Installation (nginx meldet dann
# "conflicting server name" und ignoriert eine der beiden).
if [[ -n "$INSTANCE" && "$HTTP_PORT" == "80" ]]; then
warn "Instanz $INSTANCE auf Port 80: sie verdeckt moeglicherweise die Hauptinstallation."
fi
# Inhaltsvergleich statt reiner Existenzpruefung -- genau der Punkt, an dem der
# Vorgaenger einen falschen alias-Pfad jahrelang mitschleppte. Die Anwendung
# schreibt diese Datei spaeter ohnehin selbst neu (Verwaltung -> Webserver);
# hier geht es nur um einen funktionierenden Erststart.
if [[ -f "$NGINX_SITE" ]] && ! cmp -s "$TMP_SITE" "$NGINX_SITE"; then
if grep -qE "alias\s+$ROOT/(static|static-core)/;" "$NGINX_SITE"; then
info "Vorhandene Site-Konfiguration behalten (alias-Pfade stimmen)"
else
warn "Vorhandene Site-Konfiguration zeigt auf falsche Pfade -- wird erneuert"
cp "$NGINX_SITE" "$NGINX_SITE.bak-$TIMESTAMP"
install -m 0644 "$TMP_SITE" "$NGINX_SITE"
fi
elif [[ ! -f "$NGINX_SITE" ]]; then
install -m 0644 "$TMP_SITE" "$NGINX_SITE"
info "Site-Konfiguration angelegt"
fi
TMP_SITE_KEEP="$TMP_SITE"
ln -sfn "$NGINX_SITE" "/etc/nginx/sites-enabled/$NAME"
rm -f /etc/nginx/sites-enabled/default
mkdir -p "/var/www/$NAME-acme"
chown -R www-data:www-data "/var/www/$NAME-acme"
# "alias-Pfade stimmen" heisst noch nicht "nginx akzeptiert die Datei". Eine
# von Hand nachgebesserte Site kann syntaktisch kaputt sein -- und dann
# scheitert nicht nur diese Installation, sondern jeder Reload des Webservers,
# auch fuer alle anderen Sites. Darum: pruefen, und im Zweifel die frisch
# erzeugte Vorlage nehmen.
if ! nginx -t >/dev/null 2>&1; then
warn "nginx lehnt die vorhandene Site-Konfiguration ab -- sie wird durch die Vorlage ersetzt."
[[ -f "$NGINX_SITE" ]] && cp "$NGINX_SITE" "$NGINX_SITE.bak-$TIMESTAMP"
install -m 0644 "$TMP_SITE_KEEP" "$NGINX_SITE"
nginx -t >/dev/null 2>&1 || fail "nginx lehnt auch die Vorlage ab -- bitte 'nginx -t' pruefen. Sicherung: $NGINX_SITE.bak-$TIMESTAMP"
fi
rm -f "$TMP_SITE_KEEP"
systemctl reload nginx
info "nginx neu geladen"
# -- Datenbank und Start -----------------------------------------------------
step "Anwendung starten"
# Das Schema entsteht beim ersten Start durch die Migrationen -- es gibt kein
# separates Init-Skript mehr.
systemctl enable --now "$UNIT" >/dev/null
if [[ "$APP" == "tesm" ]]; then
systemctl enable --now "$MONITOR_UNIT" >/dev/null
fi
# -- Health-Check und Rollback ----------------------------------------------
step "Gesundheitspruefung"
HEALTHY=0
for attempt in $(seq 1 20); do
if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then
HEALTHY=1
info "Antwort nach ${attempt}x"
break
fi
sleep 2
done
if [[ $HEALTHY -eq 0 ]]; then
warn "Die Anwendung antwortet nicht."
if [[ $IS_UPDATE -eq 1 ]]; then
warn "Rollback wird ausgefuehrt."
systemctl stop "$UNIT" 2>/dev/null || true
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
mv "$ROOT" "$FAILED_DIR"
cp -a "$BACKUP_DIR" "$ROOT"
systemctl start "$UNIT" || true
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" || true
fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt.
Fehlgeschlagene Version : $FAILED_DIR
Sicherung : $BACKUP_DIR
Protokoll : journalctl -u $UNIT -n 100"
fi
fail "Neuinstallation fehlgeschlagen. Protokoll: journalctl -u $UNIT -n 100"
fi
URL_HINT="http://<host>:$HTTP_PORT/"
[[ "$HTTP_PORT" == "80" ]] && URL_HINT="http://<host>/"
# HTTPS war schon eingerichtet und wird durch dieses Update nicht angefasst.
[[ $HTTPS -eq 0 && "$COOKIE_SECURE" == "1" ]] && URL_HINT="https://<host>/ (unveraendert)"
# -- HTTPS -------------------------------------------------------------------
# Bewusst erst hier: die Anwendung muss laufen, denn sie erzeugt die
# TLS-Konfiguration selbst (tesm-admin web-setup). Das Installationsskript
# baut sich keine zweite, konkurrierende Vorlage -- genau daran ist der
# Vorgaenger gescheitert.
if [[ $HTTPS -eq 1 ]]; then
step "HTTPS einrichten"
WEB_ARGS=(web-setup --https --apply --self-signed
--http-port "$HTTP_PORT" --https-port "$HTTPS_PORT")
if [[ -n "$DOMAIN" ]]; then
# HSTS nur mit einem Zertifikat, dem Browser auch trauen. Bei einem
# selbstsignierten waere es eine Falle: der Browser merkt sich fuer Monate,
# dass er die Seite nur ueber HTTPS aufrufen darf -- und zeigt bei jedem
# Aufruf eine Warnung, die sich nicht mehr wegklicken laesst.
WEB_ARGS+=(--domain "$DOMAIN")
if [[ -n "$ACME_EMAIL" ]]; then
# Let's Encrypt verlangt ohnehin eine Kontaktadresse. Sie ist damit das
# eindeutige Signal "hol ein oeffentliches Zertifikat" -- ohne sie bleibt
# es bei einem selbstsignierten, auch wenn eine Domain gesetzt ist
# (interne Namen wie lizenz.firma.local kann keine CA bestaetigen).
WEB_ARGS+=(--acme --acme-email "$ACME_EMAIL" --hsts)
info "Versuche Lets Encrypt fuer $DOMAIN; bei Misserfolg selbstsigniert."
else
info "Domain $DOMAIN, aber keine --acme-email: selbstsigniert, ohne HSTS."
fi
else
info "Ohne Domain: selbstsigniertes Zertifikat, ohne HSTS."
fi
if sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" "${WEB_ARGS[@]}"; then
info "HTTPS aktiv."
URL_HINT="https://<host>/"
[[ "$HTTPS_PORT" == "443" ]] || URL_HINT="https://<host>:$HTTPS_PORT/"
else
warn "HTTPS-Einrichtung fehlgeschlagen -- die Installation laeuft weiter ueber HTTP."
warn "Nachtraeglich: sudo -u $SERVICE_USER $NAME-admin web-setup --https --self-signed --hsts --apply"
# Ein Secure-Cookie ohne HTTPS macht die Anmeldung unmoeglich. Lieber
# zurueckdrehen als eine Installation hinterlassen, an der niemand mehr
# ankommt.
if grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "$ENV_FILE"; then
sed -i "s/^${ENV_PREFIX}COOKIE_SECURE=1/${ENV_PREFIX}COOKIE_SECURE=0/" "$ENV_FILE"
systemctl restart "$UNIT"
warn "COOKIE_SECURE auf 0 zurueckgesetzt, damit die Anmeldung weiter funktioniert."
fi
fi
fi
if [[ $IS_UPDATE -eq 1 ]]; then
rm -rf "$BACKUP_DIR"
info "Sicherung entfernt (Aktualisierung erfolgreich)"
fi
# -- Abschluss ---------------------------------------------------------------
step "Fertig"
ADMIN_CMD="tesm-admin"
[[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin"
cat <<SUMMARY
Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}
Verzeichnis : $ROOT
Protokolle : $LOG_DIR
Dienst : systemctl status $UNIT
Erreichbar : $URL_HINT
Naechste Schritte:
SUMMARY
if [[ "$APP" == "tesm-license" ]]; then
cat <<SUMMARY
1. Signaturschluessel erzeugen und sofort sichern:
sudo -u $SERVICE_USER $NAME-admin init-key
2. Eigene Master-Lizenz ausstellen:
sudo -u $SERVICE_USER $NAME-admin bootstrap
3. Administrator anlegen:
sudo -u $SERVICE_USER $NAME-admin create-admin
SUMMARY
else
cat <<SUMMARY
1. Administrator anlegen:
sudo -u $SERVICE_USER $NAME-admin create-admin
2. Lizenzdatei einspielen unter Verwaltung -> Lizenz
SUMMARY
fi
cat <<'SUMMARY'
Danach in der Anwendung unter "Verwaltung -> Webserver und TLS" die Domain
eintragen, ein Zertifikat anfordern und die Konfiguration uebernehmen --
damit laeuft der Zugriff ueber HTTPS auf Port 443 mit Umleitung von 80.
SUMMARY
+14
View File
@@ -0,0 +1,14 @@
# Von TESM installiert.
# copytruncate, weil keiner der schreibenden Prozesse (Web, Ueberwachung, Kea)
# ein Reopen-Signal verarbeitet.
/var/log/tesm/*.log {
weekly
rotate 4
compress
delaycompress
missingok
notifempty
copytruncate
su tesm tesm
create 0640 tesm tesm
}
+14
View File
@@ -0,0 +1,14 @@
# Von TESM installiert.
# copytruncate, weil keiner der schreibenden Prozesse (Web, Ueberwachung, Kea)
# ein Reopen-Signal verarbeitet.
/var/log/tesm-license/*.log {
weekly
rotate 4
compress
delaycompress
missingok
notifempty
copytruncate
su tesm-license tesm-license
create 0640 tesm-license tesm-license
}
+43
View File
@@ -0,0 +1,43 @@
# Erststart-Konfiguration. Ab der ersten Uebernahme in der Anwendung
# (Verwaltung -> Webserver und TLS) wird diese Datei vollstaendig neu
# geschrieben -- inklusive HTTPS auf 443, Umleitung von 80 und HSTS.
server {
listen 80;
listen [::]:80;
server_name _;
location /.well-known/acme-challenge/ {
alias /var/www/tesm-license-acme/.well-known/acme-challenge/;
default_type "text/plain";
}
client_max_body_size 160m;
location /assets/ {
alias /srv/tesm-license/static/;
expires 7d;
access_log off;
}
location /core-assets/ {
alias /srv/tesm-license/static-core/;
expires 7d;
access_log off;
}
location / {
# $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
# abweichendem Port wuerde die Origin-Pruefung der Anwendung sonst
# fehlschlagen.
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:5001;
proxy_http_version 1.1;
proxy_read_timeout 120s;
}
}
+61
View File
@@ -0,0 +1,61 @@
# Erststart-Konfiguration. Ab der ersten Uebernahme in der Anwendung
# (Verwaltung -> Webserver und TLS) wird diese Datei vollstaendig neu
# geschrieben -- inklusive HTTPS auf 443, Umleitung von 80 und HSTS.
server {
listen 80;
listen [::]:80;
server_name _;
location /.well-known/acme-challenge/ {
alias /var/www/tesm-acme/.well-known/acme-challenge/;
default_type "text/plain";
}
client_max_body_size 160m;
location /assets/ {
alias /srv/tesm/static/;
expires 7d;
access_log off;
}
location /core-assets/ {
alias /srv/tesm/static-core/;
expires 7d;
access_log off;
}
location /ws/ {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
# abweichendem Port wuerde die Origin-Pruefung der Anwendung sonst
# fehlschlagen.
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
location / {
# $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
# abweichendem Port wuerde die Origin-Pruefung der Anwendung sonst
# fehlschlagen.
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_read_timeout 120s;
}
}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Paketiert ein Release aus einem vorhandenen Tag.
#
# Auf Windows-Buildmaschinen ist -c core.autocrlf=false zwingend -- sonst landen
# CRLF-Zeilenenden im Tarball und die Shell-Skripte scheitern auf dem Ziel mit
# "bad interpreter". Das ist real passiert.
#
# Der Name kommt aus dem Repository, nicht aus einer festen Zeichenkette: die
# beiden Anwendungen liegen in getrennten Repositorien, und ein
# Lizenzserver-Release, das "tesm-..." heisst, verwechselt man genau einmal.
set -euo pipefail
TAG="${1:?Verwendung: release.sh vX.Y.Z [name]}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# Vorgabe: der Name der einzigen Anwendung im Baum, sonst der Verzeichnisname.
if [[ -n "${2:-}" ]]; then
NAME="$2"
else
mapfile -t APPS < <(ls -1 "$REPO_DIR/apps" 2>/dev/null || true)
if [[ ${#APPS[@]} -eq 1 ]]; then NAME="${APPS[0]}"; else NAME="$(basename "$REPO_DIR")"; fi
fi
OUT="$NAME-$TAG.tar.gz"
git -c core.autocrlf=false -C "$REPO_DIR" archive --format=tar.gz --prefix="$NAME/" -o "$OUT" "$TAG"
echo "Erzeugt: $OUT"
file "$OUT"
echo "Pruefsumme:"; sha256sum "$OUT"
+9
View File
@@ -0,0 +1,9 @@
# Von TESM installiert. Der Dienstbenutzer darf genau ein Programm als root
# aufrufen -- den Helfer. Dessen Argumentpruefung ist die eigentliche
# Sicherheitsgrenze; sie liegt in /usr/local/lib/tesm/tesm-helper und gehoert
# root (Modus 0755).
#
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
# Problem, indem die gesamte Anwendung als root lief.
Defaults:tesm !requiretty
tesm ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
+9
View File
@@ -0,0 +1,9 @@
# Von TESM installiert. Der Dienstbenutzer darf genau ein Programm als root
# aufrufen -- den Helfer. Dessen Argumentpruefung ist die eigentliche
# Sicherheitsgrenze; sie liegt in /usr/local/lib/tesm/tesm-helper und gehoert
# root (Modus 0755).
#
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
# Problem, indem die gesamte Anwendung als root lief.
Defaults:tesm-license !requiretty
tesm-license ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
+78
View File
@@ -0,0 +1,78 @@
[Unit]
Description=TESM-Lizenzserver
Documentation=file:/srv/tesm-license/docs/BETRIEB.md
After=network-online.target
Wants=network-online.target
[Service]
Type=notify-reload
User=tesm-license
Group=tesm-license
WorkingDirectory=/srv/tesm-license
Environment=TESM_LICENSE_BASE_DIR=/srv/tesm-license
Environment=TESM_LICENSE_DATA_DIR=/srv/tesm-license/data
Environment=TESM_LICENSE_LOG_DIR=/var/log/tesm-license
Environment=TESM_LICENSE_WEB_PROCESS=1
Environment=TESM_LICENSE_COOKIE_SECURE=1
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/tesm-license/venv/bin/gunicorn \
--workers 1 --worker-class gthread --threads 8 \
--bind 127.0.0.1:5001 \
--access-logfile - --error-logfile - \
tesm_license.wsgi:app
Restart=always
RestartSec=5
# -- Haertung ---------------------------------------------------------------
# Der Dienst braucht kein Root, keine neuen Privilegien und keinen Zugriff auf
# das System ausserhalb seiner eigenen Verzeichnisse. Was er trotzdem braucht
# (systemctl, nginx, netplan), laeuft ueber den Helfer und sudo.
# Bewusst **kein** NoNewPrivileges: der Dienst ruft den privilegierten Helfer
# ueber sudo auf, und sudo ist setuid. Unter NoNewPrivileges verweigert der
# Kernel die Rechteerhoehung, sudo bricht ab -- und damit jede Systemaktion aus
# der Oberflaeche. Die Sicherheitsgrenze liegt nicht hier, sondern in der
# sudoers-Regel (genau ein erlaubter Pfad) und im Helfer selbst (jedes Argument
# gegen eine Positivliste). Siehe docs/SICHERHEIT.md.
PrivateTmp=yes
PrivateDevices=yes
# ProtectSystem=full statt strict: "strict" sperrt den **ganzen Prozessbaum**
# schreibgeschuetzt -- auch den Helfer, der als root gerade /etc/nginx,
# /etc/netplan und /etc/kea schreiben soll. Gemessen: unter "strict" scheitert
# schon "nginx -t". Mit "full" bleiben /usr und /boot schreibgeschuetzt, /etc
# ist im Namensraum wieder erreichbar -- der unprivilegierte Dienst kommt
# trotzdem nicht heran, denn die Dateien gehoeren root. Die Grenze ist damit
# die gewoehnliche Dateiberechtigung plus die sudoers-Regel, nicht dieser
# Schalter.
ProtectSystem=full
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictSUIDSGID=yes
RestrictRealtime=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
LockPersonality=yes
MemoryDenyWriteExecute=no
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
# kann auch der Helfer nicht mehr erlangen -- gemessen: jede Einschraenkung
# fuehrt zu "sudo: unable to change to root gid". Die Grenze ist die
# sudoers-Regel und die Argumentpruefung im Helfer, nicht dieser Schalter.
UMask=0027
# Genau die Pfade, die der privilegierte Helfer schreibt -- nicht mehr.
# ProtectSystem=full sperrt /usr, /boot **und /etc**; ohne diese Freigaben
# scheitert der Helfer mit "Read-only file system", obwohl er root ist
# (gemessen). Der unprivilegierte Dienst kommt hier trotzdem nicht heran: die
# Dateien gehoeren root, und sudo laesst nur den einen Helferpfad zu.
ReadWritePaths=/srv/tesm-license/data /var/log/tesm-license /etc/nginx /etc/netplan /etc/logrotate.d /etc/tesm /run
[Install]
WantedBy=multi-user.target
+62
View File
@@ -0,0 +1,62 @@
[Unit]
Description=TESM -- Ueberwachung der PoE-Endgeraete
After=network-online.target tesm.service
Wants=network-online.target
[Service]
Type=simple
User=tesm
Group=tesm
WorkingDirectory=/srv/tesm
Environment=TESM_BASE_DIR=/srv/tesm
Environment=TESM_DATA_DIR=/srv/tesm/data
Environment=TESM_LOG_DIR=/var/log/tesm
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/tesm/venv/bin/tesm-monitor
Restart=always
RestartSec=10
# Das Pruefintervall liest der Prozess bei jedem Durchlauf frisch aus der
# Datenbank -- der naechtliche Neustart-Timer des Vorgaengers entfaellt damit.
# Bewusst **kein** NoNewPrivileges: der Dienst ruft den privilegierten Helfer
# ueber sudo auf, und sudo ist setuid. Unter NoNewPrivileges verweigert der
# Kernel die Rechteerhoehung, sudo bricht ab -- und damit jede Systemaktion aus
# der Oberflaeche. Die Sicherheitsgrenze liegt nicht hier, sondern in der
# sudoers-Regel (genau ein erlaubter Pfad) und im Helfer selbst (jedes Argument
# gegen eine Positivliste). Siehe docs/SICHERHEIT.md.
PrivateTmp=yes
# ProtectSystem=full statt strict: "strict" sperrt den **ganzen Prozessbaum**
# schreibgeschuetzt -- auch den Helfer, der als root gerade /etc/nginx,
# /etc/netplan und /etc/kea schreiben soll. Gemessen: unter "strict" scheitert
# schon "nginx -t". Mit "full" bleiben /usr und /boot schreibgeschuetzt, /etc
# ist im Namensraum wieder erreichbar -- der unprivilegierte Dienst kommt
# trotzdem nicht heran, denn die Dateien gehoeren root. Die Grenze ist damit
# die gewoehnliche Dateiberechtigung plus die sudoers-Regel, nicht dieser
# Schalter.
ProtectSystem=full
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
LockPersonality=yes
SystemCallArchitectures=native
UMask=0027
# Genau die Pfade, die der privilegierte Helfer schreibt -- nicht mehr.
# ProtectSystem=full sperrt /usr, /boot **und /etc**; ohne diese Freigaben
# scheitert der Helfer mit "Read-only file system", obwohl er root ist
# (gemessen). Der unprivilegierte Dienst kommt hier trotzdem nicht heran: die
# Dateien gehoeren root, und sudo laesst nur den einen Helferpfad zu.
ReadWritePaths=/srv/tesm/data /var/log/tesm /etc/tesm /run
# ping braucht CAP_NET_RAW -- als einzige Ausnahme, und nur diese.
AmbientCapabilities=CAP_NET_RAW
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
# kann auch der Helfer nicht mehr erlangen -- gemessen: jede Einschraenkung
# fuehrt zu "sudo: unable to change to root gid". Die Grenze ist die
# sudoers-Regel und die Argumentpruefung im Helfer, nicht dieser Schalter.
[Install]
WantedBy=multi-user.target
+78
View File
@@ -0,0 +1,78 @@
[Unit]
Description=TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten
Documentation=file:/srv/tesm/docs/BETRIEB.md
After=network-online.target
Wants=network-online.target
[Service]
Type=notify-reload
User=tesm
Group=tesm
WorkingDirectory=/srv/tesm
Environment=TESM_BASE_DIR=/srv/tesm
Environment=TESM_DATA_DIR=/srv/tesm/data
Environment=TESM_LOG_DIR=/var/log/tesm
Environment=TESM_WEB_PROCESS=1
Environment=TESM_COOKIE_SECURE=1
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/tesm/venv/bin/gunicorn \
--workers 1 --worker-class gthread --threads 16 --timeout 0 \
--bind 127.0.0.1:5000 \
--access-logfile - --error-logfile - \
tesm.wsgi:app
Restart=always
RestartSec=5
# -- Haertung ---------------------------------------------------------------
# Der Dienst braucht kein Root, keine neuen Privilegien und keinen Zugriff auf
# das System ausserhalb seiner eigenen Verzeichnisse. Was er trotzdem braucht
# (systemctl, nginx, netplan), laeuft ueber den Helfer und sudo.
# Bewusst **kein** NoNewPrivileges: der Dienst ruft den privilegierten Helfer
# ueber sudo auf, und sudo ist setuid. Unter NoNewPrivileges verweigert der
# Kernel die Rechteerhoehung, sudo bricht ab -- und damit jede Systemaktion aus
# der Oberflaeche. Die Sicherheitsgrenze liegt nicht hier, sondern in der
# sudoers-Regel (genau ein erlaubter Pfad) und im Helfer selbst (jedes Argument
# gegen eine Positivliste). Siehe docs/SICHERHEIT.md.
PrivateTmp=yes
PrivateDevices=yes
# ProtectSystem=full statt strict: "strict" sperrt den **ganzen Prozessbaum**
# schreibgeschuetzt -- auch den Helfer, der als root gerade /etc/nginx,
# /etc/netplan und /etc/kea schreiben soll. Gemessen: unter "strict" scheitert
# schon "nginx -t". Mit "full" bleiben /usr und /boot schreibgeschuetzt, /etc
# ist im Namensraum wieder erreichbar -- der unprivilegierte Dienst kommt
# trotzdem nicht heran, denn die Dateien gehoeren root. Die Grenze ist damit
# die gewoehnliche Dateiberechtigung plus die sudoers-Regel, nicht dieser
# Schalter.
ProtectSystem=full
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictSUIDSGID=yes
RestrictRealtime=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
LockPersonality=yes
MemoryDenyWriteExecute=no
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
# kann auch der Helfer nicht mehr erlangen -- gemessen: jede Einschraenkung
# fuehrt zu "sudo: unable to change to root gid". Die Grenze ist die
# sudoers-Regel und die Argumentpruefung im Helfer, nicht dieser Schalter.
UMask=0027
# Genau die Pfade, die der privilegierte Helfer schreibt -- nicht mehr.
# ProtectSystem=full sperrt /usr, /boot **und /etc**; ohne diese Freigaben
# scheitert der Helfer mit "Read-only file system", obwohl er root ist
# (gemessen). Der unprivilegierte Dienst kommt hier trotzdem nicht heran: die
# Dateien gehoeren root, und sudo laesst nur den einen Helferpfad zu.
ReadWritePaths=/srv/tesm/data /var/log/tesm /mnt/tesm-shares /etc/nginx /etc/netplan /etc/kea /etc/logrotate.d /etc/tesm /var/lib/kea /var/log/kea /run
[Install]
WantedBy=multi-user.target
+351
View File
@@ -0,0 +1,351 @@
#!/bin/bash
# =============================================================================
# tesm-helper -- die einzige Stelle, an der TESM Root-Rechte bekommt.
#
# Der Webprozess laeuft unprivilegiert. Alles, was Root braucht, geht durch
# dieses Skript: es gehoert root, ist per sudoers mit NOPASSWD nur fuer genau
# diesen Pfad freigegeben und prueft jedes Argument gegen eine Positivliste.
#
# Damit ist die Sicherheitsgrenze klein, an einer Stelle und pruefbar -- statt
# ueber tausende Zeilen Anwendungscode verteilt. Der Vorgaenger liess die
# komplette Flask-Anwendung als root laufen; eine einzige Luecke in irgendeinem
# Pfad haette den Host bedeutet.
#
# Regeln fuer Aenderungen an dieser Datei:
# * Jedes neue Verb braucht eine Argumentpruefung. Ohne Pruefung kein Verb.
# * Nie "$@" ungeprueft an eine Shell weiterreichen.
# * Nie einen Pfad akzeptieren, der nicht gegen eine feste Liste geprueft ist.
# =============================================================================
set -euo pipefail
umask 022
APP_KEYS_ALLOWED=("tesm" "tesm-license")
SERVICES_ALLOWED=(
"tesm.service" "tesm-monitor.service"
"tesm-license.service"
"kea-dhcp4-server" "kea-dhcp4-server.service"
"nginx" "nginx.service"
)
PACKAGES_ALLOWED=("kea-dhcp4-server" "kea-common" "cifs-utils" "certbot" "expect"
"samba-common-bin")
fail() { echo "tesm-helper: $*" >&2; exit 2; }
require_root() {
[[ "$(id -u)" -eq 0 ]] || fail "muss als root laufen (ueber sudo aufrufen)"
}
# -- Argumentpruefungen ------------------------------------------------------
check_in_list() {
local needle="$1"; shift
local item
for item in "$@"; do
[[ "$needle" == "$item" ]] && return 0
done
return 1
}
# Name einer Installation auf diesem Host: entweder die Hauptinstallation
# ("tesm") oder eine benannte Instanz daneben ("tesm-opus"). Mehr als diese
# beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen
# Pfad ausserhalb des vorgesehenen Namensraums erzeugen.
check_app_key() {
local key="$1" app
for app in "${APP_KEYS_ALLOWED[@]}"; do
[[ "$key" == "$app" ]] && return 0
[[ "$key" == "$app-"* && "${key#"$app"-}" =~ ^[a-z0-9][a-z0-9-]{0,15}$ ]] && return 0
done
fail "unbekannter App-Schluessel: $key"
}
check_service() {
check_in_list "$1" "${SERVICES_ALLOWED[@]}" || fail "Dienst nicht erlaubt: $1"
}
check_hostname() {
[[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]] || fail "ungueltiger Hostname"
}
check_timezone() {
[[ "$1" =~ ^[A-Za-z]+(/[A-Za-z0-9_+-]+){0,2}$ ]] || fail "ungueltige Zeitzone"
[[ -f "/usr/share/zoneinfo/$1" ]] || fail "unbekannte Zeitzone"
}
check_domain() {
[[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] || fail "ungueltige Domain"
}
check_email() {
[[ "$1" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]] || fail "ungueltige E-Mail"
}
check_unc() {
[[ "$1" =~ ^//[A-Za-z0-9._-]+/[^[:space:]]+$ || "$1" =~ ^\\\\[A-Za-z0-9._-]+\\[^[:space:]]+$ ]] \
|| fail "ungueltiger Freigabepfad"
}
check_mount_target() {
# Nur unterhalb der bekannten Einbindungswurzeln, kein ".." im Pfad.
[[ "$1" == /mnt/tesm-shares/* ]] || fail "Einbindungsziel liegt ausserhalb von /mnt/tesm-shares"
[[ "$1" != *".."* ]] || fail "unzulaessiger Pfad"
}
check_readable_path() {
# Positivliste der Dateien, die die Anwendung lesen darf, obwohl sie ihr
# nicht gehoeren. Ohne die ".."-Sperre koennte ein Muster wie
# /etc/nginx/sites-available/../../../root/tesm die Liste unterlaufen: der
# Basisname waere "tesm" und damit gueltig, gelesen wuerde aber /root/tesm.
[[ "$1" != *".."* ]] || fail "unzulaessiger Pfad"
case "$1" in
/var/log/kea/kea-dhcp4.log) ;;
/var/lib/kea/kea-leases4.csv) ;;
/etc/netplan/90-*.yaml)
check_app_key "$(basename "$1" .yaml | sed 's/^90-//')" ;;
/etc/nginx/sites-available/*)
check_app_key "$(basename "$1")" ;;
*) fail "Datei nicht in der Leseliste: $1" ;;
esac
}
read_stdin_limited() {
# Maximal 1 MiB aus stdin -- verhindert, dass ein Fehler die Platte fuellt.
head -c 1048576
}
write_atomic() {
local target="$1" mode="$2" owner="$3"
local tmp
tmp="$(mktemp "${target}.XXXXXX")"
read_stdin_limited > "$tmp"
chmod "$mode" "$tmp"
[[ -n "$owner" ]] && chown "$owner" "$tmp"
mv -f "$tmp" "$target"
}
# -- Verben ------------------------------------------------------------------
verb="${1:-}"; shift || true
require_root
case "$verb" in
service-status)
check_service "${1:?Dienst fehlt}"
systemctl is-active "$1" || true
systemctl is-enabled "$1" || true
;;
service-start|service-stop|service-restart|service-enable|service-disable)
check_service "${1:?Dienst fehlt}"
systemctl "${verb#service-}" "$1"
;;
nginx-test)
nginx -t
;;
nginx-reload)
nginx -t && systemctl reload nginx
;;
nginx-write)
check_app_key "${1:?App-Schluessel fehlt}"
target="/etc/nginx/sites-available/$1"
write_atomic "$target" 0644 "root:root"
ln -sfn "$target" "/etc/nginx/sites-enabled/$1"
rm -f /etc/nginx/sites-enabled/default
;;
nginx-apply)
# Schreiben, pruefen, neu laden -- als *eine* Operation, mit Ruecknahme.
#
# Getrennte Verben waren hier ein echter Fehler: "nginx-write" verlinkt die
# Datei sofort nach sites-enabled. Schlug das anschliessende "nginx -t"
# fehl, blieb eine kaputte Konfiguration aktiviert liegen. Der laufende
# nginx merkte davon nichts -- aber der naechste Reload oder Neustart durch
# irgendwen (auch fuer eine voellig andere Site) schlug fehl. Aus einem
# abgelehnten Formular wurde so ein Ausfall des ganzen Webservers.
check_app_key "${1:?App-Schluessel fehlt}"
target="/etc/nginx/sites-available/$1"
link="/etc/nginx/sites-enabled/$1"
backup="$(mktemp /run/tesm-nginx.XXXXXX)"
had_file=0; had_link=0
[[ -f "$target" ]] && { cp -a "$target" "$backup"; had_file=1; }
[[ -L "$link" || -e "$link" ]] && had_link=1
write_atomic "$target" 0644 "root:root"
ln -sfn "$target" "$link"
if nginx -t 2>&1; then
rm -f /etc/nginx/sites-enabled/default
systemctl reload nginx
rm -f "$backup"
echo "uebernommen"
else
# Genau in den Zustand zurueck, der vorher galt.
if [[ $had_file -eq 1 ]]; then
cp -a "$backup" "$target"
else
rm -f "$target"
fi
[[ $had_link -eq 1 ]] || rm -f "$link"
rm -f "$backup"
# Nach der Ruecknahme muss die Konfiguration wieder sauber sein. Ist sie
# es nicht, war sie schon vorher kaputt -- das gehoert ins Protokoll.
nginx -t >/dev/null 2>&1 || echo "Warnung: nginx-Konfiguration war schon vorher fehlerhaft." >&2
fail "nginx hat die Konfiguration abgelehnt -- alter Stand wiederhergestellt"
fi
;;
netplan-write)
check_app_key "${1:?App-Schluessel fehlt}"
# Eigene Datei je Anwendung: der Vorgaenger schrieb aus beiden Apps nach
# /etc/netplan/90-tesm.yaml und ueberschrieb sich damit gegenseitig.
write_atomic "/etc/netplan/90-$1.yaml" 0600 "root:root"
;;
netplan-apply)
netplan apply
;;
hostname-set)
check_hostname "${1:?Hostname fehlt}"
hostnamectl set-hostname "$1"
;;
timezone-set)
check_timezone "${1:?Zeitzone fehlt}"
timedatectl set-timezone "$1"
;;
kea-write)
install -d -o root -g root -m 0755 /etc/kea
# Kea laeuft als eigener, unprivilegierter Benutzer und muss seine
# Lease-Datei und sein Protokoll schreiben koennen. Vor dem Test anlegen,
# sonst beanstandet Kea die fehlenden Verzeichnisse.
install -d -o _kea -g _kea -m 0750 /var/lib/kea /var/log/kea 2>/dev/null || true
# Erst pruefen, dann uebernehmen. Vorher wurde jede Konfiguration
# ungeprueft abgelegt -- eine fehlerhafte fiel erst beim naechsten Start
# auf, und dann lag der Dienst, bis jemand ins Journal sah.
tmp="$(mktemp /etc/kea/kea-dhcp4.conf.XXXXXX)"
chmod 0640 "$tmp"; chown root:_kea "$tmp"
read_stdin_limited > "$tmp"
if command -v kea-dhcp4 >/dev/null 2>&1; then
if ! pruefung="$(kea-dhcp4 -t "$tmp" 2>&1)"; then
rm -f "$tmp"
# Nur die aussagekraeftige Zeile zurueckgeben, nicht das ganze Protokoll.
fail "Konfiguration abgelehnt: $(printf '%s' "$pruefung" | grep -m1 -i "error" || printf '%s' "$pruefung" | tail -1)"
fi
fi
mv -f "$tmp" /etc/kea/kea-dhcp4.conf
;;
logrotate-write)
check_app_key "${1:?App-Schluessel fehlt}"
write_atomic "/etc/logrotate.d/$1" 0644 "root:root"
;;
mount-cifs)
check_unc "${1:?Freigabe fehlt}"
check_mount_target "${2:?Ziel fehlt}"
share="${1//\\//}"
target="$2"
creds="$(mktemp /run/tesm-cifs.XXXXXX)"
chmod 600 "$creds"
# Zugangsdaten kommen ueber stdin in eine 0600-Datei -- nie ueber die
# Kommandozeile (Prozessliste) und nie ueber die Umgebung
# (/proc/<pid>/environ).
read_stdin_limited > "$creds"
mkdir -p "$target"
if mount -t cifs "$share" "$target" \
-o "credentials=$creds,uid=$(id -u tesm 2>/dev/null || echo 0),gid=$(id -g tesm 2>/dev/null || echo 0),file_mode=0640,dir_mode=0750,vers=3.1.1,seal"
then
shred -u "$creds" 2>/dev/null || rm -f "$creds"
else
shred -u "$creds" 2>/dev/null || rm -f "$creds"
fail "Einbindung fehlgeschlagen"
fi
;;
umount)
check_mount_target "${1:?Ziel fehlt}"
umount -l "$1" || true
rmdir "$1" 2>/dev/null || true
;;
apt-install)
[[ $# -gt 0 ]] || fail "kein Paket angegeben"
for package in "$@"; do
check_in_list "$package" "${PACKAGES_ALLOWED[@]}" || fail "Paket nicht erlaubt: $package"
done
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq "$@"
;;
certbot-issue)
check_domain "${1:?Domain fehlt}"
check_email "${2:?E-Mail fehlt}"
webroot="${3:?Webroot fehlt}"
[[ "$webroot" == /var/www/* && "$webroot" != *".."* ]] || fail "ungueltiger Webroot"
mkdir -p "$webroot"
# Bewusst --webroot statt des nginx-Plugins: das Plugin wuerde die von der
# Anwendung erzeugte Site-Datei selbst umschreiben.
certbot certonly --webroot -w "$webroot" -d "$1" \
--email "$2" --agree-tos --non-interactive --keep-until-expiring
;;
selfsigned-issue)
# Fuer Installationen ohne oeffentliche Domain: ein selbstsigniertes
# Zertifikat, damit Port 443 ueberhaupt bedient werden kann. Bewusst
# getrennt vom certbot-Pfad -- hier wird nichts nach draussen gesprochen.
check_app_key "${1:?App-Schluessel fehlt}"
subject="${2:?Common Name fehlt}"
check_domain "$subject"
dir="/etc/tesm/certs/$1"
install -d -m 0755 /etc/tesm/certs
# 0755 auf dem Verzeichnis, nicht 0750: die Anwendung muss das Zertifikat
# lesen koennen, um Aussteller und Ablauf anzuzeigen. Geheim ist nur der
# private Schluessel -- der bleibt 0640 root:root und wird ausschliesslich
# vom nginx-Masterprozess (root) gelesen. Dieselbe Aufteilung wie bei
# Let's Encrypt: live/ ist begehbar, privkey.pem nicht lesbar.
install -d -m 0755 "$dir"
# Alternative Namen: alles, was ab Argument 3 kommt -- jeweils geprueft und
# nach DNS- bzw. IP-Eintrag sortiert. Kein Argument geht ungeprueft weiter.
alt="DNS:$subject"
shift 2
for name in "$@"; do
if [[ "$name" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
alt="$alt,IP:$name"
elif [[ "$name" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]]; then
[[ "$name" == "$subject" ]] || alt="$alt,DNS:$name"
else
fail "ungueltiger alternativer Name: $name"
fi
done
openssl req -x509 -newkey rsa:3072 -sha256 -days 825 -nodes -keyout "$dir/privkey.pem" -out "$dir/fullchain.pem" -subj "/CN=$subject" -addext "subjectAltName=$alt" -addext "basicConstraints=critical,CA:FALSE" -addext "keyUsage=critical,digitalSignature,keyEncipherment" -addext "extendedKeyUsage=serverAuth" >/dev/null 2>&1 || fail "openssl ist fehlgeschlagen"
chown root:root "$dir/privkey.pem" "$dir/fullchain.pem"
chmod 0640 "$dir/privkey.pem"
# Das Zertifikat selbst ist oeffentlich -- es geht bei jedem
# TLS-Handschlag an jeden Client.
chmod 0644 "$dir/fullchain.pem"
# nginx laeuft als root und liest die Datei selbst -- die Anwendung braucht
# den privaten Schluessel nie. Nur der Pfad wird zurueckgegeben.
echo "$dir/fullchain.pem"
echo "$dir/privkey.pem"
;;
file-read)
check_readable_path "${1:?Pfad fehlt}"
[[ -f "$1" ]] || exit 0
cat "$1"
;;
*)
fail "unbekanntes Verb: ${verb:-<leer>}"
;;
esac
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# =============================================================================
# Bootstrapper: laedt ein Release herunter und ruft dessen install.sh auf.
# Enthaelt selbst keinerlei Installationslogik.
#
# Zwei Fallen des Vorgaengers sind hier bewusst vermieden:
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Assets.
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert -- faengt
# denselben Fehler ein zweites Mal ab.
# =============================================================================
set -euo pipefail
APP="${1:-}"
TAG="${2:-latest}"
BASE="${TESM_RELEASE_BASE:-https://gitea.example.invalid}"
OWNER="${TESM_RELEASE_OWNER:-wis}"
REPO="${TESM_RELEASE_REPO:-tesm}"
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
echo "Verwendung: update.sh <tesm|tesm-license> [tag]" >&2
exit 2
}
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
if [[ "$TAG" == "latest" ]]; then
echo "==> Ermittle aktuellen Release-Tag ..."
TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$REPO/releases/latest" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')"
echo " $TAG"
fi
ASSET="$REPO-$TAG.tar.gz"
echo "==> Lade $ASSET ..."
curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
echo "==> Entpacke ..."
tar -xzf "$WORK/$ASSET" -C "$WORK"
SRC="$(find "$WORK" -maxdepth 2 -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
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
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes
+295
View File
@@ -0,0 +1,295 @@
# Betrieb
Dieses Dokument richtet sich an die Person, die die Anlage installiert,
aktualisiert und im Fehlerfall wieder zum Laufen bringt. Es beschreibt, was auf
dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird.
---
## 1. Was wo liegt
| Zweck | Pfad |
|---|---|
| Anwendung (Code, venv, statische Dateien) | `/srv/<name>/` |
| Instanzdaten (Datenbank, Schluessel, Lizenz) | `/srv/<name>/data/` |
| Protokolle | `/var/log/<name>/` |
| Umgebungsdatei | `/etc/tesm/<name>.env` |
| Zertifikate (selbstsigniert) | `/etc/tesm/certs/<name>/` |
| nginx-Site | `/etc/nginx/sites-available/<name>` |
| systemd-Unit | `/etc/systemd/system/<name>.service` |
| Privilegierter Helfer | `/usr/local/lib/tesm/tesm-helper` |
| Verwaltungswerkzeug | `/usr/local/bin/<name>-admin` |
`<name>` ist `tesm` bzw. `tesm-license` -- oder, bei einer zusaetzlichen
Instanz, `tesm-<instanz>`. Dieser Name (der **SITE_KEY**) benennt alles, was
ausserhalb von `/srv/<name>` liegt. Deshalb koennen zwei Installationen
derselben Anwendung nebeneinander laufen, ohne sich gegenseitig die
nginx-Site, die netplan-Datei oder das Sitzungscookie zu ueberschreiben.
Alles, was ein Mensch verlieren wuerde, liegt unter `data/`. Das Verzeichnis
wird von `install.sh` nie angefasst.
---
## 2. Installation
```bash
sudo bash deploy/install.sh --app tesm
sudo bash deploy/install.sh --app tesm-license
```
Wichtige Optionen:
| Option | Wirkung |
|---|---|
| `--https` | Port 443 mit TLS, Port 80 leitet dorthin um. Ohne `--domain` entsteht ein selbstsigniertes Zertifikat. |
| `--domain <name>` | 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). |
| `--skip-packages` | Keine Systempakete installieren. |
Danach:
```bash
sudo -u tesm tesm-admin create-admin
```
Beim Lizenzserver zusaetzlich, **in dieser Reihenfolge**:
```bash
sudo -u tesm-license tesm-license-admin init-key # Signaturschluessel -- 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
> `data/master_signing_key.json` verloren, ist keine ausgestellte Lizenz mehr
> pruefbar. Sichern Sie ihn getrennt vom Server.
### Zwei Anwendungen auf einem Host
Beide wollen Port 80 und 443. nginx unterscheidet sie am Namen:
```bash
sudo bash deploy/install.sh --app tesm --https
sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.local
```
Die Installation **ohne** Domain wird zur Vorgabe-Site (`default_server`) und
beantwortet alles, was zu keinem Namen passt. Zwei namenlose Sites auf
demselben Port weist nginx ab -- richtig so, und beim Uebernehmen sofort
sichtbar.
---
## 3. Aktualisierung
```bash
sudo bash deploy/install.sh --app tesm
```
Derselbe Befehl. Das Skript erkennt an der vorhandenen Datenbank, dass es eine
Aktualisierung ist, und tut dann zusaetzlich:
1. **Vollstaendige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` --
unabhaengig davon, ob sich das Schema geaendert 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
`/srv/<name>-failed-<zeitstempel>` zur Analyse liegen.
Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
`--https`.
---
## 4. HTTPS
Der Zielzustand ist immer: **Port 443 mit TLS, Port 80 leitet um, ausser dem
ACME-Pfad.**
Drei Wege dorthin:
**a) Bei der Installation** -- siehe oben, `--https`.
**b) Nachtraeglich ueber die Kommandozeile:**
```bash
sudo -u tesm tesm-admin web-setup --https --self-signed --apply
sudo -u tesm tesm-admin web-setup --https --domain tesm.firma.de \
--acme --acme-email it@firma.de --hsts --apply
```
`--print-config` zeigt die Datei, ohne etwas zu schreiben.
**c) In der Oberflaeche** 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,
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.
### Selbstsigniert oder Let's Encrypt?
* **Oeffentlich 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`
tragen. **HSTS hier nicht einschalten** -- der Browser merkt sich die Vorgabe
monatelang und die Warnung laesst 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.
---
## 5. Dienste
```bash
systemctl status tesm.service # Weboberflaeche (gunicorn)
systemctl status tesm-monitor.service # Ueberwachungsschleife (nur TESM)
systemctl status tesm-license.service
journalctl -u tesm -n 100 -f
```
Die Units laufen unprivilegiert (`User=tesm`) mit
`NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp` und einer engen
`RestrictAddressFamilies`-Liste. Schreibrechte gibt es nur auf `/srv/<name>/data`
und `/var/log/<name>`.
---
## 6. Root-Rechte
Die Anwendung hat keine. Alles, was Root braucht, geht durch **einen** Pfad:
```
/usr/local/lib/tesm/tesm-helper <verb> [argumente]
```
Der Helfer gehoert `root`, ist per sudoers ausschliesslich fuer genau diesen
Pfad mit `NOPASSWD` freigegeben und prueft jedes Argument gegen eine
Positivliste. Er reicht nie etwas an eine Shell weiter (kein `eval`, kein
`sh -c`).
Verben ansehen:
```bash
sudo /usr/local/lib/tesm/tesm-helper
grep -n ')$' /usr/local/lib/tesm/tesm-helper
```
Regel fuer Aenderungen: **Jedes neue Verb braucht eine Argumentpruefung.**
`tests/test_repo_hygiene.py` erzwingt, dass jedes der Anwendung bekannte Verb
im Helfer vorkommt und dass jede Pfadpruefung `..` 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
Vorschau, bevor irgendetwas geschrieben wird.
Auf dem Host reicht:
```bash
sudo systemctl stop tesm
sudo tar -czf /root/tesm-data-$(date +%F).tar.gz -C /srv/tesm data
sudo systemctl start tesm
```
Unverzichtbar:
* `data/app.db` -- alle Daten
* `data/secret.key` -- ohne sie sind alle Sitzungen ungueltig
* `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`
---
## 8. Wenn etwas nicht geht
**Die Anwendung antwortet nicht.**
```bash
journalctl -u tesm -n 200 --no-pager
curl -s http://127.0.0.1:5000/gesundheit
```
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
**nginx laesst 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
`install.sh`, das eine unbrauchbare Site durch die Vorlage ersetzt.
**Anmeldung schlaegt ohne Fehlermeldung fehl.**
Fast immer `COOKIE_SECURE=1` ohne HTTPS. Der Browser bekommt dann ein Cookie,
das er ueber HTTP nie zuruecksendet.
```bash
grep COOKIE_SECURE /etc/tesm/tesm.env
```
**"Die Anfrage kam von einer fremden Herkunft (Origin/Referer)."**
Der Reverse Proxy reicht den Host falsch durch. In der Site-Datei muss
`proxy_set_header Host $http_host;` stehen -- **nicht** `$host`, das verwirft
den Port.
**Statische Dateien fehlen, die Oberflaeche 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.
**Zwei Instanzen stoeren 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".**
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
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.
**Die Pruefkette des Aenderungsprotokolls 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
```bash
sudo systemctl stop tesm
sudo mv /srv/tesm /srv/tesm-kaputt
sudo cp -a /srv/tesm-backup-<zeitstempel> /srv/tesm
sudo systemctl start tesm
```
Schema-Migrationen laufen nur vorwaerts. Ein Ruecksprung ueber eine
Schema-Aenderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
genau das liegt im Backup-Verzeichnis.
+183
View File
@@ -0,0 +1,183 @@
# Sicherheit
Was geschuetzt wird, womit, und wo bewusst Grenzen gezogen sind.
---
## 1. Anmeldung
| | |
|---|---|
| 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 |
| 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 |
Nach einer Anmeldung wird die Sitzungskennung neu vergeben (gegen Session
Fixation) und das CSRF-Token gewechselt.
### Erneute Bestaetigung ("sudo-Modus")
Alles, was Geheimnisse offenlegt oder Daten aus dem System heraustraegt,
verlangt eine frische Passworteingabe: Klartextanzeige von Zugangsdaten,
Ausfuhr, Herunterladen eines Lizenzbundles, Schluesselrotation,
2FA-Aenderungen, Deaktivierung einer Lizenz.
Ein uebernommenes Sitzungscookie allein reicht dafuer nicht.
---
## 2. Sitzungen
Serverseitig in SQLite, nicht im Cookie. Das Cookie traegt 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
unterscheiden keine Ports)
* Aktive Sitzungen sind einsehbar und einzeln beendbar
---
## 3. CSRF
Drei voneinander unabhaengige 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.
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.
Ausnahmen (`@csrf.exempt`) gibt es nur fuer Maschinenschnittstellen, die sich
**nicht** ueber Cookies autorisieren. Eine cookie-autorisierte Route ohne
CSRF-Schutz waere eine Luecke.
---
## 4. Header
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
`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.
* `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
> HSTS gehoert **nicht** zu einem selbstsignierten Zertifikat. Der Browser
> merkt sich die Vorgabe monatelang; die Warnung laesst 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):
* Versionierte Schluessel, rotierbar. Chiffrate tragen ihre Schluesselkennung
(`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
noch stimmen; Abweichungen erscheinen unter *Diagnose*.
Passwoerter fuer Mounts gehen ueber `stdin` an den privilegierten Helfer, der
sie in eine 0600-Datei schreibt -- nie als Kommandozeilenargument (Prozessliste)
und nie ueber die Umgebung (`/proc/<pid>/environ`).
---
## 6. Rechtetrennung auf dem Host
Der Webprozess laeuft 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.
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
den Basisnamen unterlaufen (`/etc/nginx/sites-available/../../../root/x` hat
den zulaessigen 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.
---
## 7. Aenderungsprotokoll
Verkettet gehasht: jeder Eintrag enthaelt den Hash seines Vorgaengers. Ein
nachtraegliches Aendern oder Loeschen faellt 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
auch Lesezugriffe auf Geheimnisse (`credential.secret_revealed`).
Aeltere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen.
---
## 8. Lizenzprotokoll
Siehe [ARCHITEKTUR.md](ARCHITEKTUR.md), Abschnitt Lizenzierung. Kurz:
getrennte Signaturkontexte, kanonisches JSON, Nonce gegen Wiedereinspielung,
Frischefenster, `seq` gegen Rueckstufung, Antwort an die Anfrage gebunden,
Aussteller-Pinning.
Das Auslieferungsbundle enthaelt den privaten Clientschluessel und ist damit
selbst ein Geheimnis: eigenes Recht (`licenses.export`), frische
Passwortbestaetigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
Aenderungsprotokoll.
---
## 9. Ein- und Ausfuhr
Verschluesselte Umschlaege (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
gewaehlten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb
braucht der Dienst kein `--workers 1` mehr.
---
## 10. Bewusst nicht getan
* **Kein Schalter, der die LDAP-Zertifikatspruefung 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 Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
nicht brechen.
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
---
## 11. Wenn Sie eine Schwachstelle finden
Bitte nicht oeffentlich melden. Wenden Sie sich an die im Anbieterprofil des
Lizenzservers hinterlegte Adresse.
-18
View File
@@ -1,18 +0,0 @@
server {
listen 80;
server_name _;
location / {
client_max_body_size 4m;
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static/ {
alias /srv/tesm-license/static/;
expires 7d;
}
}
-27
View File
@@ -1,27 +0,0 @@
[Unit]
Description=TESM Lizenzserver (Master)
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/srv/tesm-license
# Analog zu TESM_WEB_PROCESS bei TESM selbst -- markiert diesen Prozess als
# den echten Web-Dienst (startet den Lizenz-Heartbeat- und den Auditlog-
# Archivierungs-Thread genau einmal). Anders als bei TESM importiert hier
# kein Hilfsskript app.py periodisch als Modul (kein Geräte-Polling auf
# dem Lizenzserver) -- das Flag bleibt trotzdem gesetzt, für den Fall
# künftiger Hilfsskripte und zur Konsistenz mit dem TESM-Muster.
Environment=TESM_LICENSE_WEB_PROCESS=1
# Kein WebSocket-Terminal wie bei TESM -- gthread/--timeout 0 daher nicht
# nötig, ein paar Threads reichen für die parallele Aktivierungs-/
# Heartbeat-API mehrerer Kunden.
# "--bind 127.0.0.1:5000" wie bei TESM: nur über den nginx-Reverse-Proxy
# von außen erreichbar.
ExecStart=/srv/tesm-license/venv/bin/gunicorn --workers 1 --threads 8 \
--bind 127.0.0.1:5000 app:app
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
-155
View File
@@ -1,155 +0,0 @@
#!/bin/bash
# ============================================================================
# TESM-Lizenzserver — Installer/Updater
# Richtet System-Pakete und die Flask-App (systemd + nginx) unter
# /srv/tesm-license ein. Ursprünglich als Teil des TESM-Hauptrepos gebaut
# (Fork von dessen install.sh) und später in dieses eigenständige Repo
# ausgelagert -- Backup + Health-Check + automatischer Rückroll bei einem
# fehlgeschlagenen Update funktionieren identisch zu TESMs eigenem
# Installer, hier nur die Kurzfassung je Schritt.
#
# Auszuführen als root, nachdem dieses Repo/Release-Paket z.B. nach
# /root/tesm-license entpackt wurde:
# sudo ./install.sh
# ============================================================================
set -e
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
print_status() { echo -e "${GREEN}${NC} ${1} completed."; }
step() { echo -e "${RED}${NC} ${1}..." | tee -a /var/log/tesm-license-install.log; }
install_if_changed() {
local src="$1" dst="$2" mode="${3:-644}"
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
return 1
fi
sudo install -m "$mode" "$src" "$dst"
return 0
}
clear 2>/dev/null || true
# ---- Bestehende Installation erkennen ----
EXISTING_INSTALL=0
[ -f /srv/tesm-license/sqlite.db ] && EXISTING_INSTALL=1
NEEDS_MIGRATION_GUARD=0
BACKUP_DIR=""
if [ "$EXISTING_INSTALL" -eq 1 ]; then
OLD_SCHEMA_VERSION=""
[ -f /srv/tesm-license/SCHEMA_VERSION ] && OLD_SCHEMA_VERSION="$(tr -d '[:space:]' < /srv/tesm-license/SCHEMA_VERSION)"
NEW_SCHEMA_VERSION=""
[ -f "$REPO_DIR/srv/tesm-license/SCHEMA_VERSION" ] && NEW_SCHEMA_VERSION="$(tr -d '[:space:]' < "$REPO_DIR/srv/tesm-license/SCHEMA_VERSION")"
step "Stopping tesm-license.service for update"
systemctl stop tesm-license.service 2>/dev/null || true
print_status "Service stopped"
if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ] && [ "$OLD_SCHEMA_VERSION" == "$NEW_SCHEMA_VERSION" ]; then
echo -e "${GREEN}Bestehende Installation erkannt, Datenbank-Schema unverändert (Version ${OLD_SCHEMA_VERSION}):${NC} In-Place-Update."
else
echo -e "${YELLOW}Bestehende Installation erkannt, Schema-Version geändert oder unbekannt:${NC} Update mit automatischer Migration."
fi
NEEDS_MIGRATION_GUARD=1
BACKUP_DIR="/srv/tesm-license-backup-pre-update-$(date +%Y%m%d-%H%M%S)"
step "Backing up current installation to $BACKUP_DIR before update"
cp -a /srv/tesm-license "$BACKUP_DIR"
print_status "Backup created"
fi
# ---- Pakete ----
step "Installing system packages"
sudo apt-get update >>/var/log/tesm-license-install.log 2>&1 && print_status "apt update"
sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 rsync logrotate certbot >>/var/log/tesm-license-install.log 2>&1 && print_status "Packages installed"
# ---- Log-Verzeichnis ----
sudo mkdir -p /var/log/tesm-license
sudo chmod 755 /var/log/tesm-license
# ---- App-Verzeichnis ----
step "Deploying application to /srv/tesm-license"
sudo mkdir -p /srv/tesm-license
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
--exclude 'license.json' --exclude 'master_signing_key.json' \
"$REPO_DIR/srv/tesm-license/" /srv/tesm-license/ >>/var/log/tesm-license-install.log 2>&1
print_status "Application files copied"
# ---- Python venv ----
step "Setting up Python virtual environment"
cd /srv/tesm-license
sudo python3 -m venv venv
sudo ./venv/bin/python3 -m pip install --upgrade pip >>/var/log/tesm-license-install.log 2>&1
sudo ./venv/bin/python3 -m pip install -r requirements.txt >>/var/log/tesm-license-install.log 2>&1
print_status "Virtual environment ready"
# ---- Datenbank ----
if [ ! -f /srv/tesm-license/sqlite.db ]; then
step "Initializing database"
sudo ./venv/bin/python3 create_db.py >>/var/log/tesm-license-install.log 2>&1
print_status "Database initialized"
echo -e "${RED}${NC} Kein Admin-Benutzer vorhanden. Bitte danach ausführen:"
echo " sudo /srv/tesm-license/venv/bin/python3 /srv/tesm-license/create_admin.py"
echo -e "${RED}${NC} Und die eigene Bootstrap-Lizenz erzeugen:"
echo " sudo /srv/tesm-license/venv/bin/python3 /srv/tesm-license/create_master_license.py"
fi
# ---- systemd ----
step "Copying systemd unit"
UNITS_CHANGED=0
install_if_changed "$REPO_DIR/etc/systemd/system/tesm-license.service" /etc/systemd/system/tesm-license.service 644 && UNITS_CHANGED=1
print_status "Unit copied"
# ---- nginx ----
step "Configuring nginx reverse proxy"
if [ ! -f /etc/nginx/sites-available/tesm-license ]; then
sudo cp "$REPO_DIR/etc/nginx/sites-available/tesm-license" /etc/nginx/sites-available/tesm-license
fi
sudo ln -sf /etc/nginx/sites-available/tesm-license /etc/nginx/sites-enabled/tesm-license
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t >>/var/log/tesm-license-install.log 2>&1 && sudo systemctl reload nginx
print_status "nginx configured"
# ---- Dienst aktivieren ----
step "Enabling service"
if [ "$UNITS_CHANGED" -eq 1 ]; then
sudo systemctl daemon-reload
fi
sudo systemctl enable --now tesm-license.service
print_status "Service enabled and started"
# ---- Sicherheitsnetz prüfen ----
if [ "$NEEDS_MIGRATION_GUARD" -eq 1 ]; then
step "Verifying migrated installation"
HEALTHY=0
for i in $(seq 1 15); do
sleep 2
if curl -fsS -o /dev/null http://127.0.0.1:5000/login 2>/dev/null; then
HEALTHY=1
break
fi
done
if [ "$HEALTHY" -eq 1 ]; then
print_status "Migration verified healthy"
rm -rf "$BACKUP_DIR"
echo -e "${GREEN}${NC} Update erfolgreich -- Backup wieder entfernt."
else
echo -e "${RED}✖ Update fehlgeschlagen -- Dienst antwortet nicht gesund. Rolle zurück...${NC}"
sudo systemctl stop tesm-license.service 2>/dev/null || true
FAILED_DIR="/srv/tesm-license-failed-update-$(date +%Y%m%d-%H%M%S)"
sudo mv /srv/tesm-license "$FAILED_DIR"
sudo cp -a "$BACKUP_DIR" /srv/tesm-license
sudo systemctl start tesm-license.service
echo -e "${YELLOW}Zurückgerollt auf den Stand vor dem Update.${NC}"
echo "Backup bleibt erhalten unter: ${BACKUP_DIR}"
echo "Die fehlgeschlagene Installation liegt zur Fehlersuche unter: ${FAILED_DIR}"
echo "Bitte /var/log/tesm-license/app.log dort prüfen, bevor erneut aktualisiert wird."
fi
fi
echo -e "${GREEN}${NC} Installation abgeschlossen. Web-App erreichbar auf Port 80 (nginx) bzw. 5000 (Flask direkt)."
+25
View File
@@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.0.0"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
"Flask>=3.1",
"cryptography>=42",
"argon2-cffi>=23",
"tesm-licensing>=2.0.0",
]
[project.optional-dependencies]
ldap = ["ldap3>=2.9"]
legacy-hashes = ["bcrypt>=4"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
tesm_core = ["templates/**/*.html", "static/**/*"]
@@ -0,0 +1,38 @@
"""tesm_core -- gemeinsames Anwendungsgeruest von TESM und TESM-Lizenzserver.
Enthaelt alles, was beide Anwendungen teilen: Konfiguration, Datenbankzugriff,
Migrationen, Schluesselverwaltung, Sitzungen, CSRF, Rechtesystem, Navigation,
Benutzer-/Gruppen-/Kontoverwaltung, Papierkorb, Diagnose und das
Design-System.
"""
from __future__ import annotations
__version__ = "2.0.0"
from .app_factory import create_base_app
from .config import CoreConfig, SecurityConfig, load_core_config
from .db import Database, NestedConnectionError
from .extension import TesmCore, core, db, transaction
from .migrations import Migration
from .nav import NavItem, NavTree
from .settings_store import SettingSpec, SettingsStore
__all__ = [
"CoreConfig",
"Database",
"Migration",
"NavItem",
"NavTree",
"NestedConnectionError",
"SecurityConfig",
"SettingSpec",
"SettingsStore",
"TesmCore",
"core",
"create_base_app",
"db",
"load_core_config",
"transaction",
"__version__",
]
@@ -0,0 +1,374 @@
"""Anwendungsfabrik: verdrahtet alle Bausteine zu einer lauffaehigen Flask-App.
Jede der beiden Anwendungen ruft ``create_base_app()`` und ergaenzt danach nur
noch ihre eigenen Blueprints. Alles, was beide teilen -- Datenbank,
Migrationen, Sitzungen, CSRF, Header, Protokollierung, Rechtesystem,
Navigation, Benutzer-/Gruppenverwaltung, Kontoseite, Diagnose -- entsteht hier
genau einmal.
"""
from __future__ import annotations
import secrets
from datetime import datetime, timezone
from typing import Any, Sequence
from flask import Blueprint, Flask, g, render_template, request
from werkzeug.exceptions import HTTPException
from . import audit, logging_setup, ui
from .config import CoreConfig
from .core_schema import CORE_MIGRATIONS
from .db import Database
from .extension import TesmCore, close_db, core, db
from .keystore import Keystore, load_or_create_secret_key
from .licensing_client import LicenseManager
from .migrations import Migration
from .nav import NavTree
from .rbac import service as rbac
from .rbac.model import PermissionTree, RolePreset
from .security import csrf
from .security import sessions as session_store
from .security.sessions import SqliteSessionInterface
from .settings_store import SettingSpec, SettingsStore
from .sysops import SysOps
from .auth import directory
from .auth import routes as auth_routes
from .auth import service as auth_service
from .auth.models import ANONYMOUS, current_user
from .views import account as account_views
from .views import admin as admin_views
from .views import diagnostics as diagnostics_views
from .views import hostsettings as hostsettings_views
from .views import logs as logs_views
from .views import trash as trash_views
CORE_SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"),
SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"),
SettingSpec(
"trash_retention_days",
30,
"int",
"Aufbewahrung im Papierkorb (Tage)",
hint="Nach dieser Frist werden Eintraege beim naechsten Aufruf endgueltig entfernt.",
section="system",
minimum=1,
maximum=3650,
),
SettingSpec("timezone", "", "str", "Zeitzone des Hosts", section="system"),
SettingSpec("hostname", "", "str", "Hostname", section="system"),
SettingSpec(
"log_rotate_interval",
"weekly",
"str",
"Rotationsintervall der Protokolle",
section="system",
choices=(("daily", "taeglich"), ("weekly", "woechentlich"), ("monthly", "monatlich")),
),
SettingSpec(
"log_rotate_keep", 4, "int", "Aufbewahrte Rotationen", section="system", minimum=1, maximum=52
),
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
SettingSpec(
"license_server_ca",
"",
"str",
"Zertifikat des Lizenzservers (PEM)",
section="license",
),
SettingSpec("web_domain", "", "str", "Domain", section="web"),
SettingSpec("web_https_enabled", False, "bool", "HTTPS aktiv", section="web"),
SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"),
SettingSpec("web_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", section="web"),
SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"),
SettingSpec("web_key_path", "", "str", "Privater Schluessel (Pfad)", section="web"),
SettingSpec("web_acme_email", "", "str", "E-Mail fuer Let's Encrypt", section="web"),
SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"),
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
# im Regelbetrieb bleibt es bei 80 und 443.
SettingSpec(
"web_http_port", 80, "int", "HTTP-Port", section="web", minimum=1, maximum=65535
),
SettingSpec(
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
),
SettingSpec(
"web_max_body_mb", 128, "int", "Maximale Anfragegroesse (MB)", section="web",
minimum=1, maximum=8192,
),
*directory.SETTINGS,
)
def create_base_app(
*,
config: CoreConfig,
permissions: PermissionTree,
nav: NavTree,
role_presets: Sequence[RolePreset],
app_migrations: dict[str, Sequence[Migration]] | None = None,
settings_specs: Sequence[SettingSpec] = (),
import_name: str = "tesm_core",
app_short: str = "TESM",
app_edition: str = "",
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = kein
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
#: Funktionsliste von TESM waere dort schlicht falsch.
auth_tagline: str = "",
app_version: str = "2.0.0",
built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True,
template_folder: str | None = None,
static_folder: str | None = None,
) -> Flask:
app = Flask(
import_name,
template_folder=template_folder or "templates",
static_folder=static_folder or "static",
static_url_path="/assets",
)
# Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
# Anwendung zusaetzlich ihre eigenen templates/static mitbringen, ohne dass
# sich Pfade in die Quere kommen.
core_bp = Blueprint(
"tesm_core",
"tesm_core",
template_folder="templates",
static_folder="static/tesm_core",
static_url_path="/core-assets",
)
app.register_blueprint(core_bp)
config.data_dir.mkdir(parents=True, exist_ok=True)
config.log_dir.mkdir(parents=True, exist_ok=True)
app.config.update(
SECRET_KEY=load_or_create_secret_key(config.secret_key_file),
# site_key statt app_key: Cookies unterscheiden keine Ports. Zwei
# Instanzen derselben Anwendung auf einem Host wuerden sich sonst
# gegenseitig die Sitzung ueberschreiben.
SESSION_COOKIE_NAME=f"{config.site_key.replace('-', '_')}_session",
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SECURE=config.security.cookie_secure,
SESSION_COOKIE_SAMESITE=config.security.cookie_samesite,
MAX_CONTENT_LENGTH=config.security.max_content_length,
JSON_SORT_KEYS=False,
TRAP_HTTP_EXCEPTIONS=False,
PREFERRED_URL_SCHEME="https" if config.security.cookie_secure else "http",
TEMPLATES_AUTO_RELOAD=config.debug,
EXPLAIN_TEMPLATE_LOADING=False,
)
app.debug = config.debug
logging_setup.init_app(app, log_dir=config.log_dir, debug=config.debug)
database = Database(config.db_path)
settings = SettingsStore([*CORE_SETTINGS, *settings_specs])
keystore = Keystore(config.data_key_file)
extension = TesmCore(
config=config,
database=database,
keystore=keystore,
permissions=permissions,
nav=nav,
settings=settings,
role_presets=tuple(role_presets),
migrations={"core": CORE_MIGRATIONS, **(app_migrations or {})},
sysops=SysOps(app_key=config.app_key, site_key=config.site_key),
)
if enable_license:
extension.license = LicenseManager(
database=database,
settings=settings,
license_path=config.license_path,
key_path=config.license_key_path,
app_key=config.app_key,
built_in_trusted_keys=built_in_trusted_keys,
)
app.extensions["tesm"] = extension
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,
)
# -- Schema und Startzustand ------------------------------------------ #
applied = extension.run_migrations()
with database.session() as conn, database.transaction(conn):
created = rbac.ensure_presets(conn, permissions, extension.role_presets)
if applied or created:
audit.log(
conn,
actor="system",
action="system.startup_migration",
detail="; ".join([*applied, *(f"Gruppe {name} angelegt" for name in created)]),
severity="notice",
)
session_store.prune(conn)
if extension.license is not None:
with database.session() as conn:
extension.license.reload(conn)
# -- Request-Lebenszyklus --------------------------------------------- #
csrf.init_app(app)
ui.init_app(app)
app.teardown_appcontext(close_db)
@app.before_request
def _prepare_request() -> None:
g.csp_nonce = secrets.token_urlsafe(16)
auth_service.load_request_user()
@app.after_request
def _finish_request(response): # type: ignore[no-untyped-def]
hsts = ""
if config.security.hsts_enabled:
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
return csrf.apply_security_headers(
response, nonce=getattr(g, "csp_nonce", ""), hsts=hsts
)
# -- Template-Kontext -------------------------------------------------- #
from .rbac.model import ACTION_META
@app.context_processor
def _inject_context() -> dict[str, Any]:
user = getattr(g, "user", ANONYMOUS)
conn = db()
order = settings.get(conn, "nav_order") or nav.default_order()
child_order = settings.get(conn, "nav_child_order") or {}
pills: list[dict[str, str]] = []
if extension.license is not None and user.is_authenticated:
pill = extension.license.topbar_pill()
if pill:
pills.append(pill)
pills.extend(extension.registry.get("status_pill_builder", lambda _u: [])(user))
nav_items = nav.resolve(user, order=order, child_order=child_order)
palette = [
{**entry, "url": _safe_url(entry["endpoint"])}
for entry in nav.searchable(user)
if entry["endpoint"]
]
return {
"app_name": config.app_name,
"app_short": app_short,
"app_edition": app_edition,
"auth_tagline": auth_tagline,
"app_version": app_version,
"current_year": datetime.now(timezone.utc).year,
"current_user": current_user,
"nav_items": nav_items,
"palette_entries": palette,
"status_pills": pills,
"csp_nonce": getattr(g, "csp_nonce", ""),
"permission_tree": permissions,
"action_meta": ACTION_META,
"license_state": extension.license.state if extension.license else None,
"license_active": extension.license.active() if extension.license else True,
"module_active": (
extension.license.module_active if extension.license else (lambda _n: True)
),
"feature_allowed": (
extension.license.feature_allowed if extension.license else (lambda _n: True)
),
"migration_warning": bool(extension.registry.get("migration_warning")),
}
# -- Fehlerseiten ------------------------------------------------------ #
@app.errorhandler(HTTPException)
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
if _wants_json():
return {
"error": error.name,
"message": error.description,
"request_id": getattr(g, "request_id", ""),
}, error.code or 500
return (
render_template(
"tesm_core/error.html",
code=error.code or 500,
name=error.name,
description=error.description,
request_id=getattr(g, "request_id", ""),
back_url=request.referrer or "/",
),
error.code or 500,
)
@app.errorhandler(Exception)
def _unhandled(error: Exception): # type: ignore[no-untyped-def]
app.logger.exception("Unbehandelter Fehler: %s", error)
if _wants_json():
return {
"error": "Interner Fehler",
"message": "Der Vorgang konnte nicht abgeschlossen werden.",
"request_id": getattr(g, "request_id", ""),
}, 500
return (
render_template(
"tesm_core/error.html",
code=500,
name="Interner Fehler",
description=(
"Der Vorgang konnte nicht abgeschlossen werden. Der Fehler wurde "
"protokolliert."
),
request_id=getattr(g, "request_id", ""),
back_url=request.referrer or "/",
),
500,
)
# -- Gemeinsame Blueprints -------------------------------------------- #
app.register_blueprint(auth_routes.bp)
app.register_blueprint(admin_views.bp)
app.register_blueprint(account_views.bp)
app.register_blueprint(diagnostics_views.bp)
app.register_blueprint(trash_views.bp)
app.register_blueprint(hostsettings_views.bp)
app.register_blueprint(logs_views.bp)
with app.app_context():
trash_views.register_core_providers()
app.add_url_rule("/gesundheit", "health", _health, methods=["GET"])
return app
def _safe_url(endpoint: str) -> str:
from flask import url_for
try:
return url_for(endpoint)
except Exception: # noqa: BLE001 - Endpunkt existiert in dieser App nicht
return ""
def _wants_json() -> bool:
if request.path.startswith("/api/"):
return True
accept = request.accept_mimetypes
return bool(accept.accept_json and not accept.accept_html)
def _health() -> tuple[dict[str, Any], int]:
"""Gesundheitsendpunkt fuer install.sh und Monitoring.
Bewusst ohne Anmeldung, aber ohne jede verwertbare Information: nur
"laeuft die App und antwortet die Datenbank".
"""
extension = core()
try:
with extension.database.session() as conn:
conn.execute("SELECT 1").fetchone()
except Exception: # noqa: BLE001
return {"status": "degraded"}, 503
return {"status": "ok", "version": extension.config.app_key}, 200
+283
View File
@@ -0,0 +1,283 @@
"""Aenderungsprotokoll mit Hash-Kette.
Ein Audit-Log, das der Administrator selbst manipulieren kann, ist kein
Audit-Log. Der Vorgaenger schrieb einfache Zeilen in eine Tabelle -- wer
Schreibzugriff auf die SQLite-Datei hatte (auf dem Zielsystem: jeder root, und
die App lief als root), konnte Eintraege spurlos entfernen.
Hier bindet jede Zeile den Hash ihrer Vorgaengerin ein:
hash_n = sha256(hash_(n-1) || kanonische Felder von Zeile n)
Eine geloeschte oder geaenderte Zeile bricht die Kette an genau dieser Stelle.
``verify_chain()`` findet den Bruch; die Diagnoseseite zeigt ihn an. Das
verhindert Manipulation nicht, macht sie aber nachweisbar -- und genau darum
geht es bei einem Protokoll.
Archivierung: ab ``archive_threshold`` Zeilen werden die aeltesten vollstaendigen
Kalendertage in Dateien ``audit-JJJJ-MM-TT.jsonl`` ausgelagert, inklusive ihrer
Hashes, sodass die Kette ueber die Archivgrenze hinweg pruefbar bleibt.
"""
from __future__ import annotations
import hashlib
import json
import logging
import sqlite3
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from .db import Database
logger = logging.getLogger(__name__)
GENESIS = "0" * 64
ARCHIVE_THRESHOLD = 20000
ARCHIVE_TARGET = 15000
SEVERITIES = ("info", "notice", "warning", "critical")
def _canonical(row: dict[str, Any]) -> bytes:
payload = {
"ts": row.get("ts", ""),
"actor": row.get("actor", ""),
"actor_id": row.get("actor_id"),
"action": row.get("action", ""),
"target": row.get("target", ""),
"detail": row.get("detail", ""),
"ip": row.get("ip", ""),
"request_id": row.get("request_id", ""),
"severity": row.get("severity", "info"),
}
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
def compute_hash(prev_hash: str, row: dict[str, Any]) -> str:
return hashlib.sha256((prev_hash or GENESIS).encode("ascii") + _canonical(row)).hexdigest()
def log(
conn: sqlite3.Connection,
*,
actor: str,
action: str,
target: str = "",
detail: str = "",
actor_id: int | None = None,
ip: str = "",
request_id: str = "",
severity: str = "info",
ts: datetime | None = None,
) -> int:
"""Schreibt einen Protokolleintrag auf der uebergebenen Verbindung.
Wichtig: **immer** die bestehende Verbindung nutzen. Eine Protokollfunktion,
die selbst eine Verbindung oeffnet, blockiert die laufende Transaktion --
das war im Vorgaenger eine reale Deadlock-Ursache.
"""
if severity not in SEVERITIES:
severity = "info"
row = {
"ts": (ts or datetime.now(timezone.utc)).isoformat(timespec="seconds"),
"actor": actor or "system",
"actor_id": actor_id,
"action": action,
"target": target,
"detail": detail,
"ip": ip,
"request_id": request_id,
"severity": severity,
}
prev_hash = (
Database.value(conn, "SELECT hash FROM audit_log ORDER BY id DESC LIMIT 1") or GENESIS
)
row_hash = compute_hash(str(prev_hash), row)
cursor = conn.execute(
"INSERT INTO audit_log (ts, actor, actor_id, action, target, detail, ip, request_id, "
"severity, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(
row["ts"],
row["actor"],
row["actor_id"],
row["action"],
row["target"],
row["detail"],
row["ip"],
row["request_id"],
row["severity"],
prev_hash,
row_hash,
),
)
return int(cursor.lastrowid or 0)
def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
sql = "SELECT * FROM audit_log ORDER BY id"
params: tuple[Any, ...] = ()
if limit:
sql = "SELECT * FROM (SELECT * FROM audit_log ORDER BY id DESC LIMIT ?) ORDER BY id"
params = (limit,)
checked = 0
expected_prev: str | None = None
for row in conn.execute(sql, params):
data = {key: row[key] for key in row.keys()}
if expected_prev is not None and data["prev_hash"] != expected_prev:
return {
"ok": False,
"checked": checked,
"broken_at": data["id"],
"reason": "Vorgaenger-Hash passt nicht -- eine Zeile wurde entfernt oder eingefuegt.",
}
if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
return {
"ok": False,
"checked": checked,
"broken_at": data["id"],
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash -- Eintrag geaendert.",
}
expected_prev = str(data["hash"])
checked += 1
return {"ok": True, "checked": checked, "broken_at": None, "reason": ""}
def search(
conn: sqlite3.Connection,
*,
query: str = "",
action: str = "",
actor: str = "",
severity: str = "",
limit: int = 200,
offset: int = 0,
) -> list[dict[str, Any]]:
clauses: list[str] = []
params: list[Any] = []
if query:
clauses.append("(target LIKE ? OR detail LIKE ? OR action LIKE ? OR actor LIKE ?)")
params.extend([f"%{query}%"] * 4)
if action:
clauses.append("action = ?")
params.append(action)
if actor:
clauses.append("actor = ?")
params.append(actor)
if severity:
clauses.append("severity = ?")
params.append(severity)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
params.extend([limit, offset])
return Database.all(
conn,
f"SELECT * FROM audit_log {where} ORDER BY id DESC LIMIT ? OFFSET ?",
tuple(params),
)
def count(conn: sqlite3.Connection) -> int:
return int(Database.value(conn, "SELECT COUNT(*) FROM audit_log", default=0) or 0)
def distinct_actions(conn: sqlite3.Connection) -> list[str]:
return [
str(row["action"])
for row in Database.all(conn, "SELECT DISTINCT action FROM audit_log ORDER BY action")
]
def archive_old_rows(
conn: sqlite3.Connection,
archive_dir: Path,
*,
threshold: int = ARCHIVE_THRESHOLD,
target: int = ARCHIVE_TARGET,
) -> list[Path]:
"""Laagert ganze Kalendertage aus, bis die Tabelle unter ``target`` liegt.
Nur *vollstaendige* Tage werden ausgelagert -- der laufende Tag bleibt immer
in der Datenbank, damit die Auditseite nie halbe Tage zeigt.
"""
total = count(conn)
if total <= threshold:
return []
archive_dir.mkdir(parents=True, exist_ok=True)
today = datetime.now(timezone.utc).date().isoformat()
written: list[Path] = []
while total > target:
# Nach Zeitstempel, nicht nach id: Eintraege koennen mit einem
# nachgereichten ts geschrieben worden sein (Import, Migration).
oldest_day = Database.value(conn, "SELECT MIN(substr(ts,1,10)) FROM audit_log")
if not oldest_day or str(oldest_day) >= today:
break
rows = Database.all(
conn,
"SELECT * FROM audit_log WHERE substr(ts,1,10)=? ORDER BY id",
(str(oldest_day),),
)
if not rows:
break
path = archive_dir / f"audit-{oldest_day}.jsonl"
with path.open("a", encoding="utf-8", newline="\n") as stream:
for row in rows:
stream.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
conn.execute("DELETE FROM audit_log WHERE substr(ts,1,10)=?", (str(oldest_day),))
written.append(path)
total -= len(rows)
logger.info("Auditlog: %s Zeilen von %s nach %s ausgelagert", len(rows), oldest_day, path)
return written
def archived_days(archive_dir: Path) -> list[dict[str, Any]]:
"""Liste der Archivdateien fuer die Verlaufsseite."""
if not archive_dir.exists():
return []
entries: list[dict[str, Any]] = []
for path in sorted(archive_dir.glob("audit-*.jsonl"), reverse=True):
try:
stat = path.stat()
except OSError:
continue
entries.append(
{
"day": path.stem.removeprefix("audit-"),
"path": path,
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(
timespec="seconds"
),
}
)
return entries
def read_archived_day(archive_dir: Path, day: str) -> list[dict[str, Any]]:
path = archive_dir / f"audit-{day}.jsonl"
if not path.is_file():
return []
rows: list[dict[str, Any]] = []
with path.open("r", encoding="utf-8") as stream:
for line in stream:
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except ValueError:
continue
return rows
def summarize_by_day(rows: Iterable[dict[str, Any]]) -> list[tuple[str, int]]:
buckets: dict[str, int] = defaultdict(int)
for row in rows:
buckets[str(row.get("ts", ""))[:10]] += 1
return sorted(buckets.items(), reverse=True)
@@ -0,0 +1,27 @@
"""Authentisierung: Benutzerobjekt, Anmeldedienst, Verzeichnisanbindung."""
from __future__ import annotations
from .models import (
ANONYMOUS,
AnonymousUser,
Group,
User,
admin_required,
current_user,
login_required,
permission_required,
reauth_required,
)
__all__ = [
"ANONYMOUS",
"AnonymousUser",
"Group",
"User",
"admin_required",
"current_user",
"login_required",
"permission_required",
"reauth_required",
]
@@ -0,0 +1,581 @@
"""Anmeldung gegen Active Directory / LDAP (Same Sign-On).
"Same Sign-On", nicht "Single Sign-On": der Benutzer tippt sein
Verzeichnispasswort weiterhin auf der normalen Anmeldeseite ein; es wird bei
jeder Anmeldung live gegen das Verzeichnis geprueft, nie hier gespeichert.
Sicherheitsaenderungen gegenueber dem Vorgaenger:
* **Transportverschluesselung ist Pflicht** (LDAPS oder StartTLS). Der
Vorgaenger erlaubte unverschluesseltes LDAP auf Port 389 ohne Warnung -- das
Passwort ging im Klartext ueber das Netz. Wer es dennoch braucht, muss
``ldap_allow_insecure`` ausdruecklich setzen; das wird bei jeder Anmeldung
protokolliert.
* **Serverzertifikat wird geprueft.** Der Vorgaenger validierte es nicht --
LDAPS ohne Validierung schuetzt nur gegen passives Mitlesen, nicht gegen
einen Man-in-the-Middle.
* Das Bind-Passwort des Dienstkontos liegt AES-GCM-verschluesselt in
``service_accounts`` (an den Zweck gebunden), nicht Fernet-verschluesselt
ohne Kontext.
"""
from __future__ import annotations
import logging
import re
import socket
import sqlite3
import ssl
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Literal
from .. import audit
from ..db import Database
from ..extension import core
from ..settings_store import SettingSpec
logger = logging.getLogger(__name__)
SERVICE_PURPOSE = "ldap_bind"
AAD_LDAP_BIND = "service_accounts.ldap_bind"
#: userAccountControl-Bit fuer "Konto deaktiviert".
ACCOUNTDISABLE = 0x0002
#: OID fuer rekursive Gruppenauflösung in AD.
MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
Status = Literal["ok", "invalid", "unavailable", "disabled"]
SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec("ldap_enabled", False, "bool", "Verzeichnisanmeldung aktiv", section="ldap"),
SettingSpec("ldap_host", "", "str", "Server (Hostname)", section="ldap"),
SettingSpec("ldap_port", 636, "int", "Port", section="ldap", minimum=1, maximum=65535),
SettingSpec(
"ldap_security",
"ldaps",
"str",
"Transport",
section="ldap",
choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
),
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat pruefen", section="ldap"),
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
SettingSpec(
"ldap_allow_insecure",
False,
"bool",
"Unverschluesselte Verbindung ausdruecklich erlauben",
hint="Nur fuer Testumgebungen. Das Passwort geht dabei im Klartext ueber das Netz.",
section="ldap",
),
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"),
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis uebernehmen", section="ldap"),
SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60),
)
_USERNAME_RE = re.compile(r"^[A-Za-z0-9._\-@ ]{1,128}$")
@dataclass(frozen=True, slots=True)
class DirectoryProfile:
username: str
dn: str
first_name: str = ""
last_name: str = ""
email: str = ""
group_dns: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class DirectoryResult:
status: Status
profile: DirectoryProfile | None = None
message: str = ""
warnings: tuple[str, ...] = field(default=())
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def is_enabled(conn: sqlite3.Connection) -> bool:
settings = core().settings
if not settings.get(conn, "ldap_enabled"):
return False
return bool(settings.get(conn, "ldap_host"))
def bind_credentials(conn: sqlite3.Connection) -> tuple[str, str]:
"""Benutzer und Passwort des Bind-Dienstkontos (entschluesselt)."""
row = Database.one(
conn,
"SELECT username, password_encrypted FROM service_accounts WHERE purpose=?",
(SERVICE_PURPOSE,),
)
if row is None:
return "", ""
encrypted = str(row.get("password_encrypted") or "")
if not encrypted:
return str(row.get("username") or ""), ""
try:
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschluesselt werden")
return str(row.get("username") or ""), ""
return str(row.get("username") or ""), secret
def store_bind_credentials(conn: sqlite3.Connection, username: str, password: str | None) -> None:
"""Speichert das Dienstkonto. ``password=None`` laesst das bestehende unveraendert."""
existing = Database.one(
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,)
)
encrypted = str(existing.get("password_encrypted") or "") if existing else ""
if password:
encrypted = core().keystore.encrypt(password, aad=AAD_LDAP_BIND)
conn.execute(
"INSERT INTO service_accounts (purpose, username, password_encrypted, updated_at) "
"VALUES (?,?,?,?) ON CONFLICT(purpose) DO UPDATE SET username=excluded.username, "
"password_encrypted=excluded.password_encrypted, updated_at=excluded.updated_at",
(SERVICE_PURPOSE, username.strip(), encrypted, _now()),
)
# --------------------------------------------------------------------------- #
# Verbindung
# --------------------------------------------------------------------------- #
def _tls_object(config: dict[str, Any]):
from ldap3 import Tls
verify = bool(config["ldap_verify_cert"])
ca_file = str(config["ldap_ca_file"] or "") or None
return Tls(
validate=ssl.CERT_REQUIRED if verify else ssl.CERT_NONE,
version=ssl.PROTOCOL_TLS_CLIENT,
ca_certs_file=ca_file,
)
def _server(config: dict[str, Any]):
from ldap3 import Server
security = str(config["ldap_security"])
return Server(
str(config["ldap_host"]),
port=int(config["ldap_port"]),
use_ssl=security == "ldaps",
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
get_info="ALL",
connect_timeout=int(config["ldap_timeout"]),
)
def _connect(config: dict[str, Any], user: str, password: str):
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
from ldap3 import Connection
security = str(config["ldap_security"])
if security == "none" and not config["ldap_allow_insecure"]:
raise PermissionError(
"Unverschluesselte Verzeichnisverbindungen sind deaktiviert. "
"Nutzen Sie LDAPS oder StartTLS."
)
connection = Connection(
_server(config),
user=user or None,
password=password or None,
auto_bind=False,
raise_exceptions=False,
receive_timeout=int(config["ldap_timeout"]),
)
if security == "starttls":
if not connection.open():
raise ConnectionError("Verbindung zum Verzeichnisdienst fehlgeschlagen")
if not connection.start_tls():
raise ConnectionError(
f"StartTLS fehlgeschlagen: {connection.result.get('description', 'unbekannt')}"
)
if not connection.bind():
return connection, False
return connection, True
def _read_config(conn: sqlite3.Connection) -> dict[str, Any]:
settings = core().settings
return settings.get_many(conn, [spec.key for spec in SETTINGS])
def _base_dn(connection, config: dict[str, Any]) -> str:
configured = str(config["ldap_base_dn"] or "").strip()
if configured:
return configured
info = getattr(connection.server, "info", None)
naming = getattr(info, "other", {}).get("defaultNamingContext") if info else None
if isinstance(naming, list) and naming:
return str(naming[0])
if isinstance(naming, str):
return naming
contexts = getattr(info, "naming_contexts", None) if info else None
if contexts:
return str(contexts[0])
return ""
def _escape(value: str) -> str:
"""RFC-4515-Escaping fuer Filterwerte -- verhindert LDAP-Injection."""
replacements = {"\\": r"\5c", "*": r"\2a", "(": r"\28", ")": r"\29", "\x00": r"\00", "/": r"\2f"}
return "".join(replacements.get(char, char) for char in value)
def authenticate(conn: sqlite3.Connection, username: str, password: str) -> DirectoryResult:
"""Suchen-und-Binden gegen das Verzeichnis."""
try:
import ldap3 # noqa: F401
except ImportError:
return DirectoryResult("unavailable", message="Das Paket ldap3 ist nicht installiert.")
if not _USERNAME_RE.match(username or ""):
return DirectoryResult("invalid")
config = _read_config(conn)
if not config.get("ldap_enabled") or not config.get("ldap_host"):
return DirectoryResult("disabled")
bind_user, bind_password = bind_credentials(conn)
warnings: list[str] = []
if str(config["ldap_security"]) == "none":
warnings.append("Unverschluesselte Verzeichnisverbindung in Benutzung")
if not config["ldap_verify_cert"]:
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprueft")
try:
service_conn, bound = _connect(config, bind_user, bind_password)
except (PermissionError, ConnectionError) as exc:
logger.error("Verzeichnisdienst: %s", exc)
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
except (socket.error, ssl.SSLError, OSError) as exc:
logger.error("Verzeichnisdienst nicht erreichbar: %s", exc)
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
if not bound:
detail = service_conn.result.get("description", "unbekannt") if service_conn.result else ""
logger.error("Bind des Dienstkontos fehlgeschlagen: %s", detail)
return DirectoryResult(
"unavailable",
message=f"Anmeldung des Dienstkontos fehlgeschlagen ({detail}).",
warnings=tuple(warnings),
)
try:
base = _base_dn(service_conn, config)
if not base:
return DirectoryResult(
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
)
attribute = str(config["ldap_user_attribute"] or "sAMAccountName")
email_attribute = str(config["ldap_email_attribute"] or "userPrincipalName")
wanted = ["distinguishedName", "givenName", "sn", "userAccountControl", email_attribute, attribute]
service_conn.search(
search_base=base,
search_filter=f"(&(objectClass=user)({attribute}={_escape(username)}))",
attributes=list(dict.fromkeys(wanted)),
size_limit=2,
)
entries = list(service_conn.entries)
if len(entries) != 1:
return DirectoryResult("invalid", warnings=tuple(warnings))
entry = entries[0]
dn = str(entry.entry_dn)
control = _attr_int(entry, "userAccountControl")
if control is not None and control & ACCOUNTDISABLE:
return DirectoryResult("invalid", message="Konto im Verzeichnis deaktiviert.", warnings=tuple(warnings))
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
user_conn, user_bound = _connect(config, dn, password)
try:
if not user_bound:
return DirectoryResult("invalid", warnings=tuple(warnings))
finally:
try:
user_conn.unbind()
except Exception: # noqa: BLE001
pass
group_dns: tuple[str, ...] = ()
if config["ldap_group_sync"]:
group_dns = _resolve_groups(service_conn, base, dn)
profile = DirectoryProfile(
username=_attr(entry, attribute) or username,
dn=dn,
first_name=_attr(entry, "givenName"),
last_name=_attr(entry, "sn"),
email=_attr(entry, email_attribute),
group_dns=group_dns,
)
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
finally:
try:
service_conn.unbind()
except Exception: # noqa: BLE001
pass
def _attr(entry: Any, name: str) -> str:
try:
value = entry[name].value
except Exception: # noqa: BLE001
return ""
if isinstance(value, (list, tuple)):
return str(value[0]) if value else ""
return "" if value is None else str(value)
def _attr_int(entry: Any, name: str) -> int | None:
raw = _attr(entry, name)
try:
return int(raw)
except (TypeError, ValueError):
return None
def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]:
"""Rekursive Gruppenauflösung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
try:
connection.search(
search_base=base,
search_filter=f"(member:{MATCHING_RULE_IN_CHAIN}:={_escape(user_dn)})",
attributes=["distinguishedName", "cn"],
size_limit=500,
)
return tuple(str(entry.entry_dn) for entry in connection.entries)
except Exception as exc: # noqa: BLE001
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
return ()
# --------------------------------------------------------------------------- #
# Schattenkonto
# --------------------------------------------------------------------------- #
def sync_account(conn: sqlite3.Connection, profile: DirectoryProfile) -> dict[str, Any]:
"""Legt das lokale Schattenkonto an bzw. aktualisiert es und mappt Gruppen."""
from ..rbac import service as rbac
row = rbac.find_user_by_username(conn, profile.username)
with core().database.transaction(conn):
if row is None:
cursor = conn.execute(
"INSERT INTO users (username, password_hash, is_admin, auth_source, first_name, "
"last_name, email, created_at, updated_at) VALUES (?,?,0,'ldap',?,?,?,?,?)",
(
profile.username,
"", # Kein lokales Passwort -- Anmeldung laeuft immer ueber das Verzeichnis.
profile.first_name,
profile.last_name,
profile.email,
_now(),
_now(),
),
)
user_id = int(cursor.lastrowid or 0)
audit.log(
conn,
actor=profile.username,
actor_id=user_id,
action="auth.directory_account_created",
target=profile.username,
detail=profile.dn,
severity="notice",
)
else:
user_id = int(row["id"])
conn.execute(
"UPDATE users SET first_name=?, last_name=?, email=?, auth_source='ldap', "
"updated_at=? WHERE id=?",
(profile.first_name, profile.last_name, profile.email, _now(), user_id),
)
_apply_group_mappings(conn, user_id, profile.group_dns)
result = rbac.get_user(conn, user_id)
assert result is not None
return result
def _apply_group_mappings(
conn: sqlite3.Connection, user_id: int, group_dns: tuple[str, ...]
) -> None:
"""Setzt die Gruppen additiv aus den Verzeichnisgruppen.
Bewusst *ersetzend*: wer im Verzeichnis aus einer Gruppe entfernt wurde,
verliert das Recht bei der naechsten Anmeldung. Manuell in der Anwendung
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
"""
if not group_dns:
return
mappings = Database.all(
conn, "SELECT ad_group_dn, app_group_id FROM ldap_group_mappings"
)
if not mappings:
return
mapped_group_ids = {int(row["app_group_id"]) for row in mappings}
lowered = {dn.lower() for dn in group_dns}
wanted = {
int(row["app_group_id"])
for row in mappings
if str(row["ad_group_dn"]).lower() in lowered
}
current = {int(row["id"]) for row in _user_group_ids(conn, user_id)}
# Alles, was ueber ein Mapping kommt, wird neu gesetzt; alles andere bleibt.
keep = {gid for gid in current if gid not in mapped_group_ids}
final = keep | wanted
if final == current:
return
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
conn.executemany(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
[(user_id, gid) for gid in sorted(final)],
)
def _user_group_ids(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,))
def search_directory_users(
conn: sqlite3.Connection, query: str, *, limit: int = 20
) -> list[dict[str, str]]:
"""Live-Suche fuer die Vorab-Zuweisung von Verzeichniskonten."""
if len(query.strip()) < 2:
return []
try:
import ldap3 # noqa: F401
except ImportError:
return []
config = _read_config(conn)
if not config.get("ldap_enabled") or not config.get("ldap_host"):
return []
bind_user, bind_password = bind_credentials(conn)
try:
connection, bound = _connect(config, bind_user, bind_password)
except Exception: # noqa: BLE001
return []
if not bound:
return []
try:
base = _base_dn(connection, config)
attribute = str(config["ldap_user_attribute"] or "sAMAccountName")
email_attribute = str(config["ldap_email_attribute"] or "userPrincipalName")
escaped = _escape(query.strip())
connection.search(
search_base=base,
search_filter=(
f"(&(objectClass=user)(|({attribute}={escaped}*)"
f"(givenName={escaped}*)(sn={escaped}*)(displayName={escaped}*)))"
),
attributes=[attribute, "givenName", "sn", email_attribute],
size_limit=limit,
)
return [
{
"username": _attr(entry, attribute),
"first_name": _attr(entry, "givenName"),
"last_name": _attr(entry, "sn"),
"email": _attr(entry, email_attribute),
"dn": str(entry.entry_dn),
}
for entry in connection.entries
if _attr(entry, attribute)
]
except Exception: # noqa: BLE001
return []
finally:
try:
connection.unbind()
except Exception: # noqa: BLE001
pass
def search_directory_groups(conn: sqlite3.Connection, query: str, *, limit: int = 20) -> list[dict[str, str]]:
"""Live-Suche nach Verzeichnisgruppen fuer die Mapping-Oberflaeche."""
if len(query.strip()) < 2:
return []
try:
import ldap3 # noqa: F401
except ImportError:
return []
config = _read_config(conn)
if not config.get("ldap_enabled") or not config.get("ldap_host"):
return []
bind_user, bind_password = bind_credentials(conn)
try:
connection, bound = _connect(config, bind_user, bind_password)
except Exception: # noqa: BLE001
return []
if not bound:
return []
try:
base = _base_dn(connection, config)
escaped = _escape(query.strip())
connection.search(
search_base=base,
search_filter=f"(&(objectClass=group)(cn={escaped}*))",
attributes=["cn", "distinguishedName"],
size_limit=limit,
)
return [
{"name": _attr(entry, "cn"), "dn": str(entry.entry_dn)} for entry in connection.entries
]
except Exception: # noqa: BLE001
return []
finally:
try:
connection.unbind()
except Exception: # noqa: BLE001
pass
def test_connection(conn: sqlite3.Connection) -> dict[str, Any]:
"""Verbindungstest fuer die Einstellungsseite -- ohne Anmeldung eines Benutzers."""
try:
import ldap3 # noqa: F401
except ImportError:
return {"ok": False, "message": "Das Paket ldap3 ist nicht installiert."}
config = _read_config(conn)
if not config.get("ldap_host"):
return {"ok": False, "message": "Es ist kein Server konfiguriert."}
bind_user, bind_password = bind_credentials(conn)
try:
connection, bound = _connect(config, bind_user, bind_password)
except Exception as exc: # noqa: BLE001
return {"ok": False, "message": str(exc)}
if not bound:
detail = connection.result.get("description", "unbekannt") if connection.result else ""
return {"ok": False, "message": f"Anmeldung des Dienstkontos fehlgeschlagen ({detail})."}
try:
base = _base_dn(connection, config)
return {
"ok": True,
"message": f"Verbindung erfolgreich. Basis-DN: {base or 'nicht ermittelbar'}",
"base_dn": base,
}
finally:
try:
connection.unbind()
except Exception: # noqa: BLE001
pass
@@ -0,0 +1,297 @@
"""Benutzerobjekt, Rechtepruefung und Zugriffsdekoratoren."""
from __future__ import annotations
import functools
from dataclasses import dataclass, field
from typing import Any, Callable, Iterable, Sequence
from flask import abort, flash, g, redirect, request, session, url_for
from werkzeug.local import LocalProxy
from ..rbac.model import Action, PermissionTree
AUTH_SOURCES = ("local", "ldap")
@dataclass(frozen=True, slots=True)
class Group:
id: int
name: str
is_system: bool = False
is_default: bool = False
@dataclass(slots=True)
class User:
"""Angemeldeter Benutzer. Unveraenderlich fuer die Dauer eines Requests."""
id: int
username: str
is_admin: bool = False
is_locked: bool = False
auth_source: str = "local"
first_name: str = ""
last_name: str = ""
email: str = ""
avatar_filename: str = ""
totp_enabled: bool = False
must_change_password: bool = False
groups: tuple[Group, ...] = ()
granted: frozenset[str] = frozenset()
_tree: PermissionTree | None = field(default=None, repr=False, compare=False)
_effective: frozenset[str] | None = field(default=None, repr=False, compare=False)
# -- Anzeige ------------------------------------------------------- #
@property
def is_authenticated(self) -> bool:
return True
@property
def display_name(self) -> str:
full = f"{self.first_name} {self.last_name}".strip()
return full or self.username
@property
def initials(self) -> str:
parts = [p for p in (self.first_name, self.last_name) if p]
if parts:
return "".join(p[0] for p in parts[:2]).upper()
return (self.username[:2] or "?").upper()
@property
def role_label(self) -> str:
if self.is_admin:
return "Administrator"
if self.groups:
return ", ".join(group.name for group in self.groups)
return "Ohne Gruppe"
@property
def is_directory_account(self) -> bool:
return self.auth_source == "ldap"
# -- Rechte -------------------------------------------------------- #
@property
def effective_permissions(self) -> frozenset[str]:
"""Rechte nach Anwendung der Bereichs-Kill-Switches."""
if self._effective is None:
tree = self._tree
self._effective = (
frozenset(tree.effective(self.granted)) if tree is not None else frozenset()
)
return self._effective
def has(self, *permissions: str) -> bool:
"""True, wenn **alle** angegebenen Rechte greifen. Admins duerfen immer alles."""
if self.is_admin:
return True
if not permissions:
return True
return all(permission in self.effective_permissions for permission in permissions)
def has_any(self, permissions: Iterable[str]) -> bool:
if self.is_admin:
return True
effective = self.effective_permissions
return any(permission in effective for permission in permissions)
def can(self, resource: str, action: Action | str) -> bool:
value = action.value if isinstance(action, Action) else str(action)
return self.has(f"{resource}.{value}")
def can_any(self, resource: str, *actions: Action | str) -> bool:
return self.has_any(
f"{resource}.{a.value if isinstance(a, Action) else a}" for a in actions
)
def as_audit_actor(self) -> str:
return self.username
class AnonymousUser:
"""Nicht angemeldet. Hat keine Rechte -- auch nicht ``view``."""
id = 0
username = ""
is_admin = False
is_locked = False
auth_source = "anonymous"
first_name = ""
last_name = ""
email = ""
avatar_filename = ""
totp_enabled = False
must_change_password = False
groups: tuple[Group, ...] = ()
granted: frozenset[str] = frozenset()
@property
def is_authenticated(self) -> bool:
return False
@property
def display_name(self) -> str:
return "Gast"
@property
def initials(self) -> str:
return "?"
@property
def role_label(self) -> str:
return "Nicht angemeldet"
@property
def is_directory_account(self) -> bool:
return False
@property
def effective_permissions(self) -> frozenset[str]:
return frozenset()
def has(self, *permissions: str) -> bool:
return False
def has_any(self, permissions: Iterable[str]) -> bool:
return False
def can(self, resource: str, action: Action | str) -> bool:
return False
def can_any(self, resource: str, *actions: Action | str) -> bool:
return False
def as_audit_actor(self) -> str:
return "anonym"
ANONYMOUS = AnonymousUser()
def _lookup_user() -> User | AnonymousUser:
return getattr(g, "user", ANONYMOUS)
#: Aktueller Benutzer -- in Templates und Views gleich benutzbar.
current_user: Any = LocalProxy(_lookup_user)
# --------------------------------------------------------------------------- #
# Dekoratoren
# --------------------------------------------------------------------------- #
def _deny(message: str) -> Any:
"""Nicht angemeldet -> Login; angemeldet aber ohne Recht -> 403."""
if not current_user.is_authenticated:
if request.method == "GET" and not request.headers.get("X-Requested-With"):
session["_after_login"] = request.full_path if request.query_string else request.path
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")
return redirect(url_for("auth.login"))
abort(403, description=message)
def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(view)
def wrapper(*args: Any, **kwargs: Any) -> Any:
if not current_user.is_authenticated:
return _deny("Anmeldung erforderlich.")
return view(*args, **kwargs)
return wrapper
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
"""Erzwingt Rechte serverseitig.
``mode="any"`` fuer Seiten, die mehrere unabhaengige Rechte akzeptieren --
wichtig, weil im Vorgaenger eine Seite mit reinem Export-Recht nicht
erreichbar war, obwohl das Recht vergeben war.
"""
def decorator(view: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(view)
def wrapper(*args: Any, **kwargs: Any) -> Any:
ok = (
current_user.has_any(permissions)
if mode == "any"
else current_user.has(*permissions)
)
if not ok:
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.")
return view(*args, **kwargs)
wrapper._required_permissions = permissions # type: ignore[attr-defined]
return wrapper
return decorator
def admin_required(view: Callable[..., Any]) -> Callable[..., Any]:
"""Nur echte Administratoren -- nicht ueber Gruppenrechte delegierbar.
Fuer Grenzen, die einen lokalen Admin *nicht* ueberleben muessen, aber auch
nicht an eine Gruppe vergeben werden sollen (Rechtevergabe selbst,
Navigationsreihenfolge, Schluesselrotation).
"""
@functools.wraps(view)
def wrapper(*args: Any, **kwargs: Any) -> Any:
if not current_user.is_authenticated or not current_user.is_admin:
return _deny("Diese Aktion ist Administratoren vorbehalten.")
return view(*args, **kwargs)
return wrapper
def reauth_required(view: Callable[..., Any]) -> Callable[..., Any]:
"""Verlangt eine frische Passworteingabe ("sudo-Modus").
Vorgeschaltet bei allem, was Geheimnisse offenlegt oder Daten aus dem System
heraustraegt: Klartextpasswoerter, Export, Schluesselrotation, 2FA-Aenderung.
Ein uebernommenes Sitzungscookie allein reicht dafuer nicht mehr aus.
"""
@functools.wraps(view)
def wrapper(*args: Any, **kwargs: Any) -> Any:
from .service import reauth_is_fresh # zyklischen Import vermeiden
if not current_user.is_authenticated:
return _deny("Anmeldung erforderlich.")
if not reauth_is_fresh():
session["_after_reauth"] = request.full_path if request.query_string else request.path
flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info")
return redirect(url_for("auth.reauth"))
return view(*args, **kwargs)
return wrapper
def build_user(
row: dict[str, Any],
*,
groups: Sequence[Group],
granted: Iterable[str],
tree: PermissionTree,
) -> User:
return User(
id=int(row["id"]),
username=str(row["username"]),
is_admin=bool(row.get("is_admin")),
is_locked=bool(row.get("is_locked")),
auth_source=str(row.get("auth_source") or "local"),
first_name=str(row.get("first_name") or ""),
last_name=str(row.get("last_name") or ""),
email=str(row.get("email") or ""),
avatar_filename=str(row.get("avatar_filename") or ""),
totp_enabled=bool(row.get("totp_secret")),
must_change_password=bool(row.get("must_change_password")),
groups=tuple(groups),
granted=frozenset(granted),
_tree=tree,
)
@@ -0,0 +1,135 @@
"""Anmelde-Routen: Login, zweiter Faktor, Abmeldung, erneute Bestaetigung."""
from __future__ import annotations
from urllib.parse import urlparse
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
from ..extension import core, db
from . import directory, service
from .models import current_user, login_required
bp = Blueprint("auth", __name__)
def _safe_next(candidate: str | None, fallback_endpoint: str = "index") -> str:
"""Verhindert Open Redirects: nur pfad-relative Ziele derselben Anwendung."""
default = url_for(fallback_endpoint)
if not candidate:
return default
parsed = urlparse(candidate)
if parsed.scheme or parsed.netloc or not candidate.startswith("/"):
return default
return candidate
@bp.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(_safe_next(session.pop("_after_login", None)))
conn = db()
username = ""
if request.method == "POST":
username = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
outcome = service.authenticate(
conn, username, password, ip=service.client_ip()
)
if outcome.status == "needs_totp" and outcome.user_row:
service.start_totp_challenge(outcome.user_row)
return redirect(url_for("auth.second_factor"))
if outcome.ok and outcome.user_row:
user = service.complete_login(
conn,
outcome.user_row,
ip=service.client_ip(),
secret=password,
group_dns=outcome.group_dns,
)
flash(f"Willkommen, {user.display_name}.", "success")
if user.must_change_password:
flash("Bitte legen Sie zuerst ein neues Passwort fest.", "warning")
return redirect(url_for("account.security"))
return redirect(_safe_next(session.pop("_after_login", None)))
flash(outcome.message or service.GENERIC_FAILURE, "danger")
return render_template(
"tesm_core/login.html",
username=username,
directory_enabled=directory.is_enabled(conn),
public_dashboard_url=core().registry.get("public_dashboard_url", ""),
)
@bp.route("/login/zweiter-faktor", methods=["GET", "POST"])
def second_factor():
conn = db()
user_row = service.pending_totp_user(conn)
if user_row is None:
flash("Die Anmeldung ist abgelaufen. Bitte erneut versuchen.", "info")
return redirect(url_for("auth.login"))
if request.method == "POST":
outcome = service.verify_second_factor(
conn, user_row, request.form.get("code") or "", ip=service.client_ip()
)
if outcome.ok:
# Kein ``secret``: das Passwort liegt seit dem ersten Schritt nicht
# mehr vor, und es fuer den zweiten Schritt in der Sitzung
# zwischenzulagern waere ein schlechter Tausch. Freigaben, die die
# Anmeldung des Benutzers weiterreichen, binden sich bei aktivem
# zweitem Faktor deshalb nicht ein -- Freigaben mit hinterlegtem
# Konto schon.
user = service.complete_login(conn, user_row, ip=service.client_ip())
flash(f"Willkommen, {user.display_name}.", "success")
return redirect(_safe_next(session.pop("_after_login", None)))
flash(outcome.message, "danger")
return render_template("tesm_core/second_factor.html")
@bp.post("/logout")
@login_required
def logout():
service.logout(db())
flash("Sie wurden abgemeldet.", "info")
return redirect(url_for("auth.login"))
@bp.get("/logout")
def logout_get():
"""Abmeldung ueber einen Link -- fuer den Abbruch des 2FA-Schritts.
Aendert nur die eigene Sitzung und ist damit kein CSRF-relevanter Vorgang
mit Datenwirkung; die eigentliche Abmeldung im angemeldeten Bereich laeuft
trotzdem ueber POST mit Token.
"""
if current_user.is_authenticated:
service.logout(db())
else:
session.clear()
return redirect(url_for("auth.login"))
@bp.route("/bestaetigen", methods=["GET", "POST"])
@login_required
def reauth():
target = _safe_next(session.get("_after_reauth"))
if request.method == "POST":
password = request.form.get("password") or ""
if service.confirm_password(db(), current_user, password):
service.mark_reauth()
session.pop("_after_reauth", None)
return redirect(target)
flash("Das Passwort ist nicht korrekt.", "danger")
return render_template(
"tesm_core/reauth.html",
cancel_url=url_for("index"),
reauth_minutes=core().config.security.reauth_valid_minutes,
)
@@ -0,0 +1,499 @@
"""Anmeldung, Sitzungsaufbau, erneute Passwortbestaetigung.
Ablauf einer Anmeldung (bewusst in dieser Reihenfolge):
1. Ratenbegrenzung pro IP **und** pro Benutzername pruefen -- bevor irgendein
Hash berechnet wird.
2. Lokales Konto suchen. Existiert es mit ``auth_source='local'``, wird das
Passwort gegen den Argon2-Hash geprueft; existiert es nicht oder als
Verzeichniskonto, greift (falls konfiguriert) die LDAP-Pruefung.
3. Bei Erfolg: gesperrt? -> abweisen. 2FA aktiv? -> Zwischenschritt.
4. Sitzung rotieren (neue ID), Rechte laden, Erfolg protokollieren.
Bei jedem Fehlschlag wird dieselbe, nichts verratende Meldung ausgegeben und
dieselbe Rechenzeit verbraucht (``passwords.dummy_verify``) -- die Antwort
erlaubt keinen Rueckschluss darauf, ob ein Konto existiert.
"""
from __future__ import annotations
import logging
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
from flask import current_app, g, request, session
from .. import audit
from ..db import Database
from ..extension import core, db
from ..rbac import service as rbac
from ..security import passwords, ratelimit
from ..security import sessions as session_store
from .models import ANONYMOUS, Group, User, build_user
logger = logging.getLogger(__name__)
SESSION_USER_KEY = "user_id"
SESSION_PENDING_KEY = "_pending_user_id"
SESSION_REAUTH_KEY = "_reauth_at"
BUCKET_LOGIN_USER = "login:user"
BUCKET_LOGIN_IP = "login:ip"
BUCKET_TOTP = "totp:user"
GENERIC_FAILURE = "Benutzername oder Passwort ist falsch."
Status = Literal["ok", "invalid", "locked", "rate_limited", "needs_totp", "directory_unavailable"]
@dataclass(frozen=True, slots=True)
class AuthOutcome:
status: Status
message: str = ""
user_row: dict[str, Any] | None = None
retry_after: int = 0
#: Verzeichnisgruppen der laufenden Anmeldung. Sie stehen nur hier zur
#: Verfuegung -- gespeichert werden sie bewusst nicht, denn beim naechsten
#: Mal koennen es andere sein.
group_dns: tuple[str, ...] = ()
@property
def ok(self) -> bool:
return self.status == "ok"
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(value: datetime) -> str:
return value.isoformat(timespec="seconds")
def client_ip() -> str:
"""Client-IP unter Beruecksichtigung vertrauenswuerdiger Proxy-Hops."""
hops = core().config.security.trusted_proxy_hops
if hops > 0:
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if parts:
# Der letzte vertrauenswuerdige Hop steht von rechts gezaehlt.
index = max(0, len(parts) - hops)
return parts[index]
return request.remote_addr or ""
# --------------------------------------------------------------------------- #
# Benutzer laden
# --------------------------------------------------------------------------- #
def load_user(conn: sqlite3.Connection, user_id: int) -> User | None:
row = Database.one(
conn, "SELECT * FROM users WHERE id=? AND deleted_at IS NULL", (user_id,)
)
if row is None or row.get("is_locked"):
return None
groups = [
Group(
id=int(entry["id"]),
name=str(entry["name"]),
is_system=bool(entry["is_system"]),
is_default=bool(entry["is_default"]),
)
for entry in rbac.user_groups(conn, user_id)
]
return build_user(
row,
groups=groups,
granted=rbac.granted_permissions(conn, user_id),
tree=core().permissions,
)
def load_request_user() -> None:
"""``before_request``-Hook: setzt ``g.user``."""
g.user = ANONYMOUS
user_id = session.get(SESSION_USER_KEY)
if not user_id:
return
user = load_user(db(), int(user_id))
if user is None:
# Konto geloescht, gesperrt oder Rechte entzogen -> Sitzung beenden.
session.clear()
return
g.user = user
# --------------------------------------------------------------------------- #
# Anmeldung
# --------------------------------------------------------------------------- #
def authenticate(
conn: sqlite3.Connection, username: str, password: str, *, ip: str
) -> AuthOutcome:
security = core().config.security
name = (username or "").strip()
if not name or not password:
return AuthOutcome("invalid", GENERIC_FAILURE)
ip_verdict = ratelimit.check(
conn,
BUCKET_LOGIN_IP,
ip or "unbekannt",
limit=security.login_max_attempts_per_ip,
window_minutes=security.login_lockout_minutes,
)
if ip_verdict.blocked:
return AuthOutcome(
"rate_limited",
"Zu viele Anmeldeversuche von dieser Adresse. Bitte spaeter erneut versuchen.",
retry_after=ip_verdict.retry_after_seconds,
)
user_verdict = ratelimit.check(
conn,
BUCKET_LOGIN_USER,
name.lower(),
limit=security.login_max_attempts,
window_minutes=security.login_lockout_minutes,
)
if user_verdict.blocked:
return AuthOutcome(
"rate_limited",
"Dieses Konto ist nach mehreren Fehlversuchen voruebergehend gesperrt.",
retry_after=user_verdict.retry_after_seconds,
)
row = rbac.find_user_by_username(conn, name)
outcome = _verify_credentials(conn, row, name, password)
if not outcome.ok:
with core().database.transaction(conn):
ratelimit.record(conn, BUCKET_LOGIN_IP, ip or "unbekannt")
ratelimit.record(conn, BUCKET_LOGIN_USER, name.lower())
audit.log(
conn,
actor=name,
action="auth.login_failed",
target=name,
detail=outcome.status,
ip=ip,
severity="warning",
request_id=getattr(g, "request_id", ""),
)
return outcome
user_row = outcome.user_row or {}
if bool(user_row.get("is_locked")):
with core().database.transaction(conn):
audit.log(
conn,
actor=str(user_row.get("username") or name),
action="auth.login_locked",
target=str(user_row.get("username") or name),
ip=ip,
severity="warning",
)
return AuthOutcome("locked", "Dieses Konto ist gesperrt. Bitte wenden Sie sich an die IT.")
with core().database.transaction(conn):
ratelimit.clear(conn, BUCKET_LOGIN_USER, name.lower())
if str(user_row.get("totp_secret") or ""):
return AuthOutcome("needs_totp", "", user_row=user_row, group_dns=outcome.group_dns)
if core().config.security.require_totp_for_admins and bool(user_row.get("is_admin")):
# Kein harter Block: der Admin kommt herein, muss aber sofort 2FA
# einrichten (die Kontoseite fuehrt ihn dorthin).
logger.warning(
"Administrator %s hat keine 2FA, obwohl sie verlangt wird", user_row.get("username")
)
# ``outcome`` mitfuehren, nicht neu bauen: die Verzeichnisgruppen der
# laufenden Anmeldung stehen nur dort. Wer hier ein frisches AuthOutcome
# erzeugt, verliert sie lautlos -- und mit ihnen jede Freigabe, die daran
# haengt.
return AuthOutcome("ok", user_row=user_row, group_dns=outcome.group_dns)
def _verify_credentials(
conn: sqlite3.Connection, row: dict[str, Any] | None, name: str, password: str
) -> AuthOutcome:
from . import directory # lokaler Import: ldap3 ist optional
directory_enabled = directory.is_enabled(conn)
if row is not None and str(row.get("auth_source")) == "local":
if not passwords.verify_password(str(row.get("password_hash") or ""), password):
return AuthOutcome("invalid", GENERIC_FAILURE)
if passwords.needs_rehash(str(row.get("password_hash") or "")):
with core().database.transaction(conn):
conn.execute(
"UPDATE users SET password_hash=?, updated_at=? WHERE id=?",
(passwords.hash_password(password), _iso(_now()), row["id"]),
)
return AuthOutcome("ok", user_row=row)
if not directory_enabled:
passwords.dummy_verify()
return AuthOutcome("invalid", GENERIC_FAILURE)
result = directory.authenticate(conn, name, password)
if result.status == "unavailable":
return AuthOutcome(
"directory_unavailable",
"Der Verzeichnisdienst ist derzeit nicht erreichbar. "
"Bitte versuchen Sie es spaeter erneut.",
)
if result.status != "ok" or result.profile is None:
return AuthOutcome("invalid", GENERIC_FAILURE)
user_row = directory.sync_account(conn, result.profile)
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
def complete_login(
conn: sqlite3.Connection,
user_row: dict[str, Any],
*,
ip: str,
secret: str = "",
group_dns: tuple[str, ...] = (),
) -> User:
"""Baut die Sitzung auf. Rotiert die Sitzungs-ID (Session-Fixation).
``secret`` ist das Passwort dieser Anmeldung. Es wird **nicht** gespeichert
und nicht in die Sitzung geschrieben; es wird ausschliesslich an die
angemeldeten Haken weitergereicht und ist danach weg. Gebraucht wird es
genau von einem: der Einbindung von Dateifreigaben, die sich mit der
Anmeldung des Benutzers am Fileserver anmeldet.
"""
from ..security import csrf
with core().database.transaction(conn):
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
conn.execute(
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
)
audit.log(
conn,
actor=str(user_row["username"]),
actor_id=int(user_row["id"]),
action="auth.login",
target=str(user_row["username"]),
detail=f"Quelle: {user_row.get('auth_source') or 'local'}",
ip=ip,
request_id=getattr(g, "request_id", ""),
)
session.clear()
session[SESSION_USER_KEY] = int(user_row["id"])
session[SESSION_REAUTH_KEY] = _iso(_now())
session["_ip"] = ip
session["_ua"] = (request.user_agent.string or "")[:200]
csrf.rotate_token()
user = load_user(conn, int(user_row["id"]))
if user is None: # pragma: no cover - direkt nach Pruefung praktisch unmoeglich
raise RuntimeError("Benutzer konnte nach der Anmeldung nicht geladen werden")
g.user = user
_run_login_hooks(conn, user, secret=secret, group_dns=group_dns)
return user
def _run_login_hooks(
conn: sqlite3.Connection, user: User, *, secret: str, group_dns: tuple[str, ...]
) -> None:
"""Ruft die angemeldeten Haken auf -- ohne die Anmeldung zu gefaehrden.
Ein Haken, der scheitert, darf niemanden aussperren: was hier passiert, ist
Zusatzarbeit (Freigaben einbinden), nicht die Anmeldung selbst. Deshalb
wird jeder Fehler protokolliert und geschluckt.
"""
for hook in core().login_hooks:
try:
hook(conn, user, secret, group_dns)
except Exception: # pragma: no cover - haengt am Fileserver
current_app.logger.exception(
"Anmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
)
def logout(conn: sqlite3.Connection) -> None:
user = getattr(g, "user", ANONYMOUS)
if user.is_authenticated:
# Vor dem Protokolleintrag, solange die Sitzung noch steht: die Haken
# raeumen auf, was zu ihr gehoert (z. B. eingebundene Freigaben).
for hook in core().logout_hooks:
try:
hook(conn, user)
except Exception: # pragma: no cover - haengt am Fileserver
current_app.logger.exception(
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
)
with core().database.transaction(conn):
audit.log(
conn,
actor=user.username,
actor_id=user.id,
action="auth.logout",
target=user.username,
ip=client_ip(),
)
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
g.user = ANONYMOUS
# --------------------------------------------------------------------------- #
# Zweiter Faktor
# --------------------------------------------------------------------------- #
def start_totp_challenge(user_row: dict[str, Any]) -> None:
session[SESSION_PENDING_KEY] = int(user_row["id"])
session["_pending_since"] = _iso(_now())
def pending_totp_user(conn: sqlite3.Connection) -> dict[str, Any] | None:
user_id = session.get(SESSION_PENDING_KEY)
if not user_id:
return None
started = session.get("_pending_since")
if started:
try:
if _now() - datetime.fromisoformat(str(started)) > timedelta(minutes=5):
session.pop(SESSION_PENDING_KEY, None)
return None
except ValueError:
pass
return Database.one(
conn, "SELECT * FROM users WHERE id=? AND deleted_at IS NULL", (int(user_id),)
)
def verify_second_factor(
conn: sqlite3.Connection, user_row: dict[str, Any], code: str, *, ip: str
) -> AuthOutcome:
from ..security import totp
security = core().config.security
subject = str(user_row["id"])
verdict = ratelimit.check(
conn, BUCKET_TOTP, subject, limit=8, window_minutes=security.login_lockout_minutes
)
if verdict.blocked:
return AuthOutcome(
"rate_limited",
"Zu viele Fehlversuche. Bitte warten Sie einen Moment.",
retry_after=verdict.retry_after_seconds,
)
cleaned = (code or "").strip()
if totp.verify_code(str(user_row.get("totp_secret") or ""), cleaned):
with core().database.transaction(conn):
ratelimit.clear(conn, BUCKET_TOTP, subject)
return AuthOutcome("ok", user_row=user_row)
if _consume_recovery_code(conn, int(user_row["id"]), cleaned, ip=ip):
return AuthOutcome("ok", user_row=user_row)
with core().database.transaction(conn):
ratelimit.record(conn, BUCKET_TOTP, subject)
audit.log(
conn,
actor=str(user_row["username"]),
actor_id=int(user_row["id"]),
action="auth.totp_failed",
target=str(user_row["username"]),
ip=ip,
severity="warning",
)
return AuthOutcome("invalid", "Der Code ist nicht korrekt.")
def _consume_recovery_code(
conn: sqlite3.Connection, user_id: int, code: str, *, ip: str
) -> bool:
candidates = Database.all(
conn,
"SELECT id, code_hash FROM user_recovery_codes WHERE user_id=? AND used_at IS NULL",
(user_id,),
)
normalized = code.strip().lower()
for entry in candidates:
if passwords.verify_password(str(entry["code_hash"]), normalized):
with core().database.transaction(conn):
conn.execute(
"UPDATE user_recovery_codes SET used_at=? WHERE id=?",
(_iso(_now()), entry["id"]),
)
remaining = int(
Database.value(
conn,
"SELECT COUNT(*) FROM user_recovery_codes "
"WHERE user_id=? AND used_at IS NULL",
(user_id,),
default=0,
)
or 0
)
audit.log(
conn,
actor=str(
Database.value(conn, "SELECT username FROM users WHERE id=?", (user_id,))
or ""
),
actor_id=user_id,
action="auth.recovery_code_used",
detail=f"Verbleibende Codes: {remaining}",
ip=ip,
severity="notice",
)
return True
return False
# --------------------------------------------------------------------------- #
# Erneute Bestaetigung ("sudo-Modus")
# --------------------------------------------------------------------------- #
def reauth_is_fresh() -> bool:
stamp = session.get(SESSION_REAUTH_KEY)
if not stamp:
return False
try:
moment = datetime.fromisoformat(str(stamp))
except ValueError:
return False
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
window = timedelta(minutes=core().config.security.reauth_valid_minutes)
return _now() - moment <= window
def mark_reauth() -> None:
session[SESSION_REAUTH_KEY] = _iso(_now())
def confirm_password(conn: sqlite3.Connection, user: User, password: str) -> bool:
"""Prueft das Passwort des angemeldeten Benutzers erneut.
Fuer Verzeichniskonten laeuft die Pruefung gegen das Verzeichnis -- sonst
koennten LDAP-Nutzer den sudo-Modus nie erreichen.
"""
from . import directory
row = rbac.get_user(conn, user.id)
if row is None:
return False
if str(row.get("auth_source")) == "local":
return passwords.verify_password(str(row.get("password_hash") or ""), password)
if not directory.is_enabled(conn):
return False
return directory.authenticate(conn, user.username, password).status == "ok"
+230
View File
@@ -0,0 +1,230 @@
"""Der Befehl ``web-setup`` -- HTTPS einrichten, ohne nginx von Hand anzufassen.
Beide Anwendungen registrieren ihn; die Logik steht darum genau einmal hier.
Warum es diesen Befehl ueberhaupt gibt: die nginx-Site wird ausschliesslich aus
der Vorlage in :mod:`tesm_core.system.webserver` erzeugt -- das ist die
Konsequenz aus dem alias-Fehler des Vorgaengers, der jahrelang ueberlebte, weil
irgendwann jemand die Datei von Hand angelegt hatte. Ein Installationsskript,
das sich seine eigene TLS-Konfiguration zusammensetzt, waere derselbe Fehler in
neu. Stattdessen setzt dieser Befehl die Einstellungen und laesst die Anwendung
schreiben -- genau wie die Oberflaeche es tut.
Aufruf aus ``install.sh``:
tesm-admin web-setup --https --self-signed --hsts --apply
tesm-admin web-setup --https --domain tesm.example.org --acme-email a@b.c --apply
"""
from __future__ import annotations
import argparse
import sys
from typing import Any
from .system import webserver
def _config_from_settings(extension: Any, conn: Any) -> webserver.WebConfig:
values = extension.settings.get_many(
conn,
[
"web_domain",
"web_https_enabled",
"web_hsts_enabled",
"web_redirect_http",
"web_cert_path",
"web_key_path",
"web_acme_webroot",
"web_max_body_mb",
"web_http_port",
"web_https_port",
],
)
return webserver.WebConfig(
app_key=extension.config.site_key,
upstream=str(extension.registry.get("upstream", "127.0.0.1:5000")),
static_root=str(
extension.registry.get("static_root", extension.config.base_dir / "static")
),
domain=str(values["web_domain"] or ""),
https_enabled=bool(values["web_https_enabled"]),
hsts_enabled=bool(values["web_hsts_enabled"]),
redirect_http=bool(values["web_redirect_http"]),
cert_path=str(values["web_cert_path"] or ""),
key_path=str(values["web_key_path"] or ""),
acme_webroot=str(
values["web_acme_webroot"] or f"/var/www/{extension.config.site_key}-acme"
),
websocket_path=str(extension.registry.get("websocket_path", "")),
max_body_mb=int(values["web_max_body_mb"] or 128),
http_port=int(values["web_http_port"] or 80),
https_port=int(values["web_https_port"] or 443),
)
def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
"""Setzt die Webserver-Einstellungen und schreibt optional die Site-Datei."""
sysops = extension.sysops
with app.app_context(), extension.database.session() as conn:
values: dict[str, Any] = {}
if args.domain is not None:
try:
values["web_domain"] = webserver.validate_domain(args.domain)
except ValueError as exc:
print(str(exc), file=sys.stderr)
return 2
if args.acme_email:
values["web_acme_email"] = args.acme_email.strip()
if args.max_body_mb:
values["web_max_body_mb"] = int(args.max_body_mb)
for flag, key in (("http_port", "web_http_port"), ("https_port", "web_https_port")):
port = getattr(args, flag, None)
if port:
if not 1 <= int(port) <= 65535:
print(f"Ungueltiger Port: {port}", file=sys.stderr)
return 2
values[key] = int(port)
with extension.database.transaction(conn):
if values:
extension.settings.set_many(conn, values, actor="install")
# -- Zertifikat --------------------------------------------------- #
config = _config_from_settings(extension, conn)
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unveraendert."}
if args.acme:
if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
return 2
cert = webserver.request_certificate(
sysops,
domain=config.domain,
email=str(extension.settings.get(conn, "web_acme_email") or ""),
webroot=config.acme_webroot,
)
if not cert["ok"] and args.self_signed:
# Kein oeffentliches Zertifikat bekommen -- lieber
# selbstsigniertes HTTPS als gar keins. Der Aufrufer erfaehrt
# beides.
print(f"Let's Encrypt fehlgeschlagen: {cert.get('detail', cert['message'])}")
print("Weiche auf ein selbstsigniertes Zertifikat aus.")
args.acme = False
elif not cert["ok"]:
print(cert.get("detail") or cert["message"], file=sys.stderr)
return 1
if args.self_signed and not args.acme:
if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
return 2
suggested = webserver.local_names()
cert = webserver.create_self_signed(
sysops,
common_name=config.domain or (suggested[0] if suggested else "localhost"),
alt_names=[*suggested, "localhost"],
)
if not cert["ok"]:
print(cert.get("detail") or cert["message"], file=sys.stderr)
return 1
if cert.get("cert_path"):
with extension.database.transaction(conn):
extension.settings.set_many(
conn,
{"web_cert_path": cert["cert_path"], "web_key_path": cert["key_path"]},
actor="install",
)
print(cert["message"])
# -- HTTPS-Schalter ------------------------------------------------ #
config = _config_from_settings(extension, conn)
if args.https:
if not (config.cert_path and config.key_path):
print(
"HTTPS verlangt Zertifikat und Schluessel -- bitte --self-signed "
"oder --acme angeben.",
file=sys.stderr,
)
return 2
with extension.database.transaction(conn):
extension.settings.set_many(
conn,
{
"web_https_enabled": True,
"web_hsts_enabled": bool(args.hsts),
"web_redirect_http": not args.no_redirect,
},
actor="install",
)
config = _config_from_settings(extension, conn)
rendered = webserver.render(config)
if args.print_config:
print(rendered)
return 0
if not args.apply:
print("Einstellungen gespeichert. Mit --apply wird nginx neu geschrieben.")
return 0
if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
return 2
result = webserver.apply_config(sysops, rendered)
print(result["message"])
if not result["ok"]:
print(result.get("detail", ""), file=sys.stderr)
return 1
if config.https_enabled:
detail = f"HTTPS auf {config.https_port}, {config.http_port} leitet um"
else:
detail = f"HTTP auf {config.http_port}"
print(f"nginx: {config.server_name} -> {config.upstream} -- {detail}")
return 0
def add_web_setup(sub: Any, runner: Any) -> None:
"""Haengt ``web-setup`` an einen ``argparse``-Subparser."""
parser = sub.add_parser(
"web-setup",
help="nginx und TLS einrichten (Port 80/443)",
description=(
"Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. "
"Die Datei wird immer vollstaendig neu geschrieben -- eine von Hand "
"bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht."
),
)
parser.add_argument("--https", action="store_true", help="HTTPS auf Port 443 aktivieren")
parser.add_argument(
"--self-signed",
action="store_true",
help="Selbstsigniertes Zertifikat erzeugen (geschlossene Netze ohne Domain)",
)
parser.add_argument(
"--acme",
action="store_true",
help="Zertifikat per Let's Encrypt anfordern (Domain muss oeffentlich zeigen)",
)
parser.add_argument("--domain", help="Oeffentlicher Name dieser Installation")
parser.add_argument("--acme-email", help="Kontaktadresse fuer Let's Encrypt")
parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen")
parser.add_argument(
"--no-redirect",
action="store_true",
help="Port 80 nicht auf HTTPS umleiten (Sonderfall)",
)
parser.add_argument("--max-body-mb", type=int, help="Obergrenze fuer Uploads")
parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)")
parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)")
parser.add_argument("--apply", action="store_true", help="nginx schreiben, pruefen, neu laden")
parser.add_argument(
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
)
parser.set_defaults(func=runner)
__all__ = ["add_web_setup", "run_web_setup"]
+205
View File
@@ -0,0 +1,205 @@
"""Konfiguration aus Umgebungsvariablen -- typisiert, an einer Stelle.
Im Vorgaengerprojekt waren Pfade und Konstanten quer durch eine mehrere
tausend Zeilen lange ``app.py`` verstreut. Hier gibt es genau ein Objekt, das
beim Start gebaut, validiert und danach nicht mehr veraendert wird.
Namensraum: jede Anwendung bekommt ein eigenes Env-Praefix (``TESM_`` bzw.
``TESM_LICENSE_``), damit beide Dienste auf demselben Host ohne Kollisionen
laufen koennen -- inklusive der Pfade, die im Vorgaenger kollidierten
(netplan-Override!).
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field, replace
from pathlib import Path
def _env(prefix: str, name: str, default: str = "") -> str:
return os.environ.get(f"{prefix}{name}", default).strip()
def _env_bool(prefix: str, name: str, default: bool) -> bool:
raw = _env(prefix, name)
if not raw:
return default
return raw.lower() in {"1", "true", "yes", "on"}
def _env_int(prefix: str, name: str, default: int) -> int:
raw = _env(prefix, name)
try:
return int(raw) if raw else default
except ValueError:
return default
@dataclass(frozen=True, slots=True)
class SecurityConfig:
"""Alles, was das Sicherheitsverhalten steuert. Defaults sind die sichere Wahl."""
#: Cookie nur ueber HTTPS ausliefern. In Produktion immer True (nginx terminiert TLS).
cookie_secure: bool = True
cookie_samesite: str = "Lax"
#: Leerlauf-Zeitfenster einer Sitzung.
session_idle_minutes: int = 60
#: Harte Obergrenze unabhaengig von Aktivitaet.
session_absolute_hours: int = 12
#: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt.
reauth_valid_minutes: int = 15
#: Fehlversuche bis zur temporaeren Sperre eines Kontos.
login_max_attempts: int = 8
login_lockout_minutes: int = 15
#: Fehlversuche pro IP im gleichen Zeitfenster (unabhaengig vom Konto).
login_max_attempts_per_ip: int = 30
#: Mindestlaenge lokaler Passwoerter.
password_min_length: int = 12
#: 2FA fuer Administratoren erzwingen.
require_totp_for_admins: bool = False
#: HSTS-Header setzen (nur sinnvoll, wenn TLS tatsaechlich terminiert wird).
hsts_enabled: bool = False
hsts_max_age: int = 15552000
#: Vertrauenswuerdige Proxy-Hops fuer X-Forwarded-For (0 = Header ignorieren).
trusted_proxy_hops: int = 1
#: Maximale Groesse eines Uploads in Bytes (None = unbegrenzt).
max_content_length: int | None = 64 * 1024 * 1024
@dataclass(frozen=True, slots=True)
class CoreConfig:
"""Basiskonfiguration, die beide Anwendungen teilen."""
app_key: str
"""Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units."""
site_key: str
"""Name *dieser Installation* auf dem Host, z. B. ``tesm-opus``.
Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite
Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit
zwischen Instanzen kollidieren koennte -- nginx-Site, netplan-Datei,
logrotate-Regel, Zertifikatsverzeichnis, Name des Sitzungscookies --, wird
ueber diesen Schluessel benannt und **nicht** ueber den ``app_key``.
Ohne diese Trennung wuerde eine Testinstanz beim Speichern der
Webserver-Einstellungen die Site-Datei der Produktion ueberschreiben, und
beide Instanzen wuerden sich auf demselben Host dasselbe Sitzungscookie
teilen (Cookies unterscheiden keine Ports).
"""
app_name: str
"""Anzeigename in der Oberflaeche."""
env_prefix: str
base_dir: Path
data_dir: Path
log_dir: Path
db_path: Path
secret_key_file: Path
data_key_file: Path
license_path: Path
license_key_path: Path
#: Adresse, auf der der Anwendungsserver lauscht -- exakt das, was nginx
#: als ``proxy_pass``-Ziel braucht. Kommt aus derselben Umgebungsdatei wie
#: der ``--bind``-Parameter von gunicorn, damit beide nie auseinanderlaufen.
#: Vorher stand hier ein fest verdrahtetes ``127.0.0.1:5000``; eine zweite
#: Instanz auf Port 5100 erzeugte damit eine nginx-Site, die auf die
#: *andere* Installation zeigte.
bind: str = "127.0.0.1:5000"
debug: bool = False
#: Instanz laeuft als echter Webprozess (gesetzt durch die systemd-Unit).
web_process: bool = False
security: SecurityConfig = field(default_factory=SecurityConfig)
#: Zusaetzliche, app-spezifische Werte.
extra: dict[str, str] = field(default_factory=dict)
@property
def is_production(self) -> bool:
return not self.debug
def path_in_data(self, name: str) -> Path:
return self.data_dir / name
def load_core_config(
*,
app_key: str,
app_name: str,
env_prefix: str,
default_base: Path | None = None,
default_bind: str = "127.0.0.1:5000",
) -> CoreConfig:
"""Liest die Konfiguration aus der Umgebung.
Ohne gesetzte Env-Vars laeuft alles in einem Unterverzeichnis
``instance/<app_key>`` relativ zum aktuellen Arbeitsverzeichnis -- damit ist
die Anwendung ohne jede Vorbereitung lokal startbar, ohne je nach
``/srv``/``/var/log`` zu greifen.
"""
base = Path(_env(env_prefix, "BASE_DIR") or (default_base or Path.cwd() / "instance" / app_key))
base = base.expanduser().resolve()
data_dir = Path(_env(env_prefix, "DATA_DIR") or base / "data").expanduser()
log_dir = Path(_env(env_prefix, "LOG_DIR") or base / "logs").expanduser()
security = SecurityConfig(
cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True),
cookie_samesite=_env(env_prefix, "COOKIE_SAMESITE") or "Lax",
session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 60),
session_absolute_hours=_env_int(env_prefix, "SESSION_ABSOLUTE_HOURS", 12),
reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15),
login_max_attempts=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS", 8),
login_lockout_minutes=_env_int(env_prefix, "LOGIN_LOCKOUT_MINUTES", 15),
login_max_attempts_per_ip=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS_PER_IP", 30),
password_min_length=_env_int(env_prefix, "PASSWORD_MIN_LENGTH", 12),
require_totp_for_admins=_env_bool(env_prefix, "REQUIRE_TOTP_FOR_ADMINS", False),
hsts_enabled=_env_bool(env_prefix, "HSTS_ENABLED", False),
hsts_max_age=_env_int(env_prefix, "HSTS_MAX_AGE", 15552000),
trusted_proxy_hops=_env_int(env_prefix, "TRUSTED_PROXY_HOPS", 1),
max_content_length=_env_int(env_prefix, "MAX_CONTENT_LENGTH", 64 * 1024 * 1024) or None,
)
debug = _env_bool(env_prefix, "DEBUG", False)
if debug:
# Lokale Entwicklung laeuft ueber http://127.0.0.1 -- ein Secure-Cookie
# wuerde dort nie gesetzt und die Anmeldung schlicht nicht funktionieren.
# dataclasses.replace, nicht __dict__: SecurityConfig hat slots und
# damit gar kein __dict__ -- der alte Ausdruck warf beim Start einen
# AttributeError, und zwar genau im Entwicklungsmodus, den das README
# empfiehlt.
security = replace(
security, cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", False)
)
bind = (_env(env_prefix, "BIND") or default_bind).strip()
if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind):
raise ValueError(f"Ungueltige Bind-Adresse: {bind!r}")
site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower()
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key):
raise ValueError(f"Ungueltiger SITE_KEY: {site_key!r}")
return CoreConfig(
app_key=app_key,
site_key=site_key,
bind=bind,
app_name=_env(env_prefix, "APP_NAME") or app_name,
env_prefix=env_prefix,
base_dir=base,
data_dir=data_dir,
log_dir=log_dir,
db_path=Path(_env(env_prefix, "DB_PATH") or data_dir / "app.db").expanduser(),
secret_key_file=Path(
_env(env_prefix, "SECRET_KEY_FILE") or data_dir / "secret.key"
).expanduser(),
data_key_file=Path(_env(env_prefix, "DATA_KEY_FILE") or data_dir / "data.keys").expanduser(),
license_path=Path(_env(env_prefix, "LICENSE_PATH") or data_dir / "license.json").expanduser(),
license_key_path=Path(
_env(env_prefix, "LICENSE_KEY_PATH") or data_dir / "license_key.json"
).expanduser(),
debug=debug,
web_process=_env(env_prefix, "WEB_PROCESS") == "1",
security=security,
)
@@ -0,0 +1,193 @@
"""Migrationen der Komponente ``core``: Benutzer, Gruppen, Sitzungen, Audit, Settings.
Diese Tabellen sind in beiden Anwendungen identisch. Neue Aenderungen kommen
als **neue** Migration mit neuer Versionsnummer dazu -- bestehende Migrationen
werden nie editiert (die Pruefsummenwarnung des Runners weist darauf hin).
"""
from __future__ import annotations
from .migrations import Migration
V1_BASELINE = """
-- Benutzerkonten. 'local' = eigenes Passwort, 'ldap' = Schattenkonto aus dem
-- Verzeichnisdienst (Passwort wird dort geprueft, nie hier gespeichert).
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL COLLATE NOCASE,
password_hash TEXT NOT NULL DEFAULT '',
is_admin INTEGER NOT NULL DEFAULT 0,
is_locked INTEGER NOT NULL DEFAULT 0,
auth_source TEXT NOT NULL DEFAULT 'local',
first_name TEXT NOT NULL DEFAULT '',
last_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
avatar_filename TEXT NOT NULL DEFAULT '',
totp_secret TEXT NOT NULL DEFAULT '',
totp_confirmed_at TEXT,
must_change_password INTEGER NOT NULL DEFAULT 0,
password_changed_at TEXT,
last_login_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
-- Eindeutigkeit nur unter den *nicht* geloeschten Konten: ein Name im
-- Papierkorb blockiert die Neuanlage nicht dauerhaft, solange die Anwendung
-- den Konflikt bewusst behandelt.
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_live
ON users(username) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_users_deleted ON users(deleted_at);
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL COLLATE NOCASE,
description TEXT NOT NULL DEFAULT '',
is_system INTEGER NOT NULL DEFAULT 0,
is_default INTEGER NOT NULL DEFAULT 0,
preset_key TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_live
ON groups(name) WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS group_permissions (
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
permission TEXT NOT NULL,
PRIMARY KEY (group_id, permission)
);
CREATE TABLE IF NOT EXISTS user_groups (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, group_id)
);
CREATE INDEX IF NOT EXISTS idx_user_groups_group ON user_groups(group_id);
-- Einmal-Codes fuer den Verlust des Authenticators. Gespeichert wird nur ein
-- Argon2-Hash -- ein Datenbankleck gibt die Codes nicht her.
CREATE TABLE IF NOT EXISTS user_recovery_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
used_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_recovery_user ON user_recovery_codes(user_id);
CREATE TABLE IF NOT EXISTS user_sessions (
id TEXT PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
idle_expires_at TEXT NOT NULL,
absolute_expires_at TEXT NOT NULL,
reauth_at TEXT,
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
data TEXT NOT NULL DEFAULT '{}',
revoked_at TEXT,
revoked_reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id, revoked_at);
CREATE INDEX IF NOT EXISTS idx_sessions_idle ON user_sessions(idle_expires_at);
-- Sliding-Window-Log der Ratenbegrenzung (Anmeldeversuche, Portal-Anfragen, API).
CREATE TABLE IF NOT EXISTS rate_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bucket TEXT NOT NULL,
subject TEXT NOT NULL,
ts TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rate_lookup ON rate_events(bucket, subject, ts);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL DEFAULT ''
);
-- Aenderungsprotokoll mit Hash-Kette: jede Zeile bindet den Hash ihrer
-- Vorgaengerin ein. Eine nachtraeglich geloeschte oder veraenderte Zeile bricht
-- die Kette und wird von der Pruefroutine gefunden.
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
actor TEXT NOT NULL,
actor_id INTEGER,
action TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
request_id TEXT NOT NULL DEFAULT '',
severity TEXT NOT NULL DEFAULT 'info',
prev_hash TEXT NOT NULL DEFAULT '',
hash TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
-- Dienstkonten (z. B. LDAP-Bind). Das Passwort liegt AES-GCM-verschluesselt vor,
-- gebunden an den Verwendungszweck als AAD.
CREATE TABLE IF NOT EXISTS service_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
purpose TEXT NOT NULL UNIQUE,
username TEXT NOT NULL DEFAULT '',
password_encrypted TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL UNIQUE,
ad_group_name TEXT NOT NULL,
app_group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);
"""
V2_TRUSTED_KEYS = """
-- Gepinnte Aussteller-Schluessel der Lizenzpruefung (Trust on First Use bzw.
-- im Build mitgeliefert). Der Vorgaenger vertraute allein dem in der
-- Lizenzdatei eingebetteten Schluessel.
CREATE TABLE IF NOT EXISTS trusted_issuer_keys (
key_id TEXT PRIMARY KEY,
public_key TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'tofu',
added_at TEXT NOT NULL,
added_by TEXT NOT NULL DEFAULT ''
);
"""
V3_JOBS = """
-- Persistente Auftragsverfolgung fuer Hintergrundlaeufe (Wartung, Import,
-- Berichte). Im Vorgaenger lebte dieser Zustand ausschliesslich im
-- Prozessspeicher und war nach einem Dienst-Neustart weg -- inklusive der
-- Information, ob ein Lauf ueberhaupt stattgefunden hat.
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
subject TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT 'pending',
progress INTEGER NOT NULL DEFAULT 0,
message TEXT NOT NULL DEFAULT '',
output TEXT NOT NULL DEFAULT '',
started_by TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_jobs_kind_state ON jobs(kind, state);
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
"""
CORE_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1_BASELINE),
Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
Migration(version=3, name="jobs", sql=V3_JOBS),
)
+213
View File
@@ -0,0 +1,213 @@
"""Datenbankzugriff auf SQLite -- eine Verbindung pro Request, Schreibsperren strukturell.
Die schmerzhafteste Fehlerklasse des Vorgaengerprojekts war
``sqlite3.OperationalError: database is locked``: eine Hilfsfunktion oeffnete
still eine *zweite* Verbindung, waehrend die aeussere Funktion noch eine offene
Schreibtransaktion hielt. Das ist hier nicht mehr durch Disziplin, sondern
strukturell verhindert:
* Pro Request existiert genau **eine** Verbindung (in ``flask.g``).
* ``Database.transaction()`` zaehlt die Verschachtelungstiefe. Wer waehrend
einer offenen Schreibtransaktion ``Database.connect()`` aufruft, bekommt
sofort einen ``NestedConnectionError`` mit Klartext-Erklaerung -- nicht erst
nach 15 Sekunden Busy-Timeout einen Deadlock.
* Verschachtelte ``transaction()``-Aufrufe auf derselben Verbindung sind
erlaubt und werden zu einem einzigen Commit zusammengefasst (Savepoints),
damit Service-Funktionen sich gefahrlos gegenseitig aufrufen koennen.
Zusaetzlich: WAL-Modus (paralleles Lesen waehrend eines Schreibers),
``foreign_keys=ON`` (im Vorgaenger nie aktiviert -- Fremdschluessel waren
Dekoration) und ``busy_timeout=15000``.
"""
from __future__ import annotations
import os
import sqlite3
import threading
from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager
from pathlib import Path
from typing import Any
class NestedConnectionError(RuntimeError):
"""Zweite Verbindung waehrend einer offenen Schreibtransaktion."""
class Row(sqlite3.Row):
"""``sqlite3.Row`` mit ``dict``-artigem ``get`` und Mapping-Konvertierung."""
def get(self, key: str, default: Any = None) -> Any:
try:
return self[key]
except (IndexError, KeyError):
return default
def as_dict(self) -> dict[str, Any]:
return {key: self[key] for key in self.keys()}
def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
if row is None:
return None
return {key: row[key] for key in row.keys()}
def rows_to_dicts(rows: Sequence[sqlite3.Row]) -> list[dict[str, Any]]:
return [{key: row[key] for key in row.keys()} for row in rows]
class Database:
"""Zugriffsobjekt fuer eine SQLite-Datei."""
def __init__(self, path: Path | str, *, timeout: float = 15.0) -> None:
self.path = Path(path)
self.timeout = timeout
self._state = threading.local()
# ------------------------------------------------------------------ #
# Verbindungen
# ------------------------------------------------------------------ #
def _depth(self) -> int:
return int(getattr(self._state, "depth", 0))
def _set_depth(self, value: int) -> None:
self._state.depth = value
def connect(self) -> sqlite3.Connection:
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
if self._depth() > 0:
raise NestedConnectionError(
"Es laeuft bereits eine Schreibtransaktion auf dieser Datenbank. "
"Reiche die bestehende Verbindung durch, statt eine neue zu oeffnen "
"(sonst blockiert SQLite sich selbst)."
)
return self._raw_connect()
def _raw_connect(self) -> sqlite3.Connection:
self.path.parent.mkdir(parents=True, exist_ok=True)
fresh = not self.path.exists()
conn = sqlite3.connect(
self.path,
timeout=self.timeout,
isolation_level=None, # Transaktionen steuern wir selbst, explizit.
check_same_thread=False,
)
conn.row_factory = Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA busy_timeout=15000")
conn.execute("PRAGMA secure_delete=ON")
if fresh:
# Die Datei enthaelt Passwort-Hashes, verschluesselte Zugangsdaten und
# Sitzungen -- sie gehoert niemandem ausser dem Dienstbenutzer.
try:
os.chmod(self.path, 0o600)
except OSError: # pragma: no cover - z. B. Windows/NTFS
pass
return conn
@contextmanager
def session(self) -> Iterator[sqlite3.Connection]:
"""Kurzlebige Verbindung fuer Skripte und Hintergrund-Threads."""
conn = self.connect()
try:
yield conn
finally:
conn.close()
# ------------------------------------------------------------------ #
# Transaktionen
# ------------------------------------------------------------------ #
@contextmanager
def transaction(self, conn: sqlite3.Connection | None = None) -> Iterator[sqlite3.Connection]:
"""Schreibtransaktion mit ``BEGIN IMMEDIATE``.
``BEGIN IMMEDIATE`` nimmt die Schreibsperre sofort statt erst beim ersten
Schreibzugriff -- damit scheitert ein konkurrierender Schreiber sauber am
Busy-Timeout, statt mitten in der Transaktion in einen
``SQLITE_BUSY_SNAPSHOT`` zu laufen.
"""
own_connection = conn is None
if own_connection:
conn = self._raw_connect()
assert conn is not None
depth = self._depth()
savepoint = f"sp_{depth}"
self._set_depth(depth + 1)
try:
if depth == 0:
conn.execute("BEGIN IMMEDIATE")
else:
conn.execute(f"SAVEPOINT {savepoint}")
yield conn
except BaseException:
try:
if depth == 0:
conn.execute("ROLLBACK")
else:
conn.execute(f"ROLLBACK TO {savepoint}")
except sqlite3.Error: # pragma: no cover - Verbindung schon tot
pass
raise
else:
if depth == 0:
conn.execute("COMMIT")
else:
conn.execute(f"RELEASE {savepoint}")
finally:
self._set_depth(depth)
if own_connection:
conn.close()
@property
def in_transaction(self) -> bool:
return self._depth() > 0
# ------------------------------------------------------------------ #
# Bequeme Abfragehelfer (immer parametrisiert -- kein String-Basteln)
# ------------------------------------------------------------------ #
@staticmethod
def one(
conn: sqlite3.Connection, sql: str, params: Sequence[Any] | Mapping[str, Any] = ()
) -> dict[str, Any] | None:
return row_to_dict(conn.execute(sql, params).fetchone())
@staticmethod
def all(
conn: sqlite3.Connection, sql: str, params: Sequence[Any] | Mapping[str, Any] = ()
) -> list[dict[str, Any]]:
return rows_to_dicts(conn.execute(sql, params).fetchall())
@staticmethod
def value(
conn: sqlite3.Connection,
sql: str,
params: Sequence[Any] | Mapping[str, Any] = (),
default: Any = None,
) -> Any:
row = conn.execute(sql, params).fetchone()
if row is None:
return default
return row[0]
@staticmethod
def column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
return any(row["name"] == column for row in rows)
@staticmethod
def table_exists(conn: sqlite3.Connection, table: str) -> bool:
return (
conn.execute(
"SELECT 1 FROM sqlite_master WHERE type IN ('table','view') AND name=?",
(table,),
).fetchone()
is not None
)
@@ -0,0 +1,120 @@
"""Die zentrale Erweiterung: haelt alles zusammen, was eine App braucht.
Statt globaler Modulvariablen (der Vorgaenger hatte ein Dutzend davon, u. a.
``_license_state`` und die DB-Pfade) haengt der gesamte Anwendungszustand an
einem Objekt in ``app.extensions["tesm"]``. Zugriff ueber ``core()``.
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Sequence
from flask import Flask, current_app, g
from .config import CoreConfig
from .db import Database
from .keystore import Keystore
from .migrations import Migration, MigrationRunner
from .nav import NavTree
from .rbac.model import PermissionTree, RolePreset
from .security.passwords import PasswordPolicy
from .settings_store import SettingsStore
if TYPE_CHECKING: # pragma: no cover
from .licensing_client import LicenseManager
from .sysops import SysOps
@dataclass
class TesmCore:
"""Anwendungsweiter Zustand. Nach ``init_app`` unveraendert."""
config: CoreConfig
database: Database
keystore: Keystore
permissions: PermissionTree
nav: NavTree
settings: SettingsStore
role_presets: tuple[RolePreset, ...] = ()
migrations: dict[str, Sequence[Migration]] = field(default_factory=dict)
license: "LicenseManager | None" = None
sysops: "SysOps | None" = None
#: App-spezifische Erweiterungen (z. B. Papierkorb-Register, Job-Registry).
registry: dict[str, Any] = field(default_factory=dict)
#: Wird nach einer erfolgreichen Anmeldung aufgerufen:
#: ``(conn, user, secret, group_dns)``. ``secret`` ist das Passwort dieser
#: Anmeldung -- es wird nirgends gespeichert und ist nach dem Aufruf weg.
#: Ein Haken, der scheitert, sperrt niemanden aus (siehe
#: ``auth.service._run_login_hooks``).
login_hooks: list[Callable[..., None]] = field(default_factory=list)
#: Wird vor dem Verwerfen der Sitzung aufgerufen: ``(conn, user)``. Was die
#: Anmeldung aufgebaut hat, wird hier wieder abgebaut -- sonst bliebe es
#: bestehen, bis eine Zeitgrenze greift.
logout_hooks: list[Callable[..., None]] = field(default_factory=list)
@property
def password_policy(self) -> PasswordPolicy:
return PasswordPolicy(min_length=self.config.security.password_min_length)
@property
def audit_archive_dir(self) -> Path:
return self.config.log_dir / "audit"
def run_migrations(self) -> list[str]:
runner = MigrationRunner(self.database)
applied: list[str] = []
for component, migrations in self.migrations.items():
applied.extend(runner.run(component, migrations))
return applied
def migration_status(self) -> list[dict[str, object]]:
return MigrationRunner(self.database).status(list(self.migrations))
def core(app: Flask | None = None) -> TesmCore:
target = app or current_app
try:
return target.extensions["tesm"] # type: ignore[return-value]
except KeyError as exc: # pragma: no cover - Programmierfehler
raise RuntimeError("tesm_core ist fuer diese Anwendung nicht initialisiert") from exc
def db() -> sqlite3.Connection:
"""Die Verbindung dieses Requests. Genau eine pro Request, nie eine zweite.
Diese Funktion ist der einzige zugelassene Weg, im Request-Kontext an eine
Verbindung zu kommen. Wer stattdessen selbst ``Database.connect()`` aufruft,
riskiert genau den Deadlock, an dem der Vorgaenger mehrfach hing.
"""
connection = getattr(g, "_tesm_conn", None)
if connection is None:
connection = core().database.connect()
g._tesm_conn = connection
return connection
def close_db(_exception: BaseException | None = None) -> None:
connection = getattr(g, "_tesm_conn", None)
if connection is not None:
g._tesm_conn = None
connection.close()
def transaction() -> Any:
"""Kurzform fuer ``core().database.transaction(db())``."""
return core().database.transaction(db())
def with_transaction(fn: Callable[..., Any]) -> Callable[..., Any]:
"""Dekorator: fuehrt eine Funktion innerhalb einer Schreibtransaktion aus."""
def wrapper(*args: Any, **kwargs: Any) -> Any:
with transaction() as conn:
return fn(conn, *args, **kwargs)
wrapper.__name__ = fn.__name__
wrapper.__doc__ = fn.__doc__
return wrapper
@@ -0,0 +1,212 @@
"""Schluesselverwaltung und Feldverschluesselung (AES-256-GCM, rotierbar).
Der Vorgaenger nutzte Fernet mit einem einzigen, nie rotierbaren Schluessel in
``fernet.key``. Fernet ist in Ordnung, aber:
* Ein Schluesselwechsel war nicht vorgesehen -- ein kompromittierter Schluessel
haette bedeutet, alle Zugangsdaten von Hand neu einzugeben.
* Es gab keine zugeordneten Daten (AAD), also keine kryptographische Bindung
eines Chiffrats an den Ort, an dem es steht. Ein Chiffrat aus Tabelle A liess
sich in Tabelle B einspielen.
Hier: ein versionierter Schluesselbund (``data.keys``, Modus 0600) mit
AES-256-GCM. Jedes Chiffrat traegt die Schluessel-ID im Klartext-Praefix, damit
alte Werte nach einer Rotation weiter lesbar bleiben. ``aad`` bindet ein
Chiffrat an seinen Verwendungszweck (z. B. ``credentials.password:42``).
Format: ``v1.<key_id>.<base64url(nonce)>.<base64url(ciphertext)>``
"""
from __future__ import annotations
import base64
import json
import os
import secrets
import stat
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
_PREFIX = "v1"
_NONCE_BYTES = 12
_KEY_BYTES = 32
class DecryptionError(Exception):
"""Chiffrat gehoert nicht zu diesem Schluesselbund oder wurde manipuliert."""
def _b64e(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _b64d(text: str) -> bytes:
padded = text + "=" * (-len(text) % 4)
return base64.urlsafe_b64decode(padded.encode("ascii"))
def write_private_file(path: Path, content: str) -> None:
"""Schreibt eine Datei mit Modus 0600, atomar (erst tmp, dann ersetzen)."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
handle = os.open(tmp, flags, 0o600)
try:
with os.fdopen(handle, "w", encoding="utf-8") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
except BaseException:
tmp.unlink(missing_ok=True)
raise
os.replace(tmp, path)
try:
os.chmod(path, 0o600)
except OSError: # pragma: no cover - Windows
pass
def check_private_file(path: Path) -> list[str]:
"""Meldet zu weite Dateirechte -- die Diagnoseseite zeigt das an."""
problems: list[str] = []
try:
mode = path.stat().st_mode
except OSError:
return problems
if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH):
problems.append(f"{path} ist fuer Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
return problems
@dataclass(frozen=True, slots=True)
class _Key:
key_id: str
material: bytes
created_at: str
class Keystore:
"""Rotierbarer Schluesselbund fuer Feldverschluesselung."""
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._keys: dict[str, _Key] = {}
self._active: str = ""
self._load_or_create()
# ------------------------------------------------------------------ #
def _load_or_create(self) -> None:
if self.path.exists():
raw = json.loads(self.path.read_text(encoding="utf-8"))
for entry in raw.get("keys", []):
key = _Key(
key_id=str(entry["id"]),
material=_b64d(str(entry["key"])),
created_at=str(entry.get("created_at", "")),
)
if len(key.material) != _KEY_BYTES:
raise ValueError(f"Schluessel {key.key_id} hat die falsche Laenge")
self._keys[key.key_id] = key
self._active = str(raw.get("active") or "")
if self._active not in self._keys:
raise ValueError("Aktiver Schluessel fehlt im Schluesselbund")
return
self._active = "1"
self._keys = {
"1": _Key(
key_id="1",
material=secrets.token_bytes(_KEY_BYTES),
created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
)
}
self._persist()
def _persist(self) -> None:
payload = {
"active": self._active,
"keys": [
{"id": key.key_id, "key": _b64e(key.material), "created_at": key.created_at}
for key in sorted(self._keys.values(), key=lambda k: int(k.key_id))
],
}
write_private_file(self.path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
# ------------------------------------------------------------------ #
@property
def active_key_id(self) -> str:
return self._active
def key_ids(self) -> list[str]:
return sorted(self._keys, key=int)
def rotate(self) -> str:
"""Legt einen neuen aktiven Schluessel an. Alte bleiben zum Lesen erhalten."""
new_id = str(max(int(k) for k in self._keys) + 1)
self._keys[new_id] = _Key(
key_id=new_id,
material=secrets.token_bytes(_KEY_BYTES),
created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
)
self._active = new_id
self._persist()
return new_id
def drop_key(self, key_id: str) -> None:
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
if key_id == self._active:
raise ValueError("Der aktive Schluessel kann nicht entfernt werden")
self._keys.pop(key_id, None)
self._persist()
# ------------------------------------------------------------------ #
def encrypt(self, plaintext: str, *, aad: str) -> str:
key = self._keys[self._active]
nonce = secrets.token_bytes(_NONCE_BYTES)
ciphertext = AESGCM(key.material).encrypt(
nonce, plaintext.encode("utf-8"), aad.encode("utf-8")
)
return f"{_PREFIX}.{key.key_id}.{_b64e(nonce)}.{_b64e(ciphertext)}"
def decrypt(self, token: str, *, aad: str) -> str:
try:
prefix, key_id, nonce_b64, ct_b64 = token.split(".", 3)
except ValueError as exc:
raise DecryptionError("Chiffrat hat ein unbekanntes Format") from exc
if prefix != _PREFIX:
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
key = self._keys.get(key_id)
if key is None:
raise DecryptionError(f"Schluessel {key_id} ist nicht im Schluesselbund")
try:
plain = AESGCM(key.material).decrypt(
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
)
except (InvalidTag, ValueError) as exc:
raise DecryptionError(
"Chiffrat konnte nicht entschluesselt werden (falscher Schluessel, "
"manipulierte Daten oder falscher Verwendungszweck)"
) from exc
return plain.decode("utf-8")
def needs_reencrypt(self, token: str) -> bool:
"""True, wenn das Chiffrat noch unter einem alten Schluessel liegt."""
parts = token.split(".", 3)
return len(parts) == 4 and parts[1] != self._active
def load_or_create_secret_key(path: Path) -> bytes:
"""Flask-Secret-Key: 64 Byte, Datei mit Modus 0600, beim ersten Start erzeugt."""
if path.exists():
raw = path.read_text(encoding="utf-8").strip()
if raw:
return _b64d(raw)
material = secrets.token_bytes(64)
write_private_file(path, _b64e(material) + "\n")
return material
@@ -0,0 +1,778 @@
"""Lizenzverwaltung auf Client-Seite (TESM) und fuer die eigene Lizenz des Servers.
Zustaendig fuer: Lizenzdatei laden und verifizieren, Aussteller pinnen,
Aktivierung/Deaktivierung/Heartbeat (online und offline), Modul-Freischaltung
und die Anzeige in der Topbar.
Wesentliche Aenderungen gegenueber dem Vorgaenger:
* **Vertrauensanker.** Die Signatur wird gegen gepinnte Aussteller-Schluessel
geprueft (Tabelle ``trusted_issuer_keys``). Beim ersten Import wird der
Schluessel per Trust-on-First-Use uebernommen und dabei protokolliert; jede
spaetere Lizenz eines *anderen* Ausstellers wird abgelehnt, bis ein
Administrator den neuen Schluessel ausdruecklich freigibt. Der Vorgaenger
pruefte ausschliesslich gegen den Schluessel *in derselben Datei* -- damit war
jede selbst signierte Datei "gueltig".
* **Trennung von Dokument und Client-Schluessel** (siehe ``tesm_licensing``):
``license.json`` (0640) und ``license_key.json`` (0600).
* **Ein Gate, eine Wahrheit.** Der Vorgaenger hatte eine Docstring, die mehr
behauptete als der Code durchsetzte (LDAP/NGINX waren nur im Template
gegated). Hier gibt es genau eine Funktion ``require_license()`` und eine
Tabelle in ``LICENSED_FEATURES`` -- was dort steht, wird serverseitig
durchgesetzt, ohne Ausnahme.
"""
from __future__ import annotations
import json
import logging
import sqlite3
import threading
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
import tesm_licensing as lic
from . import audit
from .db import Database
from .keystore import write_private_file
logger = logging.getLogger(__name__)
SETTING_STATUS = "license_activation_status"
SETTING_PENDING = "license_pending_request"
SETTING_LAST_HEARTBEAT = "license_last_heartbeat_at"
SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
SETTING_EXPORT_GRACE = "license_export_grace"
STATUS_INACTIVE = "inactive"
STATUS_PENDING = "pending"
STATUS_ACTIVE = "active"
API_PATHS = {
"activate": "/api/v1/licenses/activate",
"deactivate": "/api/v1/licenses/deactivate",
"heartbeat": "/api/v1/licenses/heartbeat",
}
#: Funktionen, die eine gueltige, aktivierte Lizenz brauchen. Diese Tabelle ist
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
LICENSED_FEATURES: dict[str, str] = {
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
"nginx": "Webserver-Einstellungen aendern",
"export": "Daten exportieren",
"trash_restore": "Papierkorb wiederherstellen und endgueltig loeschen",
"monitor_interval": "Pruefintervall der Ueberwachung aendern",
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
}
def describe_transport_error(exc: BaseException, endpoint: str) -> str:
"""Uebersetzt einen Netzfehler in etwas, mit dem man arbeiten kann.
"War nicht erreichbar" ist bei einem Zertifikatsfehler schlicht falsch --
der Server war sehr wohl erreichbar, nur nicht vertrauenswuerdig. Wer das
nicht weiss, sucht am falschen Ende.
"""
text = str(exc)
if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text:
detail = "Das Zertifikat konnte nicht geprueft werden"
if "self-signed" in text or "self signed" in text:
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
return (
f"{detail} ({endpoint}). Hinterlegen Sie sein Zertifikat unter "
"Einstellungen -> Lizenz, oder geben Sie dem Lizenzserver ein Zertifikat "
"einer anerkannten Stelle."
)
if "Name or service not known" in text or "nodename nor servname" in text:
return f"Der Name im Endpunkt ({endpoint}) laesst sich nicht aufloesen."
if "Connection refused" in text:
return f"{endpoint} nimmt keine Verbindung an."
if "timed out" in text or "timeout" in text.lower():
return f"{endpoint} hat nicht rechtzeitig geantwortet."
return f"Der Lizenzserver ({endpoint}) war nicht erreichbar: {text[:160]}"
class LicenseError(Exception):
"""Fehler beim Import oder bei der Aktivierung -- Text ist fuer Anwender gedacht."""
@dataclass(slots=True)
class LicenseState:
"""Zwischengespeicherter Lizenzzustand. Wird bei jeder Aenderung neu gebaut."""
present: bool = False
valid: bool = False
issuer_pinned: bool = False
activated: bool = False
document: dict[str, Any] = field(default_factory=dict)
status: lic.LicenseStatus | None = None
error: str = ""
warnings: tuple[str, ...] = ()
last_heartbeat_at: str = ""
heartbeat_error: str = ""
pending_action: str = ""
@property
def role(self) -> str:
return self.status.role if self.status else "customer"
@property
def modules(self) -> frozenset[str]:
return self.status.modules if self.status else frozenset()
@property
def customer_name(self) -> str:
return str((self.status.customer if self.status else {}).get("name") or "")
@property
def vendor(self) -> dict[str, Any]:
return dict(self.status.vendor) if self.status else {}
class LicenseManager:
"""Haelt den Lizenzzustand und fuehrt die Handshakes durch."""
def __init__(
self,
*,
database: Database,
settings: Any,
license_path: Path,
key_path: Path,
app_key: str,
built_in_trusted_keys: tuple[str, ...] = (),
) -> None:
self.database = database
self.settings = settings
self.license_path = Path(license_path)
self.key_path = Path(key_path)
self.app_key = app_key
self.built_in_trusted_keys = tuple(built_in_trusted_keys)
self._state = LicenseState()
self._lock = threading.RLock()
self._heartbeat_thread: threading.Thread | None = None
self._report_builder: Callable[[sqlite3.Connection], dict[str, Any]] | None = None
# ------------------------------------------------------------------ #
# Zustand
# ------------------------------------------------------------------ #
@property
def state(self) -> LicenseState:
return self._state
def trusted_keys(self, conn: sqlite3.Connection) -> list[str]:
rows = Database.all(conn, "SELECT public_key FROM trusted_issuer_keys")
return [*self.built_in_trusted_keys, *(str(row["public_key"]) for row in rows)]
def reload(self, conn: sqlite3.Connection) -> LicenseState:
"""Liest Lizenzdatei und Status neu ein."""
with self._lock:
state = LicenseState()
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
pending = self._pending(conn)
state.pending_action = str(pending.get("action") or "") if pending else ""
if not self.license_path.is_file():
state.error = "Es ist keine Lizenzdatei installiert."
self._state = state
return state
try:
document = json.loads(self.license_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
state.error = f"Die Lizenzdatei ist unlesbar: {exc}"
self._state = state
return state
if isinstance(document, dict) and "license" in document:
# Jemand hat das Auslieferungs-Bundle direkt hierher kopiert.
document, _ = lic.split_bundle(document)
state.present = True
state.document = document
trusted = self.trusted_keys(conn)
try:
lic.verify_license(document, trusted_master_keys=trusted or None)
state.valid = True
state.issuer_pinned = bool(trusted)
except lic.UntrustedIssuer as exc:
state.error = str(exc)
except lic.LicensingError as exc:
state.error = str(exc)
if state.valid:
state.status = lic.license_status(
document, issuer_pinned=state.issuer_pinned
)
state.warnings = state.status.warnings
state.activated = (
str(self.settings.get(conn, SETTING_STATUS) or STATUS_INACTIVE) == STATUS_ACTIVE
)
self._state = state
return state
# ------------------------------------------------------------------ #
# Gates
# ------------------------------------------------------------------ #
def active(self) -> bool:
"""Grundvoraussetzung fuer alles Lizenzpflichtige."""
state = self._state
return bool(
state.valid
and state.activated
and state.status is not None
and state.status.modules_active
)
def module_active(self, name: str) -> bool:
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?"""
return self.active() and name in self._state.modules
def feature_allowed(self, feature: str) -> bool:
"""Lizenzpflichtige Einzelfunktion.
Ausnahme ``export``: bleibt erlaubt, wenn der lizenzlose Zustand durch
eine vom Kunden selbst ausgeloeste Deaktivierung entstanden ist
(Systemwechsel) -- sonst waeren die eigenen Daten eingeschlossen.
"""
if feature == "export" and not self.active():
with self.database.session() as conn:
return str(self.settings.get(conn, SETTING_EXPORT_GRACE) or "") == "1"
return self.active()
def require(self, feature: str) -> None:
if not self.feature_allowed(feature):
raise LicenseError(
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gueltige, "
"aktivierte Lizenz nicht moeglich."
)
# ------------------------------------------------------------------ #
# Import
# ------------------------------------------------------------------ #
def import_bundle(
self,
conn: sqlite3.Connection,
raw: bytes | str,
*,
actor: str,
allow_new_issuer: bool = False,
) -> LicenseState:
"""Nimmt eine hochgeladene Lizenzdatei an.
``allow_new_issuer`` ist nur fuer den ausdruecklichen Fall gedacht, dass
ein Administrator einen Aussteller-Wechsel bestaetigt (z. B. Wechsel des
Lizenzservers). Ohne dieses Kennzeichen wird eine Datei mit unbekanntem
Aussteller abgelehnt, sobald schon einer gepinnt ist.
"""
text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
try:
payload = json.loads(text)
except ValueError as exc:
raise LicenseError("Die Datei ist kein gueltiges JSON.") from exc
try:
document, client_key = lic.split_bundle(payload)
except lic.LicensingError as exc:
raise LicenseError(str(exc)) from exc
try:
lic.verify_license(document) # innere Konsistenz
except lic.LicensingError as exc:
raise LicenseError(f"Die Lizenzdatei ist ungueltig: {exc}") from exc
master_pubkey = str(document.get("master_pubkey") or "")
known = self.trusted_keys(conn)
if known and master_pubkey not in set(known) and not allow_new_issuer:
raise LicenseError(
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
f"(Schluessel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
"Wechsel des Ausstellers ausdruecklich bestaetigen."
)
if not client_key and not self.key_path.is_file():
raise LicenseError(
"Der Datei fehlt der Client-Schluessel. Bitte das vollstaendige "
"Lizenz-Bundle des Lizenzservers verwenden."
)
# Reihenfolge: erst Schluessel pinnen, dann Dateien schreiben, dann
# Aktivierungsstatus zuruecksetzen -- ein Abbruch dazwischen hinterlaesst
# keinen halb gueltigen Zustand.
if master_pubkey not in set(known):
conn.execute(
"INSERT INTO trusted_issuer_keys (key_id, public_key, label, source, added_at, added_by) "
"VALUES (?,?,?,?,?,?) ON CONFLICT(key_id) DO NOTHING",
(
lic.key_id(master_pubkey),
master_pubkey,
str((document.get("vendor") or {}).get("name") or "Lizenzserver"),
"tofu" if not known else "admin-confirmed",
datetime.now(timezone.utc).isoformat(timespec="seconds"),
actor,
),
)
audit.log(
conn,
actor=actor,
action="license.issuer_pinned",
target=lic.key_id(master_pubkey),
detail="Trust on first use" if not known else "Aussteller-Wechsel bestaetigt",
severity="notice",
)
self.license_path.parent.mkdir(parents=True, exist_ok=True)
self.license_path.write_text(
json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
encoding="utf-8",
)
try:
self.license_path.chmod(0o640)
except OSError:
pass
if client_key:
write_private_file(
self.key_path,
json.dumps({"license_id": document.get("license_id"), "client_key": client_key})
+ "\n",
)
self.settings.set(conn, SETTING_STATUS, STATUS_INACTIVE, actor=actor)
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
self.settings.set(conn, SETTING_EXPORT_GRACE, "", actor=actor)
audit.log(
conn,
actor=actor,
action="license.imported",
target=str(document.get("license_id") or ""),
detail=f"Typ {document.get('type')}, Rolle {document.get('role')}",
severity="notice",
)
return self.reload(conn)
def client_key(self) -> str:
if not self.key_path.is_file():
return ""
try:
data = json.loads(self.key_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return ""
return str(data.get("client_key") or "")
# ------------------------------------------------------------------ #
# Handshake
# ------------------------------------------------------------------ #
def _pending(self, conn: sqlite3.Connection) -> dict[str, Any]:
raw = self.settings.get(conn, SETTING_PENDING) or ""
if not raw:
return {}
try:
return json.loads(raw) if isinstance(raw, str) else dict(raw)
except ValueError:
return {}
def _endpoint(self, action: str) -> str:
base = str(self._state.document.get("master_endpoint") or "").rstrip("/")
if not base:
return ""
return base + API_PATHS[action]
def build_request(
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
) -> dict[str, Any]:
if not self._state.valid:
raise LicenseError("Es ist keine gueltige Lizenz installiert.")
key = self.client_key()
if not key:
raise LicenseError(
"Der Client-Schluessel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren."
)
return lic.build_client_request(
action, self._state.document, key, hostname=hostname, report=report
)
def handshake(
self,
conn: sqlite3.Connection,
action: str,
*,
actor: str,
hostname: str = "",
report: dict[str, Any] | None = None,
timeout: int = 12,
) -> dict[str, Any]:
"""Fuehrt eine Aktion durch. Rueckgabe beschreibt das Ergebnis fuer die UI.
``{"ok": bool, "transport": "online"|"offline", "code": str, "message": str}``
"""
request_payload = self.build_request(action, hostname=hostname, report=report)
endpoint = self._endpoint(action)
reason = ""
if endpoint:
try:
response = self._post_json(
endpoint,
request_payload,
timeout=timeout,
ca_pem=str(self.settings.get(conn, "license_server_ca") or ""),
)
except Exception as exc: # noqa: BLE001 - jeder Netzfehler fuehrt offline weiter
reason = describe_transport_error(exc, endpoint)
logger.info("Online-Handshake fehlgeschlagen (%s): %s", action, exc)
else:
return self._apply_response(
conn, action, request_payload, response, actor=actor, transport="online"
)
else:
reason = (
"In dieser Lizenz ist kein Lizenzserver hinterlegt -- sie kann sich nur "
"ueber den Offline-Weg aktivieren."
)
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
# Antwort liegen in der Praxis eine E-Mail und ein Arbeitstag; der Code
# muss ein Neuladen der Seite ueberstehen.
code = lic.encode_code(request_payload)
self.settings.set(
conn,
SETTING_PENDING,
json.dumps(
{
"action": action,
"nonce": request_payload["nonce"],
"fingerprint": request_payload["fingerprint"],
"license_id": request_payload["license_id"],
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"code": code,
"reason": reason,
}
),
actor=actor,
)
if action == "activate":
self.settings.set(conn, SETTING_STATUS, STATUS_PENDING, actor=actor)
self.reload(conn)
return {
"ok": False,
"transport": "offline",
"code": code,
"message": (
(reason + " " if reason else "")
+ "Der Offline-Weg steht offen: uebermitteln Sie den angezeigten Code an "
"Ihren Ansprechpartner und tragen Sie die Antwort hier ein."
),
}
def submit_offline_response(
self, conn: sqlite3.Connection, code: str, *, actor: str
) -> dict[str, Any]:
"""Nimmt den Antwortcode des Lizenzservers an (Offline-Weg)."""
pending = self._pending(conn)
if not pending:
raise LicenseError("Es liegt keine offene Anfrage vor.")
try:
response = lic.decode_code(code)
except ValueError as exc:
raise LicenseError(f"Der Code ist unlesbar: {exc}") from exc
return self._apply_response(
conn,
str(pending.get("action") or ""),
{
"license_id": str(pending.get("license_id") or ""),
"fingerprint": str(pending.get("fingerprint") or ""),
"nonce": str(pending.get("nonce") or ""),
},
response,
actor=actor,
transport="offline",
)
def _apply_response(
self,
conn: sqlite3.Connection,
action: str,
request_payload: dict[str, Any],
response: dict[str, Any],
*,
actor: str,
transport: str,
) -> dict[str, Any]:
master_pubkey = str(self._state.document.get("master_pubkey") or "")
current_seq = int(self._state.document.get("seq") or 1)
try:
lic.verify_master_response(
response,
master_pubkey,
expected_license_id=str(request_payload["license_id"]),
expected_fingerprint=str(request_payload["fingerprint"]),
expected_action=action,
expected_nonce=str(request_payload["nonce"]),
current_seq=current_seq,
)
except lic.LicensingError as exc:
raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gueltig: {exc}") from exc
status = str(response.get("status") or "")
detail = str(response.get("detail") or "")
update = response.get("license_update")
if isinstance(update, dict):
try:
lic.verify_license(update, trusted_master_keys=self.trusted_keys(conn) or None)
except lic.LicensingError as exc:
logger.warning("Mitgeliefertes Lizenz-Update verworfen: %s", exc)
else:
self.license_path.write_text(
json.dumps(update, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
encoding="utf-8",
)
audit.log(
conn,
actor=actor,
action="license.updated_by_master",
target=str(update.get("license_id") or ""),
detail=f"Revision {update.get('seq')}",
severity="notice",
)
if status == "revoked":
self.settings.set(conn, SETTING_STATUS, STATUS_INACTIVE, actor=actor)
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
audit.log(
conn,
actor=actor,
action="license.revoked_by_master",
target=str(request_payload["license_id"]),
severity="warning",
)
self.reload(conn)
return {
"ok": False,
"transport": transport,
"code": "",
"message": "Diese Lizenz wurde vom Lizenzserver widerrufen.",
}
if status != "ok":
self.reload(conn)
return {
"ok": False,
"transport": transport,
"code": "",
"message": detail or f"Der Lizenzserver hat die Anfrage abgelehnt ({status}).",
}
if action == "activate":
self.settings.set(conn, SETTING_STATUS, STATUS_ACTIVE, actor=actor)
self.settings.set(conn, SETTING_EXPORT_GRACE, "", actor=actor)
message = "Die Lizenz ist jetzt aktiv."
elif action == "deactivate":
self.settings.set(conn, SETTING_STATUS, STATUS_INACTIVE, actor=actor)
# Der Kunde hat selbst deaktiviert (Systemwechsel): Export bleibt
# erlaubt, damit er seine Daten mitnehmen kann.
self.settings.set(conn, SETTING_EXPORT_GRACE, "1", actor=actor)
message = "Die Lizenz wurde deaktiviert und kann auf einem anderen System aktiviert werden."
else:
self.settings.set(
conn,
SETTING_LAST_HEARTBEAT,
datetime.now(timezone.utc).isoformat(timespec="seconds"),
actor=actor,
)
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor)
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",
)
self.reload(conn)
return {"ok": True, "transport": transport, "code": "", "message": message}
@staticmethod
def _tls_context(ca_pem: str) -> Any:
"""Prueft gegen ein hinterlegtes Zertifikat -- oder gegen die Systemanker.
Nie ohne Pruefung: das Lizenzprotokoll ist zwar selbst signiert und
gegen Wiedereinspielung geschuetzt, aber das ist kein Grund, den
Transport offen zu lassen.
"""
import ssl
context = ssl.create_default_context()
pem = (ca_pem or "").strip()
if not pem:
return context
try:
# cadata nimmt PEM direkt entgegen -- keine temporaere Datei, die
# liegen bleiben oder mitgelesen werden koennte.
context.load_verify_locations(cadata=pem)
except (ssl.SSLError, ValueError) as exc:
raise ValueError(
"Das hinterlegte Zertifikat des Lizenzservers ist unlesbar: " + str(exc)
) from exc
return context
@staticmethod
def _post_json(
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
) -> dict[str, Any]:
if not url.startswith(("https://", "http://")):
raise ValueError("Ungueltiger Endpunkt")
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
url,
data=data,
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
context = LicenseManager._tls_context(ca_pem) if url.startswith("https://") else None
try:
with urllib.request.urlopen( # noqa: S310
request, timeout=timeout, context=context
) as response:
body = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
try:
parsed = json.loads(body)
except ValueError:
raise
if isinstance(parsed, dict) and "signature" in parsed:
return parsed # Auch abweisende Antworten sind signiert.
raise
return json.loads(body)
# ------------------------------------------------------------------ #
# Heartbeat im Hintergrund
# ------------------------------------------------------------------ #
def set_report_builder(self, builder: Callable[[sqlite3.Connection], dict[str, Any]]) -> None:
"""Nur der Lizenzserver setzt das: Bericht an den eigenen Master."""
self._report_builder = builder
def start_heartbeat(self, *, hostname: str = "") -> None:
if self._heartbeat_thread is not None:
return
def loop() -> None:
# Erster Lauf verzoegert: der Dienst soll erst sauber hochkommen.
time.sleep(90)
while True:
try:
self.run_heartbeat(hostname=hostname)
except Exception as exc: # noqa: BLE001
logger.warning("Heartbeat fehlgeschlagen: %s", exc)
time.sleep(lic.HEARTBEAT_INTERVAL_SECONDS)
thread = threading.Thread(target=loop, name="license-heartbeat", daemon=True)
thread.start()
self._heartbeat_thread = thread
logger.info("Lizenz-Heartbeat gestartet (Intervall %ss)", lic.HEARTBEAT_INTERVAL_SECONDS)
def run_heartbeat(self, *, hostname: str = "") -> dict[str, Any]:
with self.database.session() as conn:
self.reload(conn)
if not self._state.valid or not self._state.activated:
return {"ok": False, "message": "Keine aktive Lizenz."}
report = None
if self._report_builder is not None and self._state.role == "license_server":
report = self._report_builder(conn)
try:
with self.database.transaction(conn):
return self.handshake(
conn, "heartbeat", actor="system", hostname=hostname, report=report
)
except LicenseError as exc:
with self.database.transaction(conn):
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, str(exc), actor="system")
return {"ok": False, "message": str(exc)}
# ------------------------------------------------------------------ #
# Anzeige
# ------------------------------------------------------------------ #
def topbar_pill(self) -> dict[str, str] | None:
"""Zustandsanzeige fuer die Topbar. Reihenfolge = Dringlichkeit."""
state = self._state
if not state.present:
return {
"label": "Keine Lizenz",
"css_class": "status-pill--danger",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": "Es ist keine Lizenzdatei installiert.",
}
if not state.valid:
return {
"label": "Lizenz ungueltig",
"css_class": "status-pill--danger",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": state.error,
}
if not state.activated:
return {
"label": "Lizenz nicht aktiviert",
"css_class": "status-pill--warning",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": "Die Lizenz muss noch aktiviert werden.",
}
status = state.status
if status is None:
return None
if status.expired:
days = status.days_since_expiry or 0
return {
"label": f"Lizenz abgelaufen (seit {days} Tagen)",
"css_class": "status-pill--danger",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": (
"Die Kulanzfrist laeuft noch, Funktionen bleiben vorerst verfuegbar."
if status.grace_active
else "Lizenzpflichtige Funktionen sind abgeschaltet."
),
}
if status.expiring_soon:
return {
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
"css_class": "status-pill--warning",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": "Bitte rechtzeitig verlaengern.",
}
if state.last_heartbeat_at:
try:
last = lic.parse_iso(state.last_heartbeat_at)
days = (lic.utcnow() - last).days
except ValueError:
days = 0
if days >= lic.HEARTBEAT_WARNING_DAYS:
return {
"label": f"Lizenzserver seit {days} Tagen nicht erreichbar",
"css_class": "status-pill--warning",
"dot": "1",
"dot_class": "",
"title": "Rein informativ -- es wird nichts abgeschaltet.",
}
return None
@@ -0,0 +1,136 @@
"""Protokollierung: strukturierte Zeilen, Vorgangsnummer, Rotation via logrotate.
Jede Anfrage bekommt eine ``request_id``. Sie steht in jeder Logzeile, in
Fehlerseiten und im Audit-Log -- damit ein Anruf "bei mir kam Fehler 500"
in Sekunden auffindbar ist. Der Vorgaenger loggte ohne Korrelations-ID.
Rotation macht ``logrotate`` mit ``copytruncate`` (siehe ``deploy/``), nicht
Python: mehrere Prozesse (Web, Monitor) schreiben in dasselbe Verzeichnis, und
keiner von ihnen kann ein Reopen-Signal verarbeiten.
"""
from __future__ import annotations
import json
import logging
import logging.handlers
import os
import sys
import time
import uuid
from pathlib import Path
from flask import Flask, g, has_request_context, request
ACCESS_LOGGER = "tesm.access"
class RequestFormatter(logging.Formatter):
"""Textformat fuer Menschen, mit Vorgangsnummer und Benutzer."""
def format(self, record: logging.LogRecord) -> str:
if has_request_context():
record.request_id = getattr(g, "request_id", "-")
record.actor = getattr(getattr(g, "user", None), "username", "-") or "-"
else:
record.request_id = getattr(record, "request_id", "-")
record.actor = getattr(record, "actor", "-")
return super().format(record)
class JsonFormatter(logging.Formatter):
"""Maschinenlesbares Format -- fuer die Weiterverarbeitung in einem SIEM."""
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime(record.created)),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if has_request_context():
payload["request_id"] = getattr(g, "request_id", "")
payload["path"] = request.path
payload["method"] = request.method
payload["actor"] = getattr(getattr(g, "user", None), "username", "") or ""
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
for key, value in getattr(record, "extra_fields", {}).items():
payload[key] = value
return json.dumps(payload, ensure_ascii=False)
def init_app(app: Flask, *, log_dir: Path, debug: bool, json_logs: bool = False) -> None:
log_dir.mkdir(parents=True, exist_ok=True)
level = logging.DEBUG if debug else logging.INFO
root = logging.getLogger()
root.setLevel(level)
for handler in list(root.handlers):
root.removeHandler(handler)
formatter: logging.Formatter = (
JsonFormatter()
if json_logs
else RequestFormatter(
"%(asctime)s %(levelname)-7s [%(request_id)s] %(actor)s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
stream = logging.StreamHandler(sys.stderr)
stream.setFormatter(formatter)
root.addHandler(stream)
try:
app_log = log_dir / "app.log"
file_handler = logging.FileHandler(app_log, encoding="utf-8", delay=True)
file_handler.setFormatter(formatter)
file_handler.setLevel(level)
root.addHandler(file_handler)
if app_log.exists():
try:
os.chmod(app_log, 0o640)
except OSError:
pass
except OSError as exc: # pragma: no cover - z. B. fehlende Rechte
root.warning("Konnte app.log nicht oeffnen: %s", exc)
# Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces.
access = logging.getLogger(ACCESS_LOGGER)
access.propagate = False
access.setLevel(logging.INFO)
try:
access_handler = logging.FileHandler(log_dir / "access.log", encoding="utf-8", delay=True)
access_handler.setFormatter(
JsonFormatter() if json_logs else logging.Formatter("%(asctime)s %(message)s")
)
access.addHandler(access_handler)
except OSError: # pragma: no cover
pass
logging.getLogger("werkzeug").setLevel(logging.WARNING)
@app.before_request
def _assign_request_id() -> None:
incoming = request.headers.get("X-Request-Id", "")
g.request_id = incoming[:64] if incoming.isascii() and incoming else uuid.uuid4().hex[:16]
g.request_started = time.perf_counter()
@app.after_request
def _log_access(response): # type: ignore[no-untyped-def]
if request.path.startswith("/static/"):
return response
duration = (time.perf_counter() - getattr(g, "request_started", time.perf_counter())) * 1000
actor = getattr(getattr(g, "user", None), "username", "") or "-"
access.info(
"%s %s %s %s %s %.1fms",
getattr(g, "request_id", "-"),
actor,
request.method,
request.full_path if request.query_string else request.path,
response.status_code,
duration,
)
response.headers.setdefault("X-Request-Id", getattr(g, "request_id", ""))
return response
@@ -0,0 +1,234 @@
"""Versionierte Schema-Migrationen.
Der Vorgaenger hatte eine ``_ensure_schema()``-Funktion, die bei jedem Start
rund 35 idempotente ``ALTER TABLE``-Blocks durchlief, plus Sentinel-Zeilen in
der ``settings``-Tabelle als Marker fuer Einmal-Migrationen. Das funktioniert,
ist aber nicht nachvollziehbar: welche Migration lief wann, in welcher
Reihenfolge, mit welchem Inhalt?
Hier: eine ``schema_migrations``-Tabelle mit ``(component, version)``, einer
Pruefsumme des Migrationsinhalts und Zeitstempel. Jede Migration laeuft genau
einmal, in einer eigenen Transaktion, in Versionsreihenfolge.
``component`` trennt die Namensraeume: ``core`` bringt Benutzer/Gruppen/
Sitzungen/Audit mit, jede Anwendung ihre Fachtabellen. Dadurch koennen beide
Anwendungen dieselben Core-Migrationen nutzen, ohne dass sich Versionsnummern
in die Quere kommen.
"""
from __future__ import annotations
import hashlib
import logging
import sqlite3
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from datetime import datetime, timezone
from .db import Database
logger = logging.getLogger(__name__)
MigrationFn = Callable[[sqlite3.Connection], None]
@dataclass(frozen=True, slots=True)
class Migration:
"""Eine Schemaaenderung.
``sql`` wird als Skript ausgefuehrt, danach ggf. ``fn`` fuer Datenmigrationen,
die sich in reinem SQL nicht sinnvoll ausdruecken lassen.
``allow_integrity_error``: fuer nachtraeglich eingefuehrte Unique-Indizes.
Trifft so ein Index auf bereits vorhandene, verletzende Altdaten, darf der
Anwendungsstart nicht crashen -- er loggt und laeuft weiter. Genau dieses
Muster hat sich im Vorgaengerprojekt bewaehrt.
"""
version: int
name: str
sql: str = ""
fn: MigrationFn | None = None
allow_integrity_error: bool = False
tags: tuple[str, ...] = field(default=())
@property
def checksum(self) -> str:
payload = f"{self.version}:{self.name}:{self.sql}".encode("utf-8")
if self.fn is not None:
payload += f":fn={self.fn.__name__}".encode("utf-8")
return hashlib.sha256(payload).hexdigest()[:16]
def split_statements(script: str) -> list[str]:
"""Zerlegt ein SQL-Skript in einzelne Anweisungen.
Warum nicht ``executescript``: das setzt in Pythons sqlite3-Treiber
**implizit ein COMMIT** ab, bevor es laeuft. Innerhalb einer laufenden
Transaktion zerreisst das genau die Atomizitaet, die eine Migration
braucht. Also: selbst zerlegen und Anweisung fuer Anweisung ausfuehren.
Der Splitter beruecksichtigt Zeichenketten, Zeilen- und Blockkommentare;
``BEGIN ... END``-Bloecke (Trigger) werden zusammengehalten.
"""
statements: list[str] = []
current: list[str] = []
index = 0
length = len(script)
depth = 0 # BEGIN/END-Tiefe fuer Trigger
while index < length:
char = script[index]
nxt = script[index + 1] if index + 1 < length else ""
if char == "-" and nxt == "-":
end = script.find("\n", index)
index = length if end == -1 else end + 1
continue
if char == "/" and nxt == "*":
end = script.find("*/", index + 2)
index = length if end == -1 else end + 2
continue
if char in "'\"":
quote = char
current.append(char)
index += 1
while index < length:
current.append(script[index])
if script[index] == quote:
if index + 1 < length and script[index + 1] == quote:
current.append(script[index + 1])
index += 2
continue
index += 1
break
index += 1
continue
if char == ";" and depth == 0:
statement = "".join(current).strip()
if statement:
statements.append(statement)
current = []
index += 1
continue
current.append(char)
upper_tail = "".join(current[-6:]).upper()
if upper_tail.endswith("BEGIN"):
depth += 1
elif upper_tail.endswith("END") and depth > 0:
depth -= 1
index += 1
tail = "".join(current).strip()
if tail:
statements.append(tail)
return statements
def run_script(conn: sqlite3.Connection, script: str) -> None:
for statement in split_statements(script):
conn.execute(statement)
_BOOTSTRAP = """
CREATE TABLE IF NOT EXISTS schema_migrations (
component TEXT NOT NULL,
version INTEGER NOT NULL,
name TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL,
PRIMARY KEY (component, version)
);
"""
class MigrationRunner:
"""Fuehrt Migrationen mehrerer Komponenten gegen eine Datenbank aus."""
def __init__(self, database: Database) -> None:
self.database = database
def applied(self, conn: sqlite3.Connection, component: str) -> dict[int, dict[str, str]]:
rows = Database.all(
conn,
"SELECT version, name, checksum, applied_at FROM schema_migrations WHERE component=?",
(component,),
)
return {int(row["version"]): row for row in rows}
def run(self, component: str, migrations: Sequence[Migration]) -> list[str]:
"""Wendet fehlende Migrationen an. Rueckgabe: Namen der ausgefuehrten."""
ordered = sorted(migrations, key=lambda m: m.version)
versions = [m.version for m in ordered]
if len(set(versions)) != len(versions):
duplicates = sorted({v for v in versions if versions.count(v) > 1})
raise ValueError(f"Doppelte Migrationsversionen in {component}: {duplicates}")
executed: list[str] = []
with self.database.session() as conn:
run_script(conn, _BOOTSTRAP)
known = self.applied(conn, component)
for migration in ordered:
record = known.get(migration.version)
if record is not None:
if record["checksum"] != migration.checksum:
logger.warning(
"Migration %s/%s (%s) wurde nachtraeglich geaendert "
"(Pruefsumme %s != %s). Bereits angewendete Migrationen werden "
"nicht erneut ausgefuehrt -- neue Aenderungen brauchen eine neue "
"Versionsnummer.",
component,
migration.version,
migration.name,
record["checksum"],
migration.checksum,
)
continue
try:
with self.database.transaction(conn):
if migration.sql.strip():
run_script(conn, migration.sql)
if migration.fn is not None:
migration.fn(conn)
conn.execute(
"INSERT INTO schema_migrations "
"(component, version, name, checksum, applied_at) VALUES (?,?,?,?,?)",
(
component,
migration.version,
migration.name,
migration.checksum,
datetime.now(timezone.utc).isoformat(timespec="seconds"),
),
)
except sqlite3.IntegrityError:
if not migration.allow_integrity_error:
raise
logger.error(
"Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht "
"angewendet werden. Der Start laeuft weiter; die Daten muessen "
"manuell bereinigt werden.",
component,
migration.version,
migration.name,
)
continue
executed.append(f"{component}/{migration.version} {migration.name}")
logger.info("Migration angewendet: %s/%s %s", component, migration.version, migration.name)
return executed
def status(self, components: Sequence[str]) -> list[dict[str, object]]:
"""Uebersicht fuer die Diagnoseseite."""
with self.database.session() as conn:
run_script(conn, _BOOTSTRAP)
return Database.all(
conn,
"SELECT component, version, name, checksum, applied_at FROM schema_migrations "
f"WHERE component IN ({','.join('?' * len(components))}) "
"ORDER BY component, version",
tuple(components),
)
+160
View File
@@ -0,0 +1,160 @@
"""Navigation: Struktur, Sichtbarkeit, Reihenfolge.
Uebernommen vom Vorgaenger (das Muster war gut), aber mit zwei Aenderungen:
* Sichtbarkeit haengt an einer Liste von Rechten **plus** optional einer
Bedingung. Im Vorgaenger war das eine ``if/elif``-Kette in
``_nav_key_visible()`` -- jede neue Seite bedeutete einen Eingriff in diese
Funktion. Hier deklariert jeder Eintrag selbst, was er braucht.
* "Sichtbar, wenn *irgendeines* der Rechte greift" ist der Default. Im
Vorgaenger war eine Seite mit reinem Export-Recht unerreichbar, weil die
Navigation nur auf ``.view`` prueft.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, Iterable, Sequence
VisibilityFn = Callable[[Any], bool]
@dataclass(frozen=True, slots=True)
class NavItem:
key: str
label: str
icon: str
endpoint: str = ""
#: Sichtbar, wenn eines dieser Rechte greift (leer = immer sichtbar).
permissions: tuple[str, ...] = ()
#: Zusaetzliche Bedingung, z. B. Modul-Lizenz oder vorhandene Freigaben.
condition: VisibilityFn | None = field(default=None, compare=False)
children: tuple["NavItem", ...] = ()
badge: Callable[[], str] | None = field(default=None, compare=False)
hint: str = ""
@property
def is_group(self) -> bool:
return bool(self.children)
class NavTree:
"""Navigationsbaum mit laufzeitkonfigurierbarer Reihenfolge."""
def __init__(self, items: Sequence[NavItem]) -> None:
self.items: tuple[NavItem, ...] = tuple(items)
self._by_key: dict[str, NavItem] = {}
for item in self.items:
self._by_key[item.key] = item
for child in item.children:
self._by_key[child.key] = child
def item(self, key: str) -> NavItem | None:
return self._by_key.get(key)
def default_order(self) -> list[str]:
return [item.key for item in self.items]
def default_child_order(self, group_key: str) -> list[str]:
item = self._by_key.get(group_key)
return [child.key for child in item.children] if item else []
# ------------------------------------------------------------------ #
def visible(self, item: NavItem, user: Any) -> bool:
if item.permissions and not user.has_any(item.permissions):
return False
if item.condition is not None and not item.condition(user):
return False
return True
def resolve(
self,
user: Any,
*,
order: Sequence[str] | None = None,
child_order: dict[str, Sequence[str]] | None = None,
) -> list[dict[str, Any]]:
"""Liefert die gefilterte, sortierte Navigation fuer das Template."""
child_order = child_order or {}
result: list[dict[str, Any]] = []
for item in self._ordered(self.items, order):
if item.is_group:
children = [
child
for child in self._ordered(item.children, child_order.get(item.key))
if self.visible(child, user)
]
if not children:
continue
result.append(
{
"key": item.key,
"label": item.label,
"icon": item.icon,
"endpoint": "",
"hint": item.hint,
"children": [self._as_dict(child) for child in children],
}
)
continue
if not self.visible(item, user):
continue
result.append(self._as_dict(item))
return result
def _as_dict(self, item: NavItem) -> dict[str, Any]:
return {
"key": item.key,
"label": item.label,
"icon": item.icon,
"endpoint": item.endpoint,
"hint": item.hint,
"badge": item.badge() if item.badge else "",
"children": [],
}
@staticmethod
def _ordered(
items: Sequence[NavItem], order: Sequence[str] | None
) -> list[NavItem]:
if not order:
return list(items)
index = {key: position for position, key in enumerate(order)}
# Unbekannte Schluessel (neu dazugekommene Seiten) landen hinten, statt
# zu verschwinden -- der Vorgaenger verlor sie stillschweigend.
return sorted(items, key=lambda item: (index.get(item.key, len(index)), item.label))
# ------------------------------------------------------------------ #
def sanitize_order(self, keys: Iterable[str]) -> list[str]:
known = self.default_order()
chosen = [key for key in keys if key in known]
return chosen + [key for key in known if key not in chosen]
def sanitize_child_order(self, group_key: str, keys: Iterable[str]) -> list[str]:
known = self.default_child_order(group_key)
chosen = [key for key in keys if key in known]
return chosen + [key for key in known if key not in chosen]
def searchable(self, user: Any) -> list[dict[str, str]]:
"""Flache Liste aller sichtbaren Seiten -- Datenbasis der Befehlspalette."""
entries: list[dict[str, str]] = []
for item in self.items:
if item.is_group:
for child in item.children:
if self.visible(child, user):
entries.append(
{
"label": child.label,
"group": item.label,
"endpoint": child.endpoint,
"icon": child.icon,
}
)
elif self.visible(item, user):
entries.append(
{"label": item.label, "group": "", "endpoint": item.endpoint, "icon": item.icon}
)
return entries
@@ -0,0 +1,25 @@
"""Rechtesystem: Modell (Bereiche/Ressourcen/Aktionen) und Verwaltungsdienste."""
from __future__ import annotations
from .model import (
ACTION_META,
IMPLIED_BY,
SENSITIVE_ACTIONS,
Action,
Area,
PermissionTree,
Resource,
RolePreset,
)
__all__ = [
"ACTION_META",
"Action",
"Area",
"IMPLIED_BY",
"PermissionTree",
"Resource",
"RolePreset",
"SENSITIVE_ACTIONS",
]
@@ -0,0 +1,161 @@
"""Ressourcen, die beide Anwendungen teilen.
``tesm_core`` bringt Benutzer-, Gruppen-, Papierkorb-, Protokoll- und
Diagnoseseiten selbst mit. Damit deren Rechtepruefungen greifen, muessen die
zugehoerigen Ressourcenschluessel im Rechtebaum der Anwendung existieren --
darum stehen sie hier und werden von beiden Apps eingebunden statt zweimal
getippt.
"""
from __future__ import annotations
from .model import Action, Area, Resource
A = Action
USERS = Resource(
key="users",
label="Benutzer",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
hint="Konten anlegen, sperren, Gruppen zuordnen",
action_hints={
A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung aendern.",
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
},
)
GROUPS = Resource(
key="groups",
label="Gruppen und Rechte",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
hint="Rechtematrix pflegen",
action_hints={
A.EDIT: "Rechte vergeben -- nur Rechte, die man selbst besitzt.",
},
)
SETTINGS_SYSTEM = Resource(
key="settings_system",
label="Systemeinstellungen",
actions=(A.VIEW, A.EDIT, A.EXECUTE),
hint="Hostname, Zeitzone, Protokollrotation, Navigation",
action_hints={A.EXECUTE: "Systemaktionen ausloesen (Dienste, Netzwerk uebernehmen)."},
)
SETTINGS_LICENSE = Resource(
key="settings_license",
label="Lizenz",
actions=(A.VIEW, A.EDIT),
hint="Lizenzdatei einspielen, aktivieren, deaktivieren",
)
SETTINGS_DIRECTORY = Resource(
key="settings_directory",
label="Verzeichnisdienst (LDAP/AD)",
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
hint="Anbindung und Gruppen-Zuordnung",
action_hints={
A.EXECUTE: "Verbindungstest ausfuehren.",
A.SECRETS: "Bind-Passwort des Dienstkontos setzen.",
},
)
SETTINGS_WEB = Resource(
key="settings_web",
label="Webserver und TLS",
actions=(A.VIEW, A.EDIT, A.EXECUTE),
hint="Domain, Zertifikat, HSTS",
action_hints={A.EXECUTE: "Konfiguration uebernehmen, Zertifikat anfordern."},
)
SETTINGS_NETWORK = Resource(
key="settings_network",
label="Netzwerkeinstellungen",
actions=(A.VIEW, A.EDIT),
hint="IP-Adresse, Gateway, DNS des Hosts",
)
BACKUP = Resource(
key="backup",
label="Sicherung und Uebertragung",
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
hint="Verschluesselter Export und Import",
action_hints={
A.EXECUTE: "Eine Sicherung einspielen.",
A.EXPORT: "Sicherung erzeugen und herunterladen.",
A.SECRETS: "Zugangsdaten in die Sicherung einschliessen.",
},
)
TRASH = Resource(
key="trash",
label="Papierkorb",
actions=(A.VIEW, A.EDIT, A.DELETE),
hint="Geloeschtes wiederherstellen oder endgueltig entfernen",
action_hints={
A.EDIT: "Eintraege wiederherstellen.",
A.DELETE: "Eintraege endgueltig entfernen.",
},
)
DIAGNOSTICS = Resource(
key="diagnostics",
label="Diagnose",
actions=(A.VIEW,),
hint="Schemastand, Protokollintegritaet, Schluesselrechte",
)
LOGS_LIVE = Resource(
key="logs_live",
label="Laufendes Protokoll",
actions=(A.VIEW,),
)
LOGS_HISTORY = Resource(
key="logs_history",
label="Protokollverlauf",
actions=(A.VIEW, A.DELETE, A.EXPORT),
hint="Rotierte und archivierte Protokolle",
action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."},
)
LOGS_AUDIT = Resource(
key="logs_audit",
label="Aenderungsprotokoll",
actions=(A.VIEW, A.EXPORT),
hint="Wer hat wann was geaendert",
)
def administration_area(*extra: Resource) -> Area:
"""Bereich "Verwaltung" -- Basis fuer beide Anwendungen."""
return Area(
key="administration",
label="Verwaltung",
icon="sliders",
hint="Benutzer, Rechte, Systemeinstellungen. Ohne diesen Zugang bleibt der "
"gesamte Verwaltungsbereich verborgen.",
resources=(
USERS,
GROUPS,
SETTINGS_SYSTEM,
SETTINGS_LICENSE,
SETTINGS_DIRECTORY,
SETTINGS_WEB,
SETTINGS_NETWORK,
BACKUP,
TRASH,
DIAGNOSTICS,
*extra,
),
)
def observability_area(*extra: Resource) -> Area:
"""Bereich "Protokolle"."""
return Area(
key="observability",
label="Protokolle",
icon="terminal",
resources=(LOGS_LIVE, LOGS_HISTORY, LOGS_AUDIT, *extra),
)
@@ -0,0 +1,246 @@
"""Rechtemodell: Bereiche, Ressourcen, Aktionen.
Das Vorgaengermodell kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes
bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber:
* Wer jemandem erlauben wollte, Geraete zu pflegen, musste ihm zwangslaeufig
auch das Loeschen erlauben. Das ist genau die Trennung, die man in einer
Verwaltungsanwendung am haeufigsten braucht.
* Dieselbe Spalte bedeutete in verschiedenen Zeilen Verschiedenes -- eine
Rechtematrix, die man erklaeren muss, statt sie zu lesen.
Neues Modell: ein festes, ueberall gleich bedeutendes Aktionsvokabular. Welche
Aktionen eine Ressource ueberhaupt kennt, entscheidet die Ressource; die Matrix
rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen.
Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein
``<bereich>.view``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer
Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein
eingestellten Einzelrechte zu verlieren.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Iterable, Mapping, Sequence
class Action(str, Enum):
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen."""
VIEW = "view"
CREATE = "create"
EDIT = "edit"
DELETE = "delete"
EXECUTE = "execute"
EXPORT = "export"
SECRETS = "secrets"
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix.
ACTION_META: dict[Action, tuple[str, str, str]] = {
Action.VIEW: ("R", "Ansehen", "Datensaetze und Seiten dieses Bereichs lesen."),
Action.CREATE: ("C", "Anlegen", "Neue Datensaetze erstellen."),
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensaetze aendern -- ohne Loeschen."),
Action.DELETE: ("D", "Loeschen", "Datensaetze loeschen bzw. endgueltig entfernen."),
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen ausloesen (Neustart, Dienste, Jobs, Import)."),
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."),
Action.SECRETS: (
"S",
"Geheimnisse",
"Passwoerter und Schluessel im Klartext sehen oder mitnehmen.",
),
}
#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus).
SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT})
#: ``view`` ist Voraussetzung fuer alles andere in derselben Zeile.
IMPLIED_BY: dict[Action, tuple[Action, ...]] = {
action: (Action.VIEW,) for action in Action if action is not Action.VIEW
}
@dataclass(frozen=True, slots=True)
class Resource:
"""Eine Zeile der Rechtematrix."""
key: str
label: str
actions: tuple[Action, ...]
hint: str = ""
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
action_hints: Mapping[Action, str] = field(default_factory=dict)
def permission(self, action: Action) -> str:
return f"{self.key}.{action.value}"
def has(self, action: Action) -> bool:
return action in self.actions
def permissions(self) -> tuple[str, ...]:
return tuple(self.permission(action) for action in self.actions)
@dataclass(frozen=True, slots=True)
class Area:
"""Ein Bereich (Gruppe von Ressourcen) mit eigenem Kill-Switch."""
key: str
label: str
icon: str
resources: tuple[Resource, ...]
hint: str = ""
@property
def view_key(self) -> str:
return f"{self.key}.view"
def columns(self) -> tuple[Action, ...]:
"""Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen."""
present = {action for resource in self.resources for action in resource.actions}
return tuple(action for action in Action if action in present)
def resource(self, key: str) -> Resource | None:
for resource in self.resources:
if resource.key == key:
return resource
return None
class PermissionTree:
"""Aus Bereichen abgeleitete Nachschlagestrukturen.
Wird beim Import einmal gebaut und danach nur gelesen.
"""
def __init__(self, areas: Sequence[Area]) -> None:
self.areas: tuple[Area, ...] = tuple(areas)
self._by_key: dict[str, Area] = {}
self._parent: dict[str, str] = {}
self._all: list[str] = []
self._resources: dict[str, tuple[Area, Resource]] = {}
seen_keys: set[str] = set()
for area in self.areas:
if area.key in seen_keys:
raise ValueError(f"Doppelter Bereichsschluessel: {area.key}")
seen_keys.add(area.key)
self._by_key[area.key] = area
self._all.append(area.view_key)
for resource in area.resources:
if resource.key in seen_keys:
raise ValueError(f"Doppelter Ressourcenschluessel: {resource.key}")
seen_keys.add(resource.key)
if Action.VIEW not in resource.actions:
raise ValueError(
f"Ressource {resource.key} muss die Aktion 'view' anbieten -- "
"sie ist Voraussetzung fuer jede andere Aktion."
)
self._resources[resource.key] = (area, resource)
for permission in resource.permissions():
self._all.append(permission)
self._parent[permission] = area.view_key
# ------------------------------------------------------------------ #
def all_keys(self) -> tuple[str, ...]:
return tuple(self._all)
def area(self, key: str) -> Area | None:
return self._by_key.get(key)
def parent_view_key(self, permission: str) -> str | None:
return self._parent.get(permission)
def resource(self, key: str) -> tuple[Area, Resource] | None:
return self._resources.get(key)
def label_for(self, permission: str) -> str:
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
if permission.endswith(".view") and permission[:-5] in self._by_key:
return f"Bereich {self._by_key[permission[:-5]].label}: Zugang"
resource_key, _, action_value = permission.rpartition(".")
entry = self._resources.get(resource_key)
if entry is None:
return permission
_, resource = entry
try:
action = Action(action_value)
except ValueError:
return permission
return f"{resource.label}: {ACTION_META[action][1]}"
def sanitize(self, permissions: Iterable[str]) -> list[str]:
"""Wirft unbekannte Rechte weg und ergaenzt implizierte.
Regeln:
* Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles
Weiterschleppen von Altrechten nach einem Refactoring).
* Jede Aktion impliziert ``view`` derselben Zeile.
* Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des
Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der
Bearbeiter wuerde sich wundern.
"""
known = set(self._all)
result: set[str] = {p for p in permissions if p in known}
for permission in list(result):
resource_key, _, action_value = permission.rpartition(".")
if resource_key in self._resources:
try:
action = Action(action_value)
except ValueError:
continue
for implied in IMPLIED_BY.get(action, ()):
candidate = f"{resource_key}.{implied.value}"
if candidate in known:
result.add(candidate)
for area in self.areas:
if any(p in result for resource in area.resources for p in resource.permissions()):
result.add(area.view_key)
return sorted(result)
def effective(self, permissions: Iterable[str]) -> set[str]:
"""Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen."""
granted = set(permissions)
active = {key for key in granted if key.endswith(".view") and key[:-5] in self._by_key}
return {
key
for key in granted
if (parent := self._parent.get(key)) is None or parent in active
}
def expand_preset(self, preset: "RolePreset") -> list[str]:
return self.sanitize(preset.permissions(self))
@dataclass(frozen=True, slots=True)
class RolePreset:
"""Benannte Rechtevorlage -- wird beim ersten Start als Gruppe angelegt.
Der Vorgaenger kannte nur "Admin" und eine Standardgruppe "Benutzer"; jede
weitere Rolle musste jeder Kunde von Hand zusammenklicken. Vorlagen machen
aus der haeufigsten Aufgabe einen Startpunkt.
"""
key: str
name: str
description: str
#: ``(ressource, aktionen)``-Paare, ``"*"`` fuer alle Aktionen der Ressource.
grants: tuple[tuple[str, tuple[Action, ...] | str], ...]
is_default: bool = False
"""Standardgruppe: wird jedem neuen Nicht-Admin automatisch zugeordnet."""
def permissions(self, tree: PermissionTree) -> list[str]:
result: list[str] = []
for resource_key, actions in self.grants:
entry = tree.resource(resource_key)
if entry is None:
continue
_, resource = entry
wanted = resource.actions if actions == "*" else tuple(a for a in actions if resource.has(a)) # type: ignore[arg-type]
result.extend(resource.permission(action) for action in wanted)
return result
@@ -0,0 +1,717 @@
"""Benutzer- und Gruppenverwaltung inklusive Eskalationsschutz.
Die wichtigste inhaltliche Korrektur gegenueber dem Vorgaenger steckt in
``guard_no_amplification``:
Im Vorgaenger konnte jeder, der das Recht ``groups.edit`` besass, einer Gruppe
*beliebige* Rechte zuweisen -- also auch Rechte, die er selbst nicht hatte --
und sich anschliessend selbst in diese Gruppe setzen. Das Rechtesystem war
damit fuer jeden Gruppenverwalter effektiv offen. Hier gilt: wer selbst kein
Recht X hat, kann Recht X auch nicht vergeben. Entziehen ist immer erlaubt.
Echte Administratoren sind davon ausgenommen (sie haben ohnehin alles).
Weitere fest verdrahtete Grenzen, die kein Gruppenrecht aushebeln kann:
* Das Admin-Kennzeichen setzt oder entfernt nur ein echter Administrator.
* Ein bestehendes Administratorkonto bearbeitet, sperrt oder loescht nur ein
echter Administrator.
* Es muss immer mindestens ein entsperrtes Administratorkonto uebrig bleiben.
* Niemand kann sich selbst das Admin-Kennzeichen entziehen oder sich selbst
sperren (haeufigster Weg, sich komplett auszusperren).
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Iterable, Sequence
from ..db import Database
from ..security import passwords
from .model import Action, PermissionTree, RolePreset
class PermissionDenied(Exception):
"""Verstoss gegen eine feste Sicherheitsgrenze -- wird als Klartext angezeigt."""
class ValidationError(Exception):
"""Eingabefehler, der dem Benutzer erklaert wird."""
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
# --------------------------------------------------------------------------- #
# Gruppen
# --------------------------------------------------------------------------- #
@dataclass(frozen=True, slots=True)
class GroupRecord:
id: int
name: str
description: str
is_system: bool
is_default: bool
preset_key: str
permissions: tuple[str, ...]
member_count: int
deleted_at: str | None = None
def list_groups(
conn: sqlite3.Connection, *, include_deleted: bool = False
) -> list[GroupRecord]:
where = "" if include_deleted else "WHERE g.deleted_at IS NULL"
rows = Database.all(
conn,
f"""
SELECT g.*,
(SELECT COUNT(*) FROM user_groups ug
JOIN users u ON u.id = ug.user_id AND u.deleted_at IS NULL
WHERE ug.group_id = g.id) AS member_count
FROM groups g {where}
ORDER BY g.is_system DESC, g.name COLLATE NOCASE
""",
)
perms = _permissions_by_group(conn)
return [
GroupRecord(
id=int(row["id"]),
name=str(row["name"]),
description=str(row["description"] or ""),
is_system=bool(row["is_system"]),
is_default=bool(row["is_default"]),
preset_key=str(row["preset_key"] or ""),
permissions=tuple(perms.get(int(row["id"]), ())),
member_count=int(row["member_count"] or 0),
deleted_at=row["deleted_at"],
)
for row in rows
]
def _permissions_by_group(conn: sqlite3.Connection) -> dict[int, list[str]]:
result: dict[int, list[str]] = {}
for row in Database.all(conn, "SELECT group_id, permission FROM group_permissions"):
result.setdefault(int(row["group_id"]), []).append(str(row["permission"]))
for values in result.values():
values.sort()
return result
def get_group(conn: sqlite3.Connection, group_id: int) -> GroupRecord | None:
for group in list_groups(conn, include_deleted=True):
if group.id == group_id:
return group
return None
def group_permissions(conn: sqlite3.Connection, group_id: int) -> list[str]:
return [
str(row["permission"])
for row in Database.all(
conn,
"SELECT permission FROM group_permissions WHERE group_id=? ORDER BY permission",
(group_id,),
)
]
def guard_no_amplification(
actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht"
) -> None:
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt."""
if getattr(actor, "is_admin", False):
return
missing = sorted(p for p in added if not actor.has(p))
if missing:
labels = ", ".join(tree.label_for(p) for p in missing[:6])
more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)"
raise PermissionDenied(
f"Sie koennen kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
)
def create_group(
conn: sqlite3.Connection,
*,
name: str,
description: str,
permissions: Sequence[str],
tree: PermissionTree,
actor: Any,
preset_key: str = "",
is_system: bool = False,
is_default: bool = False,
) -> int:
clean_name = (name or "").strip()
if not clean_name:
raise ValidationError("Der Gruppenname darf nicht leer sein.")
if len(clean_name) > 64:
raise ValidationError("Der Gruppenname ist zu lang (maximal 64 Zeichen).")
existing = Database.one(
conn, "SELECT id, deleted_at FROM groups WHERE name=? COLLATE NOCASE", (clean_name,)
)
if existing is not None:
if existing["deleted_at"]:
raise ValidationError(
f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. "
"Stellen Sie sie wieder her oder loeschen Sie sie endgueltig."
)
raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.")
wanted = tree.sanitize(permissions)
guard_no_amplification(actor, tree, wanted)
cursor = conn.execute(
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
"created_at, updated_at) VALUES (?,?,?,?,?,?,?)",
(clean_name, (description or "").strip(), int(is_system), int(is_default), preset_key, _now(), _now()),
)
group_id = int(cursor.lastrowid or 0)
_write_permissions(conn, group_id, wanted)
return group_id
def _write_permissions(conn: sqlite3.Connection, group_id: int, permissions: Sequence[str]) -> None:
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
conn.executemany(
"INSERT INTO group_permissions (group_id, permission) VALUES (?,?)",
[(group_id, permission) for permission in permissions],
)
def update_group(
conn: sqlite3.Connection,
group_id: int,
*,
name: str | None = None,
description: str | None = None,
permissions: Sequence[str] | None = None,
tree: PermissionTree,
actor: Any,
unlock_system: bool = False,
) -> list[str]:
"""Aendert eine Gruppe. Rueckgabe: Liste der Aenderungen fuer das Audit-Log."""
group = get_group(conn, group_id)
if group is None or group.deleted_at:
raise ValidationError("Diese Gruppe existiert nicht (mehr).")
changes: list[str] = []
if name is not None and name.strip() != group.name:
if group.is_system:
raise PermissionDenied("Systemgruppen koennen nicht umbenannt werden.")
clean = name.strip()
if not clean:
raise ValidationError("Der Gruppenname darf nicht leer sein.")
clash = Database.one(
conn,
"SELECT id FROM groups WHERE name=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL",
(clean, group_id),
)
if clash is not None:
raise ValidationError(f"Eine Gruppe namens {clean!r} existiert bereits.")
conn.execute("UPDATE groups SET name=?, updated_at=? WHERE id=?", (clean, _now(), group_id))
changes.append(f"Name: {group.name} -> {clean}")
if description is not None and description.strip() != group.description:
conn.execute(
"UPDATE groups SET description=?, updated_at=? WHERE id=?",
(description.strip(), _now(), group_id),
)
changes.append("Beschreibung geaendert")
if permissions is not None:
if group.is_system and not unlock_system:
raise PermissionDenied(
"Die Rechte dieser Systemgruppe sind gesperrt. "
"Sie muessen sie zuerst ausdruecklich freischalten."
)
wanted = tree.sanitize(permissions)
added = sorted(set(wanted) - set(group.permissions))
removed = sorted(set(group.permissions) - set(wanted))
guard_no_amplification(actor, tree, added)
if added or removed:
_write_permissions(conn, group_id, wanted)
conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id))
if added:
changes.append("Rechte hinzugefuegt: " + ", ".join(tree.label_for(p) for p in added))
if removed:
changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed))
return changes
def soft_delete_group(conn: sqlite3.Connection, group_id: int) -> None:
group = get_group(conn, group_id)
if group is None:
raise ValidationError("Diese Gruppe existiert nicht.")
if group.is_system:
raise PermissionDenied("Systemgruppen koennen nicht geloescht werden.")
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id))
def restore_group(conn: sqlite3.Connection, group_id: int) -> None:
group = get_group(conn, group_id)
if group is None:
raise ValidationError("Diese Gruppe existiert nicht.")
clash = Database.one(
conn,
"SELECT id FROM groups WHERE name=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL",
(group.name, group_id),
)
if clash is not None:
raise ValidationError(
f"Es existiert bereits eine aktive Gruppe namens {group.name!r}. "
"Benennen Sie diese zuerst um."
)
conn.execute("UPDATE groups SET deleted_at=NULL WHERE id=?", (group_id,))
def purge_group(conn: sqlite3.Connection, group_id: int) -> None:
# Mitgliedschaften und Rechte haengen per ON DELETE CASCADE dran.
conn.execute("DELETE FROM groups WHERE id=?", (group_id,))
def ensure_presets(
conn: sqlite3.Connection, tree: PermissionTree, presets: Sequence[RolePreset]
) -> list[str]:
"""Legt fehlende Vorlagen-Gruppen an.
Wichtig -- Unterschied zum Vorgaenger: eine bereits existierende Gruppe wird
**nicht** bei jedem Start auf den Vorlagen-Rechtesatz zurueckgesetzt. Der
Vorgaenger tat das fuer die Standardgruppe "Benutzer", wodurch jede
Anpassung durch den Kunden beim naechsten Neustart verschwand. Wer die
Vorlage neu anwenden will, tut das ausdruecklich in der Oberflaeche.
"""
created: list[str] = []
for preset in presets:
row = Database.one(
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
)
if row is not None:
continue
permissions = tree.expand_preset(preset)
cursor = conn.execute(
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
"created_at, updated_at) VALUES (?,?,?,?,?,?,?)",
(
preset.name,
preset.description,
1 if preset.is_default else 0,
1 if preset.is_default else 0,
preset.key,
_now(),
_now(),
),
)
_write_permissions(conn, int(cursor.lastrowid or 0), permissions)
created.append(preset.name)
return created
def default_group_id(conn: sqlite3.Connection) -> int | None:
value = Database.value(
conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1"
)
return int(value) if value is not None else None
# --------------------------------------------------------------------------- #
# Benutzer
# --------------------------------------------------------------------------- #
def granted_permissions(conn: sqlite3.Connection, user_id: int) -> list[str]:
"""Vereinigung der Rechte aller (nicht geloeschten) Gruppen des Benutzers."""
return [
str(row["permission"])
for row in Database.all(
conn,
"""
SELECT DISTINCT gp.permission
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id AND g.deleted_at IS NULL
JOIN group_permissions gp ON gp.group_id = g.id
WHERE ug.user_id = ?
""",
(user_id,),
)
]
def user_groups(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
return Database.all(
conn,
"SELECT g.id, g.name, g.is_system, g.is_default FROM user_groups ug "
"JOIN groups g ON g.id = ug.group_id AND g.deleted_at IS NULL "
"WHERE ug.user_id=? ORDER BY g.name COLLATE NOCASE",
(user_id,),
)
def list_users(
conn: sqlite3.Connection, *, include_deleted: bool = False
) -> list[dict[str, Any]]:
where = "" if include_deleted else "WHERE u.deleted_at IS NULL"
users = Database.all(
conn,
f"""
SELECT u.id, u.username, u.is_admin, u.is_locked, u.auth_source, u.first_name,
u.last_name, u.email, u.avatar_filename, u.last_login_at, u.created_at,
u.deleted_at, u.must_change_password,
CASE WHEN u.totp_secret <> '' THEN 1 ELSE 0 END AS totp_enabled
FROM users u {where}
ORDER BY u.username COLLATE NOCASE
""",
)
memberships: dict[int, list[str]] = {}
for row in Database.all(
conn,
"SELECT ug.user_id, g.name FROM user_groups ug "
"JOIN groups g ON g.id = ug.group_id AND g.deleted_at IS NULL",
):
memberships.setdefault(int(row["user_id"]), []).append(str(row["name"]))
for user in users:
user["group_names"] = sorted(memberships.get(int(user["id"]), []))
return users
def get_user(conn: sqlite3.Connection, user_id: int) -> dict[str, Any] | None:
return Database.one(conn, "SELECT * FROM users WHERE id=?", (user_id,))
def find_user_by_username(
conn: sqlite3.Connection, username: str, *, include_deleted: bool = False
) -> dict[str, Any] | None:
suffix = "" if include_deleted else " AND deleted_at IS NULL"
return Database.one(
conn,
f"SELECT * FROM users WHERE username=? COLLATE NOCASE{suffix}",
(username,),
)
def count_active_admins(conn: sqlite3.Connection, *, exclude_id: int | None = None) -> int:
sql = "SELECT COUNT(*) FROM users WHERE is_admin=1 AND is_locked=0 AND deleted_at IS NULL"
params: tuple[Any, ...] = ()
if exclude_id is not None:
sql += " AND id<>?"
params = (exclude_id,)
return int(Database.value(conn, sql, params, default=0) or 0)
def guard_target_account(actor: Any, target: dict[str, Any]) -> None:
"""Ein Administratorkonto darf nur ein echter Administrator anfassen."""
if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False):
raise PermissionDenied(
"Administratorkonten koennen nur von Administratoren bearbeitet werden."
)
def guard_last_admin(conn: sqlite3.Connection, user_id: int, *, action: str) -> None:
row = get_user(conn, user_id)
if row is None or not row.get("is_admin"):
return
if count_active_admins(conn, exclude_id=user_id) == 0:
raise PermissionDenied(
f"{action} nicht moeglich: es muss mindestens ein entsperrtes "
"Administratorkonto uebrig bleiben."
)
def _assignable_groups(
conn: sqlite3.Connection, actor: Any, tree: PermissionTree, group_ids: Sequence[int]
) -> list[int]:
"""Prueft, ob der Bearbeiter diese Gruppen zuweisen darf (kein Rechtezuwachs)."""
if getattr(actor, "is_admin", False):
return [int(gid) for gid in group_ids]
result: list[int] = []
for gid in group_ids:
permissions = group_permissions(conn, int(gid))
guard_no_amplification(actor, tree, permissions, what="Gruppenrecht")
result.append(int(gid))
return result
def set_user_groups(
conn: sqlite3.Connection,
user_id: int,
group_ids: Sequence[int],
*,
actor: Any,
tree: PermissionTree,
) -> None:
allowed = _assignable_groups(conn, actor, tree, group_ids)
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
conn.executemany(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
[(user_id, gid) for gid in allowed],
)
def create_user(
conn: sqlite3.Connection,
*,
username: str,
password: str,
is_admin: bool,
first_name: str = "",
last_name: str = "",
email: str = "",
group_ids: Sequence[int] = (),
actor: Any,
tree: PermissionTree,
policy: passwords.PasswordPolicy,
auth_source: str = "local",
must_change_password: bool = False,
) -> int:
clean = (username or "").strip()
if not clean:
raise ValidationError("Der Benutzername darf nicht leer sein.")
if len(clean) > 64 or any(ch.isspace() for ch in clean):
raise ValidationError("Der Benutzername darf keine Leerzeichen enthalten (max. 64 Zeichen).")
existing = find_user_by_username(conn, clean, include_deleted=True)
if existing is not None:
if existing["deleted_at"]:
raise ValidationError(
f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto "
"wieder her oder loeschen Sie es endgueltig."
)
raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.")
if is_admin and not getattr(actor, "is_admin", False):
raise PermissionDenied("Nur Administratoren koennen weitere Administratoren anlegen.")
if auth_source == "local":
problems = policy.check(password, username=clean)
if problems:
raise ValidationError(" ".join(problems))
password_hash = passwords.hash_password(password)
else:
password_hash = "" # Verzeichniskonto: Passwort wird nie hier geprueft.
cursor = conn.execute(
"INSERT INTO users (username, password_hash, is_admin, auth_source, first_name, "
"last_name, email, must_change_password, password_changed_at, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(
clean,
password_hash,
int(is_admin),
auth_source,
first_name.strip(),
last_name.strip(),
email.strip(),
int(must_change_password),
_now() if auth_source == "local" else None,
_now(),
_now(),
),
)
user_id = int(cursor.lastrowid or 0)
targets = list(group_ids)
if not is_admin and not targets:
fallback = default_group_id(conn)
if fallback is not None:
targets = [fallback]
set_user_groups(conn, user_id, targets, actor=actor, tree=tree)
return user_id
def update_user(
conn: sqlite3.Connection,
user_id: int,
*,
actor: Any,
tree: PermissionTree,
first_name: str | None = None,
last_name: str | None = None,
email: str | None = None,
is_admin: bool | None = None,
is_locked: bool | None = None,
group_ids: Sequence[int] | None = None,
) -> list[str]:
target = get_user(conn, user_id)
if target is None or target["deleted_at"]:
raise ValidationError("Dieses Konto existiert nicht (mehr).")
guard_target_account(actor, target)
changes: list[str] = []
directory = str(target.get("auth_source")) == "ldap"
if directory and any(value is not None for value in (first_name, last_name, email)):
# Beim naechsten Login wuerden diese Felder ohnehin aus dem Verzeichnis
# ueberschrieben -- die Bearbeitung waere eine Illusion.
raise ValidationError(
"Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung "
"aus dem Verzeichnis uebernommen und koennen hier nicht geaendert werden."
)
fields: dict[str, Any] = {}
for column, value in (
("first_name", first_name),
("last_name", last_name),
("email", email),
):
if value is not None and value.strip() != str(target.get(column) or ""):
fields[column] = value.strip()
changes.append(f"{column} geaendert")
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
if not getattr(actor, "is_admin", False):
raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator aendern.")
if int(target["id"]) == int(getattr(actor, "id", 0)):
raise PermissionDenied("Sie koennen Ihr eigenes Admin-Kennzeichen nicht aendern.")
if not is_admin:
guard_last_admin(conn, user_id, action="Herabstufen")
fields["is_admin"] = int(bool(is_admin))
changes.append("Administrator: " + ("ja" if is_admin else "nein"))
if is_locked is not None and bool(is_locked) != bool(target["is_locked"]):
if int(target["id"]) == int(getattr(actor, "id", 0)):
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht sperren.")
if is_locked:
guard_last_admin(conn, user_id, action="Sperren")
fields["is_locked"] = int(bool(is_locked))
changes.append("Gesperrt: " + ("ja" if is_locked else "nein"))
if fields:
assignments = ", ".join(f"{column}=?" for column in fields)
conn.execute(
f"UPDATE users SET {assignments}, updated_at=? WHERE id=?",
(*fields.values(), _now(), user_id),
)
if group_ids is not None:
before = {int(row["id"]) for row in user_groups(conn, user_id)}
after = {int(gid) for gid in group_ids}
if before != after:
set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree)
changes.append("Gruppenzuordnung geaendert")
return changes
def set_password(
conn: sqlite3.Connection,
user_id: int,
new_password: str,
*,
policy: passwords.PasswordPolicy,
must_change: bool = False,
) -> None:
row = get_user(conn, user_id)
if row is None:
raise ValidationError("Dieses Konto existiert nicht.")
if str(row.get("auth_source")) == "ldap":
raise ValidationError(
"Verzeichniskonten haben hier kein Passwort -- es wird im Verzeichnisdienst gepflegt."
)
problems = policy.check(new_password, username=str(row["username"]))
if problems:
raise ValidationError(" ".join(problems))
conn.execute(
"UPDATE users SET password_hash=?, must_change_password=?, password_changed_at=?, "
"updated_at=? WHERE id=?",
(passwords.hash_password(new_password), int(must_change), _now(), _now(), user_id),
)
def soft_delete_user(conn: sqlite3.Connection, user_id: int, *, actor: Any) -> str:
target = get_user(conn, user_id)
if target is None or target["deleted_at"]:
raise ValidationError("Dieses Konto existiert nicht (mehr).")
guard_target_account(actor, target)
if int(target["id"]) == int(getattr(actor, "id", 0)):
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht loeschen.")
guard_last_admin(conn, user_id, action="Loeschen")
if str(target.get("auth_source")) == "ldap":
# Ein Verzeichniskonto ist nur ein Schattenkonto: es legt sich bei der
# naechsten gueltigen Anmeldung von selbst wieder an. Ein Papierkorb
# waere hier bedeutungslos.
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
return "endgueltig"
conn.execute("UPDATE users SET deleted_at=?, updated_at=? WHERE id=?", (_now(), _now(), user_id))
conn.execute(
"UPDATE user_sessions SET revoked_at=?, revoked_reason='user-deleted' "
"WHERE user_id=? AND revoked_at IS NULL",
(_now(), user_id),
)
return "papierkorb"
def restore_user(conn: sqlite3.Connection, user_id: int) -> None:
row = get_user(conn, user_id)
if row is None:
raise ValidationError("Dieses Konto existiert nicht.")
clash = Database.one(
conn,
"SELECT id FROM users WHERE username=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL",
(row["username"], user_id),
)
if clash is not None:
raise ValidationError(
f"Es existiert bereits ein aktives Konto namens {row['username']!r}."
)
conn.execute("UPDATE users SET deleted_at=NULL, updated_at=? WHERE id=?", (_now(), user_id))
def purge_user(conn: sqlite3.Connection, user_id: int) -> None:
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
def matrix_state(
tree: PermissionTree, permissions: Iterable[str]
) -> dict[str, dict[str, bool]]:
"""Hilfsstruktur fuer das Rendering der Rechtematrix."""
granted = set(permissions)
state: dict[str, dict[str, bool]] = {}
for area in tree.areas:
state[area.key] = {"view": area.view_key in granted}
for resource in area.resources:
state[resource.key] = {
action.value: resource.permission(action) in granted for action in resource.actions
}
return state
def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> list[str]:
"""Kurzfassung fuer Listenansichten: ``Bereich: Ressource (RCUD)``."""
granted = set(permissions)
lines: list[str] = []
for area in tree.areas:
if area.view_key not in granted:
continue
parts: list[str] = []
for resource in area.resources:
letters = "".join(
ACTION_LETTERS[action]
for action in resource.actions
if resource.permission(action) in granted
)
if letters:
parts.append(f"{resource.label} ({letters})")
if parts:
lines.append(f"{area.label}: " + ", ".join(parts))
return lines
ACTION_LETTERS: dict[Action, str] = {
Action.VIEW: "R",
Action.CREATE: "C",
Action.EDIT: "U",
Action.DELETE: "D",
Action.EXECUTE: "X",
Action.EXPORT: "E",
Action.SECRETS: "S",
}
@@ -0,0 +1,7 @@
"""Sicherheitsbausteine: Passwoerter, Sitzungen, CSRF, Ratenbegrenzung, TOTP."""
from __future__ import annotations
from . import csrf, passwords, ratelimit, sessions, totp
__all__ = ["csrf", "passwords", "ratelimit", "sessions", "totp"]
@@ -0,0 +1,180 @@
"""CSRF-Schutz und Header-Haertung.
Der Vorgaenger hatte **keinen** CSRF-Schutz: jede Aenderung lief ueber ein
gewoehnliches POST-Formular ohne Token. Eine praeparierte Seite konnte damit im
Namen eines eingeloggten Admins Geraete loeschen oder Benutzer anlegen.
Hier drei Schichten:
1. ``SameSite=Lax`` am Sitzungscookie (blockt Cross-Site-POSTs bereits im Browser).
2. Ein Token pro Sitzung, das in jedem Formular mitgeschickt und in konstanter
Zeit verglichen wird.
3. Origin-/Referer-Pruefung als Rueckfallebene fuer Browser ohne SameSite.
Alle drei sind unabhaengig voneinander wirksam.
"""
from __future__ import annotations
import hmac
import secrets
from urllib.parse import urlparse
from flask import Flask, Response, abort, current_app, request, session
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
FIELD_NAME = "csrf_token"
HEADER_NAME = "X-CSRF-Token"
_SESSION_KEY = "_csrf"
class CsrfError(Exception):
"""Wird als HTTP 403 mit erklaerender Seite ausgeliefert."""
def token() -> str:
"""Aktuelles Token der Sitzung, bei Bedarf neu erzeugt."""
value = session.get(_SESSION_KEY)
if not value:
value = secrets.token_urlsafe(32)
session[_SESSION_KEY] = value
return str(value)
def rotate_token() -> str:
"""Neues Token -- nach Anmeldung/Abmeldung und Rechtewechsel."""
session.pop(_SESSION_KEY, None)
return token()
def _submitted_token() -> str:
for source in (request.form, request.args):
value = source.get(FIELD_NAME)
if value:
return value
header = request.headers.get(HEADER_NAME)
if header:
return header
if request.is_json:
try:
payload = request.get_json(silent=True) or {}
except Exception: # pragma: no cover - defensiv
payload = {}
if isinstance(payload, dict) and payload.get(FIELD_NAME):
return str(payload[FIELD_NAME])
return ""
def _origin_ok() -> bool:
"""Prueft Origin bzw. Referer gegen den Host der Anfrage."""
origin = request.headers.get("Origin") or ""
referer = request.headers.get("Referer") or ""
candidate = origin or referer
if not candidate:
# Kein Origin/Referer: manche Clients (curl, alte Proxies) senden keinen.
# Das Token allein muss dann reichen.
return True
parsed = urlparse(candidate)
if not parsed.netloc:
return False
# Hinter einem Reverse Proxy kann der Host je nach Konfiguration mit oder
# ohne Port ankommen. Beide Formen gelten als derselbe Ursprung; ein
# *anderer* Host tut das nie.
expected: set[str] = {request.host}
forwarded_host = (request.headers.get("X-Forwarded-Host") or "").split(",")[0].strip()
if forwarded_host:
expected.add(forwarded_host)
forwarded_port = (request.headers.get("X-Forwarded-Port") or "").strip()
if forwarded_port:
expected.update(f"{host.split(':')[0]}:{forwarded_port}" for host in set(expected))
# Zusaetzlich die portlose Schreibweise -- aber bewusst **keine** beliebigen
# Ports: ein anderer Dienst auf demselben Host waere ein anderer Ursprung.
expected.update(host.split(":")[0] for host in set(expected))
# Und die Standardports, falls der Proxy sie weglaesst.
scheme_port = "443" if request.headers.get("X-Forwarded-Proto") == "https" else "80"
expected.update(f"{host.split(':')[0]}:{scheme_port}" for host in set(expected))
return parsed.netloc in expected
def protect() -> None:
"""``before_request``-Hook."""
if request.method in SAFE_METHODS:
return
view = current_app.view_functions.get(request.endpoint or "")
if getattr(view, "_csrf_exempt", False):
return
if not _origin_ok():
abort(403, description="Die Anfrage kam von einer fremden Herkunft (Origin/Referer).")
expected = session.get(_SESSION_KEY) or ""
provided = _submitted_token()
if not expected or not provided:
abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
if not hmac.compare_digest(str(expected), str(provided)):
abort(403, description="Das Sicherheitstoken des Formulars ist ungueltig. Bitte Seite neu laden.")
def exempt(view):
"""Dekorator fuer Endpunkte mit eigener Authentisierung (Maschinen-APIs).
Bewusst sparsam einsetzen: nur fuer Routen, die *nicht* ueber Cookies
autorisiert werden. Eine cookie-autorisierte Route ohne CSRF-Schutz ist eine
Luecke.
"""
view._csrf_exempt = True # type: ignore[attr-defined]
return view
def init_app(app: Flask) -> None:
app.before_request(protect)
app.jinja_env.globals["csrf_token"] = token
app.jinja_env.globals["csrf_field_name"] = FIELD_NAME
# --------------------------------------------------------------------------- #
# Sicherheits-Header
# --------------------------------------------------------------------------- #
def content_security_policy(nonce: str) -> str:
"""Strikte CSP.
Kein ``unsafe-inline`` fuer Skripte -- jedes Inline-Script traegt den Nonce
dieses Requests. Styles brauchen ``unsafe-inline``, weil Jinja an einzelnen
Stellen berechnete Breiten (Fortschrittsbalken) als ``style``-Attribut
setzt; Style-Attribute sind kein Skriptausfuehrungsvektor.
"""
return "; ".join(
[
"default-src 'self'",
f"script-src 'self' 'nonce-{nonce}'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"base-uri 'none'",
"object-src 'none'",
"worker-src 'self'",
"manifest-src 'self'",
]
)
def apply_security_headers(response: Response, *, nonce: str, hsts: str = "") -> Response:
response.headers.setdefault("Content-Security-Policy", content_security_policy(nonce))
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "same-origin")
response.headers.setdefault(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()",
)
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
response.headers.setdefault("Cross-Origin-Resource-Policy", "same-origin")
response.headers.setdefault("X-Permitted-Cross-Domain-Policies", "none")
if hsts:
response.headers.setdefault("Strict-Transport-Security", hsts)
return response
@@ -0,0 +1,153 @@
"""Passwort-Hashing: Argon2id, mit Uebergangspfad von bcrypt.
Der Vorgaenger nutzte bcrypt (via Flask-Bcrypt). bcrypt ist nicht kaputt, aber
Argon2id ist der aktuelle Stand (speicherhart, damit deutlich teurer auf GPUs)
und die Empfehlung des OWASP-Passwort-Cheatsheets.
Bestehende bcrypt-Hashes werden weiter akzeptiert und beim naechsten
erfolgreichen Login transparent auf Argon2id migriert -- ein Import einer alten
Datenbank sperrt also niemanden aus.
"""
from __future__ import annotations
import hmac
import re
import secrets
import unicodedata
from dataclasses import dataclass
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
# Parameter nach OWASP-Empfehlung (m=64 MiB, t=3, p=2). Bei ~50 ms pro Hash auf
# der Ziel-VM bleibt der Login fluessig und Offline-Angriffe teuer.
_HASHER = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=2, hash_len=32, salt_len=16)
_BCRYPT_RE = re.compile(r"^\$2[aby]\$\d{2}\$")
#: Passwoerter, die selbst mit 12 Zeichen nichts wert sind. Kurze, kuratierte
#: Liste -- eine vollstaendige Breach-Datenbank gehoert nicht in dieses Repo.
_FORBIDDEN = {
"passwort1234",
"password1234",
"administrator",
"qwertzuiop12",
"123456789012",
"tesmtesmtesm",
"willkommen12",
"geheim123456",
}
@dataclass(frozen=True, slots=True)
class PasswordPolicy:
min_length: int = 12
require_classes: int = 2
"""Anzahl verschiedener Zeichenklassen (klein/gross/Ziffer/Sonderzeichen)."""
def check(self, password: str, *, username: str = "") -> list[str]:
"""Gibt eine Liste von Klartext-Problemen zurueck (leer = in Ordnung)."""
problems: list[str] = []
normalized = normalize(password)
if len(normalized) < self.min_length:
problems.append(f"Mindestens {self.min_length} Zeichen erforderlich.")
classes = sum(
bool(pattern.search(normalized))
for pattern in (
# Kleinbuchstaben inkl. Latin-1-Supplement (Umlaute, Akzente).
re.compile("[a-zß-ÿ]"),
re.compile("[A-ZÀ-Þ]"),
re.compile(r"[0-9]"),
re.compile("[^A-Za-z0-9À-ÿ]"),
)
)
if classes < self.require_classes:
problems.append(
f"Mindestens {self.require_classes} verschiedene Zeichenarten "
"(Klein-/Grossbuchstaben, Ziffern, Sonderzeichen)."
)
if normalized.lower() in _FORBIDDEN:
problems.append("Dieses Passwort ist zu haeufig und damit unsicher.")
if username and len(username) >= 3 and username.lower() in normalized.lower():
problems.append("Das Passwort darf den Benutzernamen nicht enthalten.")
if len(set(normalized)) <= 3 and normalized:
problems.append("Das Passwort besteht aus zu wenigen verschiedenen Zeichen.")
return problems
def normalize(password: str) -> str:
"""NFKC-Normalisierung.
Ohne das schlaegt eine Anmeldung fehl, wenn ein Passwort mit Umlauten auf
einem macOS-Client (NFD) gesetzt und auf Windows (NFC) eingegeben wird.
"""
return unicodedata.normalize("NFKC", password or "")
def hash_password(password: str) -> str:
return _HASHER.hash(normalize(password))
def is_legacy_hash(stored: str) -> bool:
return bool(_BCRYPT_RE.match(stored or ""))
def verify_password(stored: str, password: str) -> bool:
"""Prueft ein Passwort gegen einen Argon2id- oder bcrypt-Hash.
Laeuft bei leerem/kaputtem Hash bewusst in einen echten Argon2-Verify gegen
einen Dummy-Hash, damit die Antwortzeit nicht verraet, ob ein Konto
existiert (Timing-Orakel).
"""
stored = stored or ""
if is_legacy_hash(stored):
return _verify_bcrypt(stored, password)
try:
return _HASHER.verify(stored, normalize(password))
except (VerifyMismatchError, VerificationError, InvalidHashError, ValueError):
return False
def _verify_bcrypt(stored: str, password: str) -> bool:
try:
import bcrypt # lokaler Import: nur fuer Altbestaende noetig
except ImportError: # pragma: no cover
return False
try:
return bcrypt.checkpw(normalize(password).encode("utf-8"), stored.encode("utf-8"))
except (ValueError, TypeError):
return False
def needs_rehash(stored: str) -> bool:
"""True, wenn der Hash veraltet ist (bcrypt oder zu schwache Argon2-Parameter)."""
if is_legacy_hash(stored):
return True
try:
return _HASHER.check_needs_rehash(stored)
except (InvalidHashError, ValueError):
return True
def dummy_verify() -> None:
"""Verbrennt dieselbe Rechenzeit wie ein echter Verify.
Wird aufgerufen, wenn ein Benutzername nicht existiert -- so ist die
Antwortzeit von "Konto existiert nicht" nicht von "falsches Passwort" zu
unterscheiden.
"""
global _DUMMY_HASH
if not _DUMMY_HASH:
_DUMMY_HASH = _HASHER.hash(secrets.token_urlsafe(24))
try:
_HASHER.verify(_DUMMY_HASH, "nicht-das-richtige-passwort")
except (VerifyMismatchError, VerificationError, InvalidHashError):
pass
_DUMMY_HASH = ""
def constant_time_equals(left: str, right: str) -> bool:
return hmac.compare_digest((left or "").encode("utf-8"), (right or "").encode("utf-8"))
@@ -0,0 +1,84 @@
"""Ratenbegrenzung und Kontosperre, in SQLite persistiert.
Der Vorgaenger hatte keinerlei Bremse: ein Skript konnte beliebig schnell
Passwoerter gegen ``/login`` durchprobieren, und ein Neustart des Dienstes
haette einen In-Memory-Zaehler ohnehin geleert.
Hier: ein Sliding-Window-Log in der Tabelle ``rate_events``. Persistent, damit
ein Dienst-Neustart die Bremse nicht aufhebt, und mit derselben Verbindung
nutzbar, die der Request schon offen hat (keine zweite Verbindung -> kein
Deadlock).
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from ..db import Database
@dataclass(frozen=True, slots=True)
class RateVerdict:
allowed: bool
remaining: int
retry_after_seconds: int
@property
def blocked(self) -> bool:
return not self.allowed
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(value: datetime) -> str:
return value.isoformat(timespec="seconds")
def record(conn: sqlite3.Connection, bucket: str, key: str) -> None:
"""Vermerkt ein Ereignis (z. B. einen Fehlversuch)."""
conn.execute(
"INSERT INTO rate_events (bucket, subject, ts) VALUES (?,?,?)",
(bucket, key, _iso(_now())),
)
def check(
conn: sqlite3.Connection,
bucket: str,
key: str,
*,
limit: int,
window_minutes: int,
) -> RateVerdict:
"""Prueft, ob noch Versuche frei sind. Zaehlt selbst nichts hoch."""
horizon = _iso(_now() - timedelta(minutes=window_minutes))
rows = Database.all(
conn,
"SELECT ts FROM rate_events WHERE bucket=? AND subject=? AND ts>=? ORDER BY ts",
(bucket, key, horizon),
)
used = len(rows)
if used < limit:
return RateVerdict(allowed=True, remaining=limit - used, retry_after_seconds=0)
oldest = datetime.fromisoformat(rows[0]["ts"])
if oldest.tzinfo is None:
oldest = oldest.replace(tzinfo=timezone.utc)
retry_after = int((oldest + timedelta(minutes=window_minutes) - _now()).total_seconds())
return RateVerdict(allowed=False, remaining=0, retry_after_seconds=max(1, retry_after))
def clear(conn: sqlite3.Connection, bucket: str, key: str) -> None:
"""Loescht die Ereignisse eines Subjekts -- nach erfolgreicher Anmeldung."""
conn.execute("DELETE FROM rate_events WHERE bucket=? AND subject=?", (bucket, key))
def prune(conn: sqlite3.Connection, *, keep_hours: int = 48) -> int:
"""Raeumt alte Ereignisse ab. Laeuft im Wartungslauf mit."""
horizon = _iso(_now() - timedelta(hours=keep_hours))
cursor = conn.execute("DELETE FROM rate_events WHERE ts < ?", (horizon,))
return cursor.rowcount or 0
@@ -0,0 +1,286 @@
"""Serverseitige Sitzungen in SQLite.
Der Vorgaenger nutzte Flasks signierte Cookie-Sitzungen. Das ist bequem, hat
aber drei Nachteile, die fuer eine Verwaltungsanwendung mit Adminrechten
relevant sind:
1. **Nicht widerrufbar.** Ein gestohlenes Cookie bleibt bis zum Ablauf gueltig;
"alle Sitzungen beenden" ist unmoeglich.
2. **Kein Leerlauf-Timeout.** Cookie-Sitzungen kennen nur ein absolutes Ablauf-
datum, keine Inaktivitaet.
3. **Sitzungsinhalt liegt beim Client.** Rotieren des Flask-Secret-Keys wirft
alle Nutzer heraus; ausserdem ist jede Sitzungsvariable im Browser sichtbar
(signiert, aber nicht verschluesselt).
Hier: eine Zeile pro Sitzung in ``user_sessions``, der Cookie traegt nur eine
256-Bit-Zufalls-ID. Damit sind Widerruf, Leerlauf-Timeout, ein absolutes
Maximum, eine Uebersicht "meine aktiven Sitzungen" und Sitzungsrotation beim
Rechtewechsel moeglich.
"""
from __future__ import annotations
import json
import secrets
import sqlite3
from datetime import datetime, timedelta, timezone
from typing import Any
from flask import Flask, Request, Response
from flask.sessions import SessionInterface, SessionMixin
from werkzeug.datastructures import CallbackDict
from ..db import Database
SESSION_ID_BYTES = 32
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(value: datetime) -> str:
return value.isoformat(timespec="seconds")
def _parse(value: str | None) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
class ServerSession(CallbackDict, SessionMixin):
"""Sitzungsobjekt, das seine Aenderungen selbst als "dirty" markiert."""
def __init__(self, initial: dict[str, Any] | None = None, sid: str = "", *, new: bool = False):
def on_update(_self: Any) -> None:
self.modified = True
super().__init__(initial or {}, on_update)
self.sid = sid
self.new = new
self.modified = False
self.destroyed = False
#: Von der Datenbank geladene Metadaten (nur lesend fuer den Aufrufer).
self.meta: dict[str, Any] = {}
def destroy(self) -> None:
"""Sitzung serverseitig verwerfen (Logout)."""
self.clear()
self.destroyed = True
self.modified = True
class SqliteSessionInterface(SessionInterface):
"""Flask-Sitzungsschnittstelle gegen die Tabelle ``user_sessions``."""
session_class = ServerSession
pickle_based = False
def __init__(
self,
database: Database,
*,
cookie_name: str,
idle_minutes: int,
absolute_hours: int,
) -> None:
self.database = database
self.cookie_name = cookie_name
self.idle_minutes = idle_minutes
self.absolute_hours = absolute_hours
# -- Flask-Schnittstelle ------------------------------------------- #
def open_session(self, app: Flask, request: Request) -> ServerSession:
sid = request.cookies.get(self.cookie_name, "")
if not sid or len(sid) < 40:
return self.session_class(new=True)
with self.database.session() as conn:
row = Database.one(
conn,
"SELECT * FROM user_sessions WHERE id=? AND revoked_at IS NULL",
(sid,),
)
if row is None:
return self.session_class(new=True)
now = _now()
idle_deadline = _parse(row.get("idle_expires_at"))
absolute_deadline = _parse(row.get("absolute_expires_at"))
if (idle_deadline and now > idle_deadline) or (
absolute_deadline and now > absolute_deadline
):
with self.database.transaction(conn):
conn.execute(
"UPDATE user_sessions SET revoked_at=?, revoked_reason='expired' "
"WHERE id=?",
(_iso(now), sid),
)
return self.session_class(new=True)
try:
data = json.loads(row.get("data") or "{}")
except ValueError:
data = {}
session = self.session_class(data, sid)
session.meta = {
"user_id": row.get("user_id"),
"created_at": row.get("created_at"),
"last_seen_at": row.get("last_seen_at"),
"reauth_at": row.get("reauth_at"),
"absolute_expires_at": row.get("absolute_expires_at"),
"ip": row.get("ip"),
"user_agent": row.get("user_agent"),
}
# Leerlauffenster verlaengern, aber hoechstens einmal pro Minute
# schreiben -- sonst kostet jeder AJAX-Poll einen Schreibvorgang.
last_seen = _parse(row.get("last_seen_at"))
if last_seen is None or (now - last_seen) > timedelta(seconds=60):
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),
)
return session
def save_session(self, app: Flask, session: ServerSession, response: Response) -> None: # type: ignore[override]
name = self.cookie_name
domain = self.get_cookie_domain(app)
path = self.get_cookie_path(app)
if session.destroyed:
if session.sid:
with self.database.session() as conn, self.database.transaction(conn):
conn.execute(
"UPDATE user_sessions SET revoked_at=?, revoked_reason='logout' WHERE id=?",
(_iso(_now()), session.sid),
)
response.delete_cookie(name, domain=domain, path=path)
return
if not session:
# Leere Sitzung: nichts speichern, aber ein evtl. vorhandenes Cookie
# nicht anfassen (sonst wuerde jeder anonyme Request es loeschen).
if session.modified and session.sid:
response.delete_cookie(name, domain=domain, path=path)
return
if not session.modified and not session.new:
return
now = _now()
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
with self.database.session() as conn, self.database.transaction(conn):
if session.new or not session.sid:
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
conn.execute(
"INSERT INTO user_sessions "
"(id, user_id, created_at, last_seen_at, idle_expires_at, "
" absolute_expires_at, ip, user_agent, data) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(
session.sid,
session.get("user_id"),
_iso(now),
_iso(now),
_iso(now + timedelta(minutes=self.idle_minutes)),
_iso(now + timedelta(hours=self.absolute_hours)),
session.get("_ip", ""),
session.get("_ua", ""),
payload,
),
)
session.new = False
else:
conn.execute(
"UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
"idle_expires_at=?, reauth_at=? WHERE id=?",
(
session.get("user_id"),
payload,
_iso(now),
_iso(now + timedelta(minutes=self.idle_minutes)),
session.get("_reauth_at"),
session.sid,
),
)
response.set_cookie(
name,
session.sid,
httponly=True,
secure=self.get_cookie_secure(app),
samesite=self.get_cookie_samesite(app),
domain=domain,
path=path,
max_age=None, # Sitzungscookie: verschwindet beim Schliessen des Browsers.
)
response.vary.add("Cookie")
# --------------------------------------------------------------------------- #
# Verwaltung
# --------------------------------------------------------------------------- #
def rotate(conn: sqlite3.Connection, session: ServerSession, *, reason: str = "privilege-change") -> str:
"""Erzeugt eine neue Sitzungs-ID und entwertet die alte (Session-Fixation).
Wird bei jeder Anmeldung und bei jeder Rechteaenderung aufgerufen.
"""
old_sid = session.sid
if old_sid:
conn.execute(
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
(_iso(_now()), reason, old_sid),
)
session.sid = ""
session.new = True
session.modified = True
return old_sid
def revoke(conn: sqlite3.Connection, sid: str, *, reason: str = "manual") -> None:
conn.execute(
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=? AND revoked_at IS NULL",
(_iso(_now()), reason, sid),
)
def revoke_all_for_user(
conn: sqlite3.Connection, user_id: int, *, except_sid: str = "", reason: str = "manual"
) -> int:
cursor = conn.execute(
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? "
"WHERE user_id=? AND revoked_at IS NULL AND id <> ?",
(_iso(_now()), reason, user_id, except_sid),
)
return cursor.rowcount or 0
def active_for_user(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
return Database.all(
conn,
"SELECT id, created_at, last_seen_at, absolute_expires_at, ip, user_agent "
"FROM user_sessions WHERE user_id=? AND revoked_at IS NULL "
"AND idle_expires_at > ? ORDER BY last_seen_at DESC",
(user_id, _iso(_now())),
)
def prune(conn: sqlite3.Connection, *, keep_days: int = 30) -> int:
"""Entfernt abgelaufene und widerrufene Sitzungen."""
horizon = _iso(_now() - timedelta(days=keep_days))
cursor = conn.execute(
"DELETE FROM user_sessions WHERE (revoked_at IS NOT NULL AND revoked_at < ?) "
"OR idle_expires_at < ?",
(horizon, horizon),
)
return cursor.rowcount or 0

Some files were not shown because too many files have changed in this diff Show More