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>
This commit is contained in:
2026-09-02 18:37:12 +02:00
co-authored by Claude Opus 5
parent f7805a2180
commit 6ac2ed0a62
24 changed files with 344 additions and 4336 deletions
-166
View File
@@ -1,166 +0,0 @@
# Architektur
Zwei Anwendungen, ein gemeinsamer Kern, ein gemeinsames Lizenzprotokoll.
```
packages/
tesm-licensing/ Lizenzformat und -protokoll (Ed25519). Keine Web-Abhaengigkeit.
tesm-core/ Der Kern: Datenbank, Rechte, Anmeldung, Sitzungen, Protokolle,
Einstellungen, Systemzugriff, Oberflaechenbausteine.
apps/
tesm/ Geraete- und Switchverwaltung (PoE, DHCP, Dateifreigaben, Wartung).
tesm-license/ Lizenzserver (Kunden, Tickets, Lizenzen, Portal).
deploy/ Helfer, sudoers, systemd, nginx, logrotate, install.sh
tests/ Unit- und Integrationstests, dazu drei E2E-Skripte fuer den Zielhost.
```
## Warum genau diese Schnitte
**`tesm-licensing` ist ein eigenes Paket, keine kopierte Datei.**
Im Vorgaengerprojekt lag `licensing.py` zweimal im Repo -- einmal beim Client,
einmal beim Server -- mit der Auflage, beide byte-identisch zu halten. Das ist
keine Invariante, die ein Mensch dauerhaft einhaelt. Hier gibt es das Modul
einmal; beide Anwendungen installieren dasselbe Paket.
`tests/test_repo_hygiene.py` schlaegt Alarm, sobald irgendwo wieder eine Datei
namens `licensing.py` auftaucht.
**`tesm-core` enthaelt alles, was beide Anwendungen gleich machen.**
Nicht nur Technik (Datenbank, Sitzungen, CSRF), sondern auch ganze Seiten:
Benutzer- und Gruppenverwaltung, Protokollansichten, Papierkorb, Diagnose,
Netzwerk-, Webserver- und Verzeichnisdiensteinstellungen. Eine Seite, die in
beiden Anwendungen gleich aussehen soll, existiert genau einmal.
**Die Apps bringen nur ihre Fachlichkeit mit** -- plus vier Beschreibungen, die
der Kern auswertet: `permissions.py` (Rechtebaum), `navigation.py`,
`schema.py` (Migrationen), `settings_spec.py`.
## Datenbank
Rohes `sqlite3`, kein ORM. Begruendung: das Datenmodell ist klein und stabil,
die Abfragen sind lesbar, und es gibt keine Schicht, die im Fehlerfall erst
verstanden werden muss.
Die Klasse `tesm_core.db.Database` erzwingt drei Dinge:
* WAL-Modus, `foreign_keys=ON`, `busy_timeout=15000`.
* Verschachtelte Transaktionen laufen ueber SAVEPOINTs.
* Ein `connect()` **waehrend** einer offenen Schreibtransaktion loest einen
`NestedConnectionError` aus, statt in einen Deadlock zu laufen. Das ist der
strukturelle Riegel gegen die haeufigste Fehlerklasse des Vorgaengers: eine
Funktion oeffnete waehrend eines Schreibvorgangs eine zweite Verbindung, und
die Anwendung blieb stehen, bis das Zeitlimit zuschlug.
Migrationen sind versioniert nach `(komponente, version)` mit Pruefsumme. Sie
laufen ueber `split_statements()`, nicht ueber `executescript()` -- letzteres
committet implizit und zerlegt die umgebende Transaktion.
## Rechte
Ein Rechtebaum aus **Bereich -> Ressource -> Aktion**. Die Aktionen sind ein
festes Vokabular: `VIEW`, `CREATE`, `EDIT`, `DELETE`, `EXECUTE`, `EXPORT`,
`SECRETS`.
* `SECRETS` und `EXPORT` sind eigene Rechte. Wer Zugangsdaten bearbeiten darf,
darf sie damit noch nicht im Klartext sehen oder ausfuehren.
* Bereiche haben einen Hauptschalter: ein abgeschalteter Bereich ist weg,
unabhaengig von Einzelrechten.
* **Keine Rechteausweitung:** niemand kann einem anderen Konto ein Recht geben,
das er selbst nicht hat (`guard_no_amplification`). Ebenso wenig laesst sich
der letzte Administrator entfernen oder sperren (`guard_last_admin`).
* Jede Pruefung ist serverseitig. Das Template blendet zusaetzlich aus, was
nicht erlaubt ist -- aber es *entscheidet* nichts. Im Vorgaenger wurden
Verzeichnisdienst und Webserver nur im Template geprueft; ein direkter POST
kam durch.
## Lizenzierung
Ed25519, mit den Haertungen, die ein Signaturprotokoll erst brauchbar machen:
* **Getrennte Signaturkontexte** fuer Lizenz, Anfrage und Antwort. Eine
Signatur aus dem einen Kontext ist im anderen wertlos.
* **Kanonisches JSON** -- die Signatur haengt nicht an der Schluesselreihenfolge.
* **Nonce gegen Wiedereinspielung**, Antwort an die Anfrage gebunden.
* **Frischefenster** gegen alte, mitgeschnittene Antworten.
* **`seq` gegen Rueckstufung:** eine aeltere Revision wird abgelehnt.
* **Aussteller-Pinning (Trust on first use).** Die erste eingespielte Lizenz
pinnt den oeffentlichen Schluessel ihres Ausstellers. Ein Wechsel verlangt
eine ausdrueckliche Bestaetigung durch einen Administrator.
* **Fingerabdruck ohne Hostnamen.** Er bildet sich aus Maschinen-ID und
DMI-UUID. Der Hostname ist bewusst **nicht** enthalten: im Vorgaenger brach
jedes Umbenennen des Rechners die Bindung.
Der Ablauf: Kunde -> Ticket -> Lizenz. Das Ticket haelt die Parameter (Typ,
Module, Laufzeit, Rolle); die Lizenz ist das signierte Ergebnis. Ein zweiter
Klick auf "Ausstellen" erzeugt keine zweite Lizenz, sondern zeigt die offene.
Aenderungen an einer laufenden Lizenz brauchen keine Neuinstallation: der
Anbieter signiert eine neue Revision, der Client holt sie beim naechsten
Heartbeat.
## Systemzugriff
Der Webprozess laeuft unprivilegiert. Root-Aktionen gehen ausschliesslich durch
`deploy/tesm-helper`: ein Skript mit fester Verbliste, Argumentpruefung gegen
Positivlisten und ohne jede Weitergabe an eine Shell. sudoers gibt genau diesen
einen Pfad frei.
Aktionen, die mehrere Schritte brauchen, sind **ein** Verb -- nicht drei.
`nginx-apply` etwa schreibt, prueft und laedt neu; scheitert `nginx -t`, stellt
es den vorherigen Stand wieder her. Getrennte Verben hatten hier eine echte
Luecke: `nginx-write` verlinkt sofort nach `sites-enabled`, und eine
anschliessend abgelehnte Datei blieb aktiviert liegen. Der laufende nginx merkte
nichts -- der naechste Reload durch irgendwen legte den ganzen Webserver lahm.
## Mehrere Installationen auf einem Host
Zwei Schluessel, mit klarer Aufgabenteilung:
* **`app_key`** (`tesm`, `tesm-license`) -- was die Anwendung *ist*. Bestimmt
Env-Praefix, Rechtebaum, Migrationsnamensraum.
* **`site_key`** (`tesm`, `tesm-opus`, ...) -- welche Installation es *ist*.
Bestimmt alles ausserhalb von `/srv/<name>`: nginx-Site, netplan-Datei,
logrotate-Regel, Zertifikatsverzeichnis und den Namen des Sitzungscookies.
Ohne diese Trennung wuerde eine Testinstanz beim Speichern der
Webserver-Einstellungen die Site-Datei der Produktion ueberschreiben -- und
beide Installationen teilten sich dasselbe Sitzungscookie, weil Cookies keine
Ports unterscheiden.
Die Adresse des Anwendungsservers (`BIND`) steht in derselben Umgebungsdatei,
aus der auch gunicorn seinen `--bind` bekommt. Sie ist zugleich das
`proxy_pass`-Ziel der erzeugten nginx-Site. Vorher war die `5000` fest im Code:
eine Instanz auf Port 5100 erzeugte damit eine Konfiguration, die auf die
*andere* Installation zeigte.
## Oberflaeche
Handgeschriebenes CSS in `@layer`n mit semantischen Tokens, drei Themen (hell,
dunkel, System). JavaScript als ES-Module, angebunden ueber
`data-behavior`-Attribute -- kein Inline-Script, kein Build-Schritt, und damit
vertraeglich mit einer strikten CSP, die `unsafe-inline` fuer Skripte nicht
erlaubt.
## Tests
| Datei | Was sie absichert |
|---|---|
| `test_licensing.py` | Format, Signaturen, Protokoll, Fingerabdruck |
| `test_core_smoke.py` | Kern: Datenbank, Migrationen, Schluesselspeicher, Sitzungen |
| `test_core_security.py` | Passwoerter, TOTP, Ratenbegrenzung, CSRF, Rechte |
| `test_webserver.py` | nginx-Vorlage, TLS, Zertifikate, Instanztrennung |
| `test_tesm_smoke.py` | TESM: Seiten, Validierung, Rechtematrix |
| `test_license_server.py` | Lizenzserver: Ausstellung, Portal, API |
| `test_repo_hygiene.py` | Zeilenenden, Geheimnisse im Repo, Helferpruefungen, sudoers |
Dazu drei Skripte, die **auf dem Zielhost gegen die echte Installation** laufen
(nginx, gunicorn, systemd, TLS -- keine Attrappen):
```bash
python3 tests/e2e_smoke.py https://host admin 'Passwort'
python3 tests/e2e_license_server.py https://lizenz.host admin 'Passwort'
python3 tests/e2e_license.py https://lizenz.host https://host admin 'Passwort' http://127.0.0.1:5001
```
Das dritte spielt den vollstaendigen Lizenz-Lebenszyklus ueber beide
Anwendungen hinweg durch: ausstellen, herunterladen, einspielen, aktivieren,
Module pruefen, Heartbeat, Revision, Widerruf.