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>
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