Files
tesm/docs/ARCHITEKTUR.md
T
alientimandClaude Opus 5 7354ff352b TESM 2.0.0 -- Neubau
Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert
im Zweig SONNET5 erhalten.

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

Die wichtigsten Unterschiede zum Vorgaenger, jeweils an der Stelle im Code
kommentiert, an der der Fehler entstanden ist:

* 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.
* Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert.
* Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft (nginx, Kea).
* Kein Hostname im Lizenz-Fingerabdruck.
* Zwei Installationen auf einem Host stoeren sich nicht (eigener SITE_KEY).
* Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 18:00:06 +02:00

167 lines
8.0 KiB
Markdown

# 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.