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

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.