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>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
# 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.
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
# Betrieb
|
||||
|
||||
Dieses Dokument richtet sich an die Person, die die Anlage installiert,
|
||||
aktualisiert und im Fehlerfall wieder zum Laufen bringt. Es beschreibt, was auf
|
||||
dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird.
|
||||
|
||||
---
|
||||
|
||||
## 1. Was wo liegt
|
||||
|
||||
| Zweck | Pfad |
|
||||
|---|---|
|
||||
| Anwendung (Code, venv, statische Dateien) | `/srv/<name>/` |
|
||||
| Instanzdaten (Datenbank, Schluessel, Lizenz) | `/srv/<name>/data/` |
|
||||
| Protokolle | `/var/log/<name>/` |
|
||||
| Umgebungsdatei | `/etc/tesm/<name>.env` |
|
||||
| Zertifikate (selbstsigniert) | `/etc/tesm/certs/<name>/` |
|
||||
| nginx-Site | `/etc/nginx/sites-available/<name>` |
|
||||
| systemd-Unit | `/etc/systemd/system/<name>.service` |
|
||||
| Privilegierter Helfer | `/usr/local/lib/tesm/tesm-helper` |
|
||||
| Verwaltungswerkzeug | `/usr/local/bin/<name>-admin` |
|
||||
|
||||
`<name>` ist `tesm` bzw. `tesm-license` -- oder, bei einer zusaetzlichen
|
||||
Instanz, `tesm-<instanz>`. Dieser Name (der **SITE_KEY**) benennt alles, was
|
||||
ausserhalb von `/srv/<name>` liegt. Deshalb koennen zwei Installationen
|
||||
derselben Anwendung nebeneinander laufen, ohne sich gegenseitig die
|
||||
nginx-Site, die netplan-Datei oder das Sitzungscookie zu ueberschreiben.
|
||||
|
||||
Alles, was ein Mensch verlieren wuerde, liegt unter `data/`. Das Verzeichnis
|
||||
wird von `install.sh` nie angefasst.
|
||||
|
||||
---
|
||||
|
||||
## 2. Installation
|
||||
|
||||
```bash
|
||||
sudo bash deploy/install.sh --app tesm
|
||||
sudo bash deploy/install.sh --app tesm-license
|
||||
```
|
||||
|
||||
Wichtige Optionen:
|
||||
|
||||
| Option | Wirkung |
|
||||
|---|---|
|
||||
| `--https` | Port 443 mit TLS, Port 80 leitet dorthin um. Ohne `--domain` entsteht ein selbstsigniertes Zertifikat. |
|
||||
| `--domain <name>` | Oeffentlicher Name. Setzt `server_name`; die Site ist dann **nicht** mehr die Vorgabe-Site. |
|
||||
| `--acme-email <a>` | Erst zusammen mit `--domain` wird ein Let's-Encrypt-Zertifikat angefordert (und HSTS gesetzt). Ohne die Adresse bleibt es selbstsigniert -- interne Namen wie `lizenz.firma.local` kann keine CA bestaetigen. |
|
||||
| `--instance <name>` | Zusaetzliche Instanz neben der Hauptinstallation. |
|
||||
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur fuer Testinstanzen sinnvoll). |
|
||||
| `--skip-packages` | Keine Systempakete installieren. |
|
||||
|
||||
Danach:
|
||||
|
||||
```bash
|
||||
sudo -u tesm tesm-admin create-admin
|
||||
```
|
||||
|
||||
Beim Lizenzserver zusaetzlich, **in dieser Reihenfolge**:
|
||||
|
||||
```bash
|
||||
sudo -u tesm-license tesm-license-admin init-key # Signaturschluessel -- sofort sichern!
|
||||
sudo -u tesm-license tesm-license-admin bootstrap # eigene Master-Lizenz
|
||||
sudo -u tesm-license tesm-license-admin create-admin
|
||||
```
|
||||
|
||||
> **Der Signaturschluessel ist nicht wiederherstellbar.** Geht
|
||||
> `data/master_signing_key.json` verloren, ist keine ausgestellte Lizenz mehr
|
||||
> pruefbar. Sichern Sie ihn getrennt vom Server.
|
||||
|
||||
### Zwei Anwendungen auf einem Host
|
||||
|
||||
Beide wollen Port 80 und 443. nginx unterscheidet sie am Namen:
|
||||
|
||||
```bash
|
||||
sudo bash deploy/install.sh --app tesm --https
|
||||
sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.local
|
||||
```
|
||||
|
||||
Die Installation **ohne** Domain wird zur Vorgabe-Site (`default_server`) und
|
||||
beantwortet alles, was zu keinem Namen passt. Zwei namenlose Sites auf
|
||||
demselben Port weist nginx ab -- richtig so, und beim Uebernehmen sofort
|
||||
sichtbar.
|
||||
|
||||
---
|
||||
|
||||
## 3. Aktualisierung
|
||||
|
||||
```bash
|
||||
sudo bash deploy/install.sh --app tesm
|
||||
```
|
||||
|
||||
Derselbe Befehl. Das Skript erkennt an der vorhandenen Datenbank, dass es eine
|
||||
Aktualisierung ist, und tut dann zusaetzlich:
|
||||
|
||||
1. **Vollstaendige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` --
|
||||
unabhaengig davon, ob sich das Schema geaendert hat.
|
||||
2. Dateiabgleich; `data/` bleibt unangetastet.
|
||||
3. Schema-Migrationen beim Start (versioniert, mit Pruefsumme).
|
||||
4. **Gesundheitspruefung.** Antwortet die Anwendung nicht, wird der alte Stand
|
||||
automatisch zurueckgeholt; die fehlgeschlagene Version bleibt unter
|
||||
`/srv/<name>-failed-<zeitstempel>` zur Analyse liegen.
|
||||
|
||||
Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
|
||||
`--https`.
|
||||
|
||||
---
|
||||
|
||||
## 4. HTTPS
|
||||
|
||||
Der Zielzustand ist immer: **Port 443 mit TLS, Port 80 leitet um, ausser dem
|
||||
ACME-Pfad.**
|
||||
|
||||
Drei Wege dorthin:
|
||||
|
||||
**a) Bei der Installation** -- siehe oben, `--https`.
|
||||
|
||||
**b) Nachtraeglich ueber die Kommandozeile:**
|
||||
|
||||
```bash
|
||||
sudo -u tesm tesm-admin web-setup --https --self-signed --apply
|
||||
sudo -u tesm tesm-admin web-setup --https --domain tesm.firma.de \
|
||||
--acme --acme-email it@firma.de --hsts --apply
|
||||
```
|
||||
|
||||
`--print-config` zeigt die Datei, ohne etwas zu schreiben.
|
||||
|
||||
**c) In der Oberflaeche** unter *Einstellungen -> Webserver und TLS*.
|
||||
|
||||
Danach muss `COOKIE_SECURE` in `/etc/tesm/<name>.env` auf `1` stehen (alle drei
|
||||
Wege erledigen das selbst) und der Dienst neu gestartet werden.
|
||||
|
||||
### Die Site-Datei wird immer neu erzeugt
|
||||
|
||||
Sie ist **kein** Ort fuer Handarbeit. Jede Uebernahme schreibt sie vollstaendig
|
||||
aus der Vorlage neu. Was frueher von Hand hineingeschrieben wurde -- Domain,
|
||||
Ports, Zertifikatspfade, HSTS, Upload-Grenze -- ist heute Einstellung in der
|
||||
Anwendung.
|
||||
|
||||
Der Grund steht im Vorgaengerprojekt: dort wurde die Datei nur angelegt, wenn
|
||||
sie fehlte. Ein einmal falscher `alias`-Pfad ueberlebte dadurch jahrelang jedes
|
||||
Update, und die Oberflaeche kam die ganze Zeit ohne Design.
|
||||
|
||||
### Selbstsigniert oder Let's Encrypt?
|
||||
|
||||
* **Oeffentlich erreichbar, echter DNS-Name:** Let's Encrypt. Nur dann ist HSTS
|
||||
sinnvoll.
|
||||
* **Geschlossenes Netz:** selbstsigniert. Browser zeigen eine Warnung, die
|
||||
Verbindung ist trotzdem verschluesselt und das Sitzungscookie darf `Secure`
|
||||
tragen. **HSTS hier nicht einschalten** -- der Browser merkt sich die Vorgabe
|
||||
monatelang und die Warnung laesst sich dann nicht mehr wegklicken.
|
||||
|
||||
Die Erneuerung von Let's Encrypt laeuft ueber `certbot certonly --webroot`
|
||||
(bewusst nicht ueber das nginx-Plugin, das die erzeugte Site umschreiben
|
||||
wuerde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
|
||||
|
||||
---
|
||||
|
||||
## 5. Dienste
|
||||
|
||||
```bash
|
||||
systemctl status tesm.service # Weboberflaeche (gunicorn)
|
||||
systemctl status tesm-monitor.service # Ueberwachungsschleife (nur TESM)
|
||||
systemctl status tesm-license.service
|
||||
journalctl -u tesm -n 100 -f
|
||||
```
|
||||
|
||||
Die Units laufen unprivilegiert (`User=tesm`) mit
|
||||
`NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp` und einer engen
|
||||
`RestrictAddressFamilies`-Liste. Schreibrechte gibt es nur auf `/srv/<name>/data`
|
||||
und `/var/log/<name>`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Root-Rechte
|
||||
|
||||
Die Anwendung hat keine. Alles, was Root braucht, geht durch **einen** Pfad:
|
||||
|
||||
```
|
||||
/usr/local/lib/tesm/tesm-helper <verb> [argumente]
|
||||
```
|
||||
|
||||
Der Helfer gehoert `root`, ist per sudoers ausschliesslich fuer genau diesen
|
||||
Pfad mit `NOPASSWD` freigegeben und prueft jedes Argument gegen eine
|
||||
Positivliste. Er reicht nie etwas an eine Shell weiter (kein `eval`, kein
|
||||
`sh -c`).
|
||||
|
||||
Verben ansehen:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/lib/tesm/tesm-helper
|
||||
grep -n ')$' /usr/local/lib/tesm/tesm-helper
|
||||
```
|
||||
|
||||
Regel fuer Aenderungen: **Jedes neue Verb braucht eine Argumentpruefung.**
|
||||
`tests/test_repo_hygiene.py` erzwingt, dass jedes der Anwendung bekannte Verb
|
||||
im Helfer vorkommt und dass jede Pfadpruefung `..` abweist.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sicherung und Wiederherstellung
|
||||
|
||||
In der Oberflaeche unter *Sicherung* -- verschluesselte Ausfuhr (Argon2id +
|
||||
AES-256-GCM) mit einem selbst gewaehlten Kennwort. Der Import zeigt zuerst eine
|
||||
Vorschau, bevor irgendetwas geschrieben wird.
|
||||
|
||||
Auf dem Host reicht:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop tesm
|
||||
sudo tar -czf /root/tesm-data-$(date +%F).tar.gz -C /srv/tesm data
|
||||
sudo systemctl start tesm
|
||||
```
|
||||
|
||||
Unverzichtbar:
|
||||
|
||||
* `data/app.db` -- alle Daten
|
||||
* `data/secret.key` -- ohne sie sind alle Sitzungen ungueltig
|
||||
* `data/data.keys` -- ohne sie sind **alle gespeicherten Geheimnisse verloren**
|
||||
* `data/license.json` und `data/license_key.json`
|
||||
* Lizenzserver zusaetzlich: `data/master_signing_key.json`
|
||||
|
||||
---
|
||||
|
||||
## 8. Wenn etwas nicht geht
|
||||
|
||||
**Die Anwendung antwortet nicht.**
|
||||
```bash
|
||||
journalctl -u tesm -n 200 --no-pager
|
||||
curl -s http://127.0.0.1:5000/gesundheit
|
||||
```
|
||||
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
||||
|
||||
**nginx laesst sich nicht neu laden.**
|
||||
```bash
|
||||
sudo nginx -t
|
||||
```
|
||||
Die Uebernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
|
||||
zurueck. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
|
||||
`install.sh`, das eine unbrauchbare Site durch die Vorlage ersetzt.
|
||||
|
||||
**Anmeldung schlaegt ohne Fehlermeldung fehl.**
|
||||
Fast immer `COOKIE_SECURE=1` ohne HTTPS. Der Browser bekommt dann ein Cookie,
|
||||
das er ueber HTTP nie zuruecksendet.
|
||||
```bash
|
||||
grep COOKIE_SECURE /etc/tesm/tesm.env
|
||||
```
|
||||
|
||||
**"Die Anfrage kam von einer fremden Herkunft (Origin/Referer)."**
|
||||
Der Reverse Proxy reicht den Host falsch durch. In der Site-Datei muss
|
||||
`proxy_set_header Host $http_host;` stehen -- **nicht** `$host`, das verwirft
|
||||
den Port.
|
||||
|
||||
**Statische Dateien fehlen, die Oberflaeche ist unformatiert.**
|
||||
```bash
|
||||
grep alias /etc/nginx/sites-available/tesm
|
||||
ls /srv/tesm/static /srv/tesm/static-core
|
||||
```
|
||||
Beide `alias`-Pfade muessen in das Installationsverzeichnis zeigen.
|
||||
|
||||
**Zwei Instanzen stoeren sich.**
|
||||
`SITE_KEY` in beiden `/etc/tesm/*.env` vergleichen -- er muss sich
|
||||
unterscheiden. Sonst teilen sich beide nginx-Site und Sitzungscookie.
|
||||
|
||||
**Die Wartung meldet "Der SSH-Host-Schluessel ist unbekannt".**
|
||||
Kein Fehler, sondern die Absicht: ein Hintergrundauftrag darf keinen fremden
|
||||
Schluessel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
|
||||
der Detailseite des Geraets (*Clients -> Geraet -> SSH-Host-Schluessel*):
|
||||
erst **Auslesen**, dann den Fingerprint mit einer unabhaengigen Quelle
|
||||
vergleichen, dann **Freigeben**. Die Wartungsuebersicht zeigt in einer eigenen
|
||||
Spalte, fuer welche Geraete das noch offen ist. Fuer Switche liegt dieselbe
|
||||
Freigabe auf der Switch-Detailseite.
|
||||
|
||||
Der Schluessel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
|
||||
verbindet sich immer ueber die hinterlegte IP-Adresse. Aendert sich die IP
|
||||
eines Geraets, ist der Schluessel erneut freizugeben.
|
||||
|
||||
**Die Pruefkette des Aenderungsprotokolls ist gebrochen.**
|
||||
Unter *Diagnose* sichtbar. Das Protokoll ist verkettet gehasht; ein Bruch heisst,
|
||||
dass jemand direkt in die Datenbank geschrieben hat. Der Eintrag, ab dem es
|
||||
nicht mehr stimmt, wird genannt.
|
||||
|
||||
---
|
||||
|
||||
## 9. Zurueck auf eine aeltere Version
|
||||
|
||||
```bash
|
||||
sudo systemctl stop tesm
|
||||
sudo mv /srv/tesm /srv/tesm-kaputt
|
||||
sudo cp -a /srv/tesm-backup-<zeitstempel> /srv/tesm
|
||||
sudo systemctl start tesm
|
||||
```
|
||||
|
||||
Schema-Migrationen laufen nur vorwaerts. Ein Ruecksprung ueber eine
|
||||
Schema-Aenderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
|
||||
genau das liegt im Backup-Verzeichnis.
|
||||
@@ -0,0 +1,183 @@
|
||||
# Sicherheit
|
||||
|
||||
Was geschuetzt wird, womit, und wo bewusst Grenzen gezogen sind.
|
||||
|
||||
---
|
||||
|
||||
## 1. Anmeldung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Speicherung | Argon2id (argon2-cffi) |
|
||||
| Altbestand | bcrypt wird noch geprueft und bei der naechsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
||||
| Zweiter Faktor | TOTP, optional erzwingbar fuer Administratoren |
|
||||
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
|
||||
| Sperre je Adresse | zusaetzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst laesst sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
||||
| Verzeichnisdienst | LDAP nur ueber TLS, Zertifikat wird geprueft. Ohne gueltiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
||||
|
||||
Nach einer Anmeldung wird die Sitzungskennung neu vergeben (gegen Session
|
||||
Fixation) und das CSRF-Token gewechselt.
|
||||
|
||||
### Erneute Bestaetigung ("sudo-Modus")
|
||||
|
||||
Alles, was Geheimnisse offenlegt oder Daten aus dem System heraustraegt,
|
||||
verlangt eine frische Passworteingabe: Klartextanzeige von Zugangsdaten,
|
||||
Ausfuhr, Herunterladen eines Lizenzbundles, Schluesselrotation,
|
||||
2FA-Aenderungen, Deaktivierung einer Lizenz.
|
||||
|
||||
Ein uebernommenes Sitzungscookie allein reicht dafuer nicht.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sitzungen
|
||||
|
||||
Serverseitig in SQLite, nicht im Cookie. Das Cookie traegt nur eine
|
||||
Zufallskennung.
|
||||
|
||||
* `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist
|
||||
* Leerlaufzeit **und** absolute Hoechstdauer
|
||||
* Der Cookiename enthaelt den `SITE_KEY` -- zwei Installationen auf einem Host
|
||||
koennen sich die Sitzung nicht gegenseitig ueberschreiben (Cookies
|
||||
unterscheiden keine Ports)
|
||||
* Aktive Sitzungen sind einsehbar und einzeln beendbar
|
||||
|
||||
---
|
||||
|
||||
## 3. CSRF
|
||||
|
||||
Drei voneinander unabhaengige Schichten:
|
||||
|
||||
1. `SameSite=Lax` am Sitzungscookie.
|
||||
2. Ein Token je Sitzung, in jedem Formular, in konstanter Zeit verglichen.
|
||||
3. Origin-/Referer-Pruefung gegen den Host der Anfrage.
|
||||
|
||||
Die dritte Schicht akzeptiert den Host mit und ohne Port sowie die
|
||||
Standardports -- **nicht** aber beliebige Ports: ein anderer Dienst auf
|
||||
demselben Rechner ist ein anderer Ursprung.
|
||||
|
||||
Der Vorgaenger hatte gar keinen CSRF-Schutz. Eine praeparierte Seite konnte im
|
||||
Namen eines angemeldeten Administrators Geraete loeschen oder Benutzer anlegen.
|
||||
|
||||
Ausnahmen (`@csrf.exempt`) gibt es nur fuer Maschinenschnittstellen, die sich
|
||||
**nicht** ueber Cookies autorisieren. Eine cookie-autorisierte Route ohne
|
||||
CSRF-Schutz waere eine Luecke.
|
||||
|
||||
---
|
||||
|
||||
## 4. Header
|
||||
|
||||
Bei jeder Antwort:
|
||||
|
||||
* `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein**
|
||||
`unsafe-inline` fuer Skripte -- deshalb gibt es im ganzen Projekt kein
|
||||
Inline-Script ohne Nonce, und JavaScript haengt ueber
|
||||
`data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil
|
||||
einzelne berechnete Breiten (Fortschrittsbalken) als Style-Attribut gesetzt
|
||||
werden; Style-Attribute fuehren keinen Code aus.
|
||||
* `X-Content-Type-Options: nosniff`
|
||||
* `X-Frame-Options: DENY`, `frame-ancestors 'none'`
|
||||
* `Referrer-Policy: same-origin`
|
||||
* `Permissions-Policy` -- Kamera, Mikrofon, Ort, Zahlung, USB abgeschaltet
|
||||
* `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
|
||||
* `Strict-Transport-Security` nur, wenn TLS tatsaechlich terminiert wird
|
||||
|
||||
> HSTS gehoert **nicht** zu einem selbstsignierten Zertifikat. Der Browser
|
||||
> merkt sich die Vorgabe monatelang; die Warnung laesst sich danach nicht mehr
|
||||
> wegklicken. `install.sh` setzt HSTS deshalb nur zusammen mit Let's Encrypt.
|
||||
|
||||
---
|
||||
|
||||
## 5. Geheimnisse im Ruhezustand
|
||||
|
||||
Ein Schluesselspeicher mit AES-256-GCM (`data/data.keys`, Modus 0600):
|
||||
|
||||
* Versionierte Schluessel, rotierbar. Chiffrate tragen ihre Schluesselkennung
|
||||
(`v1.<id>.<nonce>.<ct>`) und bleiben nach einer Rotation lesbar.
|
||||
* Der Verwendungszweck geht als AAD in die Verschluesselung ein. Ein Chiffrat
|
||||
aus einem Kontext laesst sich in einem anderen nicht entschluesseln.
|
||||
* Beim Start wird geprueft, ob die Rechte auf Schluessel- und Lizenzdateien
|
||||
noch stimmen; Abweichungen erscheinen unter *Diagnose*.
|
||||
|
||||
Passwoerter fuer Mounts gehen ueber `stdin` an den privilegierten Helfer, der
|
||||
sie in eine 0600-Datei schreibt -- nie als Kommandozeilenargument (Prozessliste)
|
||||
und nie ueber die Umgebung (`/proc/<pid>/environ`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Rechtetrennung auf dem Host
|
||||
|
||||
Der Webprozess laeuft als `tesm` bzw. `tesm-license`. Root-Aktionen gehen durch
|
||||
genau einen allowlisted Helfer (`/usr/local/lib/tesm/tesm-helper`), der per
|
||||
sudoers ausschliesslich fuer diesen Pfad freigegeben ist.
|
||||
|
||||
Regeln, die im Repository durch Tests erzwungen werden:
|
||||
|
||||
* Jedes Verb, das die Anwendung kennt, existiert im Helfer.
|
||||
* Kein `eval`, kein `bash -c`, kein `sh -c`.
|
||||
* Jede Pfadpruefung weist `..` ab -- sonst laesst sich eine Positivliste ueber
|
||||
den Basisnamen unterlaufen (`/etc/nginx/sites-available/../../../root/x` hat
|
||||
den zulaessigen Basisnamen `x`).
|
||||
* Der Instanzname ist auf `tesm[-<instanz>]` begrenzt; er landet in Pfaden.
|
||||
* Keine sudoers-Regel ausser der einen. `NOPASSWD: ALL` gibt es nicht.
|
||||
|
||||
Die systemd-Units haben `NoNewPrivileges=yes`, `ProtectSystem=strict`,
|
||||
`PrivateTmp=yes` und eine enge `RestrictAddressFamilies`-Liste.
|
||||
|
||||
Der Vorgaenger liess die komplette Flask-Anwendung als root laufen. Eine
|
||||
einzige Luecke in irgendeinem Pfad haette den Host bedeutet.
|
||||
|
||||
---
|
||||
|
||||
## 7. Aenderungsprotokoll
|
||||
|
||||
Verkettet gehasht: jeder Eintrag enthaelt den Hash seines Vorgaengers. Ein
|
||||
nachtraegliches Aendern oder Loeschen faellt auf und wird unter *Diagnose*
|
||||
angezeigt, mitsamt dem Eintrag, ab dem die Kette bricht.
|
||||
|
||||
Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdruecklich
|
||||
auch Lesezugriffe auf Geheimnisse (`credential.secret_revealed`).
|
||||
|
||||
Aeltere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen.
|
||||
|
||||
---
|
||||
|
||||
## 8. Lizenzprotokoll
|
||||
|
||||
Siehe [ARCHITEKTUR.md](ARCHITEKTUR.md), Abschnitt Lizenzierung. Kurz:
|
||||
getrennte Signaturkontexte, kanonisches JSON, Nonce gegen Wiedereinspielung,
|
||||
Frischefenster, `seq` gegen Rueckstufung, Antwort an die Anfrage gebunden,
|
||||
Aussteller-Pinning.
|
||||
|
||||
Das Auslieferungsbundle enthaelt den privaten Clientschluessel und ist damit
|
||||
selbst ein Geheimnis: eigenes Recht (`licenses.export`), frische
|
||||
Passwortbestaetigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
|
||||
Aenderungsprotokoll.
|
||||
|
||||
---
|
||||
|
||||
## 9. Ein- und Ausfuhr
|
||||
|
||||
Verschluesselte Umschlaege (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
|
||||
gewaehlten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
|
||||
wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb
|
||||
braucht der Dienst kein `--workers 1` mehr.
|
||||
|
||||
---
|
||||
|
||||
## 10. Bewusst nicht getan
|
||||
|
||||
* **Kein Schalter, der die LDAP-Zertifikatspruefung abschaltet.** Wer ihn
|
||||
einmal setzt, setzt ihn dauerhaft.
|
||||
* **Kein CSRF-Freibrief fuer cookie-autorisierte Routen.**
|
||||
* **Kein `unsafe-inline` fuer Skripte**, auch nicht "voruebergehend".
|
||||
* **Keine Rechteausweitung**, auch nicht fuer Administratoren untereinander.
|
||||
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
|
||||
nicht brechen.
|
||||
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
|
||||
|
||||
---
|
||||
|
||||
## 11. Wenn Sie eine Schwachstelle finden
|
||||
|
||||
Bitte nicht oeffentlich melden. Wenden Sie sich an die im Anbieterprofil des
|
||||
Lizenzservers hinterlegte Adresse.
|
||||
Reference in New Issue
Block a user