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>
167 lines
8.0 KiB
Markdown
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.
|