Files
tesm-license/docs/ARCHITEKTUR.md
T
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

8.0 KiB

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 @layern 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):

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.