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>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user