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>
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 einenNestedConnectionErroraus, 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.
SECRETSundEXPORTsind 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.
seqgegen 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.