# 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/`: 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.