Nur noch, was zum Installieren und Betreiben des Lizenzservers noetig ist

Entfernt: Tests, Architekturdokumentation und die Anwendung TESM. Beides liegt
im Entwicklungsrepository alientim/TESM-DEV.

Neu: packages/tesm-licensing-server -- die Serverhaelfte des Lizenzprotokolls
(issue_license, resign_license, build_master_response, verify_client_request,
Schluesselerzeugung). Sie war bisher Teil von tesm-licensing und lag damit auch
in jeder Client-Installation. Die gemeinsame Haelfte bleibt in tesm-licensing
und wird von beiden Seiten benutzt.

install.sh installiert diese Serverhaelfte ausschliesslich fuer
--app tesm-license und bricht ab, wenn sie fehlt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 18:37:12 +02:00
co-authored by Claude Opus 5
parent f7805a2180
commit 6ac2ed0a62
24 changed files with 344 additions and 4336 deletions
+41 -67
View File
@@ -4,11 +4,24 @@ Ausgabe und Verwaltung der Lizenzen fuer TESM: Kunde -> Ticket -> Lizenz,
Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand ueber Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand ueber
MS Graph und eine Hierarchie aus Unter-Lizenzservern. MS Graph und eine Hierarchie aus Unter-Lizenzservern.
Die verwaltete Anwendung liegt in einem eigenen Repository: **Dieses Repository enthaelt genau das, was zum Installieren und Betreiben des
[alientim/tesm](https://gitea.int.eertmoed.net/alientim/tesm). Beide teilen sich Lizenzservers gebraucht wird** -- nichts darueber hinaus:
den Kern (`packages/tesm-core`) und das Lizenzprotokoll
(`packages/tesm-licensing`); diese Pakete sind hier mit enthalten, damit sich ```
das Repository allein installieren laesst. apps/tesm-license die Anwendung
packages/tesm-core gemeinsamer Kern (Anmeldung, Rechte, Sicherung, nginx, ...)
packages/tesm-licensing Lizenzprotokoll, gemeinsame Haelfte
packages/tesm-licensing-server Lizenzprotokoll, Serverhaelfte: ausstellen, erneuern, antworten
deploy/ install.sh, update.sh, release.sh, systemd, privilegierter Helfer
docs/ Betrieb und Sicherheit
```
Die verwaltete Anwendung liegt hier **nicht** mit drin.
| Wo sonst noch | |
|---|---|
| [alientim/tesm](https://gitea.int.eertmoed.net/alientim/tesm) | TESM selbst |
| [alientim/TESM-DEV](https://gitea.int.eertmoed.net/alientim/TESM-DEV) | Entwicklung: beide Anwendungen, Tests, Architekturdoku |
## Zweige ## Zweige
@@ -19,7 +32,7 @@ das Repository allein installieren laesst.
--- ---
## Installation auf einem Server ## Installation
```bash ```bash
sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.de sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.de
@@ -52,6 +65,23 @@ Health-Check mit automatischem Rollback.
--- ---
## Betrieb von der Kommandozeile
```bash
sudo -u tesm-license tesm-license-admin status
sudo -u tesm-license tesm-license-admin init-key # Signaturschluessel
sudo -u tesm-license tesm-license-admin bootstrap # Grunddaten
sudo -u tesm-license tesm-license-admin create-admin
sudo -u tesm-license tesm-license-admin migrate-legacy # Altbestand uebernehmen
```
`migrate-legacy` liest die SQLite-Datei eines alten Lizenzservers **nur
lesend**, uebernimmt dessen Signaturschluessel und stellt die Lizenzen mit ihrer
restlichen Laufzeit neu aus. Wird der Schluessel nicht uebernommen, muessen
Kunden den Ausstellerwechsel einmal bestaetigen.
---
## Release bauen ## Release bauen
```bash ```bash
@@ -65,42 +95,6 @@ genau einmal.
--- ---
## Entwicklung
```bash
python -m venv .venv && . .venv/bin/activate
pip install -e packages/tesm-licensing -e packages/tesm-core -e apps/tesm-license
pip install pytest
pytest tests -q
```
Ohne gesetzte Umgebungsvariablen legt die Anwendung ihre Daten unter
`instance/tesm-license/` im Arbeitsverzeichnis an -- kein Zugriff auf `/srv`
oder `/var/log` noetig.
```bash
TESM_LICENSE_DEBUG=1 flask --app tesm_license run
```
---
## Verwaltung von der Kommandozeile
```bash
sudo -u tesm-license tesm-license-admin status
sudo -u tesm-license tesm-license-admin init-key # Signaturschluessel
sudo -u tesm-license tesm-license-admin bootstrap # Grunddaten
sudo -u tesm-license tesm-license-admin create-admin
sudo -u tesm-license tesm-license-admin migrate-legacy # Altbestand uebernehmen
```
`migrate-legacy` liest die SQLite-Datei eines alten Lizenzservers **nur lesend**,
uebernimmt dessen Signaturschluessel und stellt die Lizenzen mit ihrer
restlichen Laufzeit neu aus. Wird der Schluessel nicht uebernommen, muessen
Kunden den Ausstellerwechsel einmal bestaetigen.
---
## Lizenzprotokoll in Kurzform ## Lizenzprotokoll in Kurzform
* Ed25519, getrennte Signaturkontexte je Nachrichtenart * Ed25519, getrennte Signaturkontexte je Nachrichtenart
@@ -115,6 +109,8 @@ Kunden den Ausstellerwechsel einmal bestaetigen.
brach im Vorgaenger die Bindung. brach im Vorgaenger die Bindung.
* Offline-Aktivierung ueber einen kurzen Code in beide Richtungen; die Lizenz * Offline-Aktivierung ueber einen kurzen Code in beide Richtungen; die Lizenz
bleibt auf dem Server als "Aktivierung offen" markiert, bis sie zurueckkommt. bleibt auf dem Server als "Aktivierung offen" markiert, bis sie zurueckkommt.
* Keine Lizenz ohne `master_endpoint`: eine Ausstellung ohne Endpunkt wird
abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann.
--- ---
@@ -122,31 +118,11 @@ Kunden den Ausstellerwechsel einmal bestaetigen.
| | | | | |
|---|---| |---|---|
| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Aufbau, Schnitte und die Gruende dafuer |
| [docs/SICHERHEIT.md](docs/SICHERHEIT.md) | Anmeldung, Sitzungen, CSRF, Geheimnisse, Rechtetrennung |
| [docs/BETRIEB.md](docs/BETRIEB.md) | Installation, Update, HTTPS, Sicherung, Fehlersuche | | [docs/BETRIEB.md](docs/BETRIEB.md) | Installation, Update, HTTPS, Sicherung, Fehlersuche |
| [docs/SICHERHEIT.md](docs/SICHERHEIT.md) | Anmeldung, Sitzungen, CSRF, Geheimnisse, Rechtetrennung |
--- Aufbau und Entwurfsentscheidungen stehen in `docs/ARCHITEKTUR.md` im
Entwicklungsrepository, ebenso die Tests.
## Tests
```bash
pytest tests -q
```
Dazu zwei Skripte, die **auf dem Zielhost gegen laufende Installationen**
sprechen -- ueber nginx, gunicorn und echtes TLS. Sie werden nicht
mitinstalliert (`install.sh` laesst `tests/` aus); bei Bedarf dorthin kopieren:
```bash
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 zweite spielt den vollstaendigen Lizenz-Lebenszyklus ueber **beide**
Anwendungen durch: ausstellen, herunterladen, einspielen, online aktivieren,
Module pruefen, Heartbeat, neue Revision, Widerruf. Es braucht deshalb auch eine
laufende TESM-Installation.
--- ---
@@ -158,6 +134,4 @@ laufende TESM-Installation.
Argumentpruefung. Argumentpruefung.
* **CSRF-Schutz ueberhaupt.** Der Vorgaenger hatte keinen. * **CSRF-Schutz ueberhaupt.** Der Vorgaenger hatte keinen.
* **Rechte werden serverseitig geprueft**, nicht nur im Template ausgeblendet. * **Rechte werden serverseitig geprueft**, nicht nur im Template ausgeblendet.
* **Keine Lizenz ohne Endpunkt.** Eine Ausstellung ohne `master_endpoint` wird
abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann.
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock. * **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
+2 -1
View File
@@ -20,6 +20,7 @@ from pathlib import Path
from typing import Any from typing import Any
import tesm_licensing as lic import tesm_licensing as lic
import tesm_licensing_server as licsrv
from tesm_core import audit from tesm_core import audit
from tesm_core.cli_web import add_web_setup, run_web_setup from tesm_core.cli_web import add_web_setup, run_web_setup
from tesm_core.db import Database from tesm_core.db import Database
@@ -73,7 +74,7 @@ def cmd_bootstrap(args: argparse.Namespace) -> int:
"address": str(extension.settings.get(conn, "vendor_address") or ""), "address": str(extension.settings.get(conn, "vendor_address") or ""),
"logo_base64": "", "logo_base64": "",
} }
bundle, _ = lic.issue_license( bundle, _ = licsrv.issue_license(
customer={"name": args.name, "contact_email": vendor["email"]}, customer={"name": args.name, "contact_email": vendor["email"]},
license_type="enterprise", license_type="enterprise",
modules=[], modules=[],
@@ -29,6 +29,7 @@ from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
import tesm_licensing as lic import tesm_licensing as lic
import tesm_licensing_server as licsrv
from tesm_core import audit from tesm_core import audit
from tesm_core.db import Database from tesm_core.db import Database
@@ -72,7 +73,7 @@ def _signed_response(
detail: str = "", detail: str = "",
license_update: dict[str, Any] | None = None, license_update: dict[str, Any] | None = None,
) -> Outcome: ) -> Outcome:
response = lic.build_master_response( response = licsrv.build_master_response(
str(request["action"]), str(request["action"]),
license_id=str(request["license_id"]), license_id=str(request["license_id"]),
fingerprint=str(request["fingerprint"]), fingerprint=str(request["fingerprint"]),
@@ -124,7 +125,7 @@ def _verify(
) )
try: try:
lic.verify_client_request( licsrv.verify_client_request(
request, request,
str(row["license_pubkey"]), str(row["license_pubkey"]),
expected_action=expected_action, expected_action=expected_action,
@@ -11,6 +11,7 @@ from datetime import datetime, timezone
from typing import Any, Iterable, Sequence from typing import Any, Iterable, Sequence
import tesm_licensing as lic import tesm_licensing as lic
import tesm_licensing_server as licsrv
from tesm_core.db import Database from tesm_core.db import Database
TICKET_TYPES = lic.LICENSE_TYPES TICKET_TYPES = lic.LICENSE_TYPES
@@ -458,7 +459,7 @@ def issue_from_ticket(
customer = get_customer(conn, int(ticket["customer_id"])) customer = get_customer(conn, int(ticket["customer_id"]))
assert customer is not None assert customer is not None
bundle, license_pub = lic.issue_license( bundle, license_pub = licsrv.issue_license(
customer={ customer={
"name": str(customer["name"]), "name": str(customer["name"]),
"contact_email": str(customer["contact_email"] or ""), "contact_email": str(customer["contact_email"] or ""),
@@ -600,7 +601,7 @@ def resign_license(
if row is None: if row is None:
raise IssuanceError("Diese Lizenz existiert nicht.") raise IssuanceError("Diese Lizenz existiert nicht.")
document = json.loads(str(row["license_document"])) document = json.loads(str(row["license_document"]))
updated = lic.resign_license( updated = licsrv.resign_license(
document, master_private_key_b64=master_private_key, changes=changes document, master_private_key_b64=master_private_key, changes=changes
) )
conn.execute( conn.execute(
@@ -33,6 +33,7 @@ from pathlib import Path
from typing import Any from typing import Any
import tesm_licensing as lic import tesm_licensing as lic
import tesm_licensing_server as licsrv
from tesm_core.db import Database from tesm_core.db import Database
from tesm_core.keystore import Keystore from tesm_core.keystore import Keystore
@@ -124,7 +125,7 @@ def read_legacy_key(root: Path) -> tuple[str, str]:
public = str(data.get("public") or data.get("public_key") or "") public = str(data.get("public") or data.get("public_key") or "")
if not private or not public: if not private or not public:
raise LegacyImportError("Der alte Signaturschluessel ist unvollstaendig.") raise LegacyImportError("Der alte Signaturschluessel ist unvollstaendig.")
if lic.public_from_private(private) != public: if licsrv.public_from_private(private) != public:
raise LegacyImportError( raise LegacyImportError(
"Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht " "Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht "
"zueinander. Die Datei ist beschaedigt." "zueinander. Die Datei ist beschaedigt."
@@ -291,7 +292,7 @@ def import_all(
) )
continue continue
bundle, license_public = lic.issue_license( bundle, license_public = licsrv.issue_license(
customer={ customer={
"name": str(customer["name"]), "name": str(customer["name"]),
"contact_email": str(customer["contact_email"] or ""), "contact_email": str(customer["contact_email"] or ""),
@@ -24,6 +24,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import tesm_licensing as lic import tesm_licensing as lic
import tesm_licensing_server as licsrv
from tesm_core.keystore import check_private_file, write_private_file from tesm_core.keystore import check_private_file, write_private_file
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -59,7 +60,7 @@ def load(path: Path) -> MasterKey:
public = str(data.get("public_key") or "") public = str(data.get("public_key") or "")
if not private or not public: if not private or not public:
raise MasterKeyError("Der Signaturschluessel ist unvollstaendig.") raise MasterKeyError("Der Signaturschluessel ist unvollstaendig.")
if lic.public_from_private(private) != public: if licsrv.public_from_private(private) != public:
raise MasterKeyError( raise MasterKeyError(
"Der oeffentliche Teil des Signaturschluessels passt nicht zum privaten. " "Der oeffentliche Teil des Signaturschluessels passt nicht zum privaten. "
"Die Datei ist beschaedigt oder wurde manipuliert." "Die Datei ist beschaedigt oder wurde manipuliert."
@@ -77,7 +78,7 @@ def ensure(path: Path) -> MasterKey:
""" """
if path.is_file(): if path.is_file():
return load(path) return load(path)
private, public = lic.generate_keypair() private, public = licsrv.generate_keypair()
payload = { payload = {
"private_key": private, "private_key": private,
"public_key": public, "public_key": public,
+10 -5
View File
@@ -229,11 +229,16 @@ fi
# Immer ueber "python3 -m pip" -- der pip-Shim im venv baeckt einen absoluten # Immer ueber "python3 -m pip" -- der pip-Shim im venv baeckt einen absoluten
# Pfad in seinen Shebang, der nach einer Umbenennung des Verzeichnisses bricht. # Pfad in seinen Shebang, der nach einer Umbenennung des Verzeichnisses bricht.
"$ROOT/venv/bin/python3" -m pip install --quiet --upgrade pip setuptools wheel "$ROOT/venv/bin/python3" -m pip install --quiet --upgrade pip setuptools wheel
"$ROOT/venv/bin/python3" -m pip install --quiet \ PAKETE=("$ROOT/packages/tesm-licensing" "$ROOT/packages/tesm-core")
"$ROOT/packages/tesm-licensing" \ # Die Serverhaelfte des Lizenzprotokolls -- ausstellen, erneuern, antworten --
"$ROOT/packages/tesm-core" \ # gehoert ausschliesslich zum Lizenzserver. Ein Client verifiziert Lizenzen und
"$ROOT/apps/$APP" \ # stellt Anfragen; den Code zum Ausstellen soll er nicht einmal mitbringen.
gunicorn if [[ "$APP" == "tesm-license" ]]; then
[[ -d "$ROOT/packages/tesm-licensing-server" ]] ||
fail "packages/tesm-licensing-server fehlt -- ohne sie kann der Lizenzserver nichts ausstellen"
PAKETE+=("$ROOT/packages/tesm-licensing-server")
fi
"$ROOT/venv/bin/python3" -m pip install --quiet "${PAKETE[@]}" "$ROOT/apps/$APP" gunicorn
if [[ "$APP" == "tesm" ]]; then if [[ "$APP" == "tesm" ]]; then
"$ROOT/venv/bin/python3" -m pip install --quiet flask-sock "$ROOT/venv/bin/python3" -m pip install --quiet flask-sock
fi fi
-166
View File
@@ -1,166 +0,0 @@
# 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.
@@ -0,0 +1,13 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tesm-licensing-server"
version = "2.0.0"
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
requires-python = ">=3.11"
dependencies = ["tesm-licensing==2.0.0", "cryptography>=41"]
[tool.setuptools.packages.find]
where = ["src"]
@@ -0,0 +1,263 @@
"""Die Serverhaelfte des Lizenzprotokolls: ausstellen, erneuern, antworten.
Dieses Paket liegt **nur** beim Lizenzserver. Ein Client verifiziert Lizenzen
und stellt Anfragen -- ausstellen kann er nichts, und er soll den Code dafuer
auch nicht mitbringen. Die gemeinsame Haelfte (kanonisches JSON, Signieren und
Pruefen, Fingerabdruck, Statusbewertung, Client-Anfragen) steht in
``tesm_licensing`` und wird von beiden Seiten benutzt.
Schluesselerzeugung gehoert ebenfalls hierher: der Server erzeugt seinen
Signaturschluessel und je Lizenz ein Client-Schluesselpaar. Der Client
*empfaengt* seinen Schluessel mit dem Bundle; er erzeugt nie einen.
"""
from __future__ import annotations
import base64
import uuid
from datetime import datetime, timedelta
from typing import Any, Callable, Iterable, Mapping, Sequence
from cryptography.hazmat.primitives.asymmetric import ed25519
from tesm_licensing import (
ACTIONS,
ALL_MODULES,
FORMAT_VERSION,
LICENSE_ROLES,
LICENSE_TYPES,
LIFETIME_EXPIRES_AT,
MAX_REPORT_ROWS,
RESPONSE_STATES,
SIG_CONTEXT_LICENSE,
SIG_CONTEXT_REQUEST,
SIG_CONTEXT_RESPONSE,
ProtocolViolation,
ReplayDetected,
SignatureInvalid,
canonical_bytes,
iso,
key_id,
new_nonce,
parse_iso,
utcnow,
)
from tesm_licensing.canonical import _b64d, sign, verify
from tesm_licensing.protocol import _FINGERPRINT_RE, _NONCE_RE, check_freshness
__all__ = [
"build_master_response",
"generate_keypair",
"issue_license",
"public_from_private",
"resign_license",
"verify_client_request",
]
def generate_keypair() -> tuple[str, str]:
"""Erzeugt ein Ed25519-Schluesselpaar. Rueckgabe: ``(private_b64, public_b64)``."""
private = ed25519.Ed25519PrivateKey.generate()
return (
base64.b64encode(private.private_bytes_raw()).decode("ascii"),
base64.b64encode(private.public_key().public_bytes_raw()).decode("ascii"),
)
def public_from_private(private_key_b64: str) -> str:
"""Leitet den oeffentlichen Teil aus einem privaten Schluessel ab."""
key = ed25519.Ed25519PrivateKey.from_private_bytes(
_b64d(private_key_b64, field="private_key")
)
return base64.b64encode(key.public_key().public_bytes_raw()).decode("ascii")
def _validate_issue_args(
license_type: str, role: str, modules: Sequence[str], lifetime: bool
) -> None:
if license_type not in LICENSE_TYPES:
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r}")
if role not in LICENSE_ROLES:
raise ValueError(f"Unbekannte Rolle: {role!r}")
unknown = sorted(set(modules) - set(ALL_MODULES))
if unknown:
raise ValueError(f"Unbekannte Module: {', '.join(unknown)}")
if lifetime and license_type == "trial":
# Dritte und letzte Absicherung derselben Regel (UI + Service pruefen
# vorher schon): eine unbefristete Trial-Lizenz waere eine permanente
# Lizenz ohne Module -- ein sinnloser Zustand.
raise ValueError("Trial-Lizenzen koennen nicht unbefristet sein")
if role != "customer" and modules:
raise ValueError("Lizenzserver-Lizenzen tragen keine TESM-Module")
def issue_license(
*,
customer: Mapping[str, Any],
license_type: str,
modules: Iterable[str],
valid_days: int,
master_private_key_b64: str,
master_public_key_b64: str,
master_endpoint: str,
vendor: Mapping[str, Any],
license_id: str | None = None,
role: str = "customer",
lifetime: bool = False,
seq: int = 1,
now: datetime | None = None,
) -> tuple[dict[str, Any], str]:
"""Stellt eine Lizenz aus.
Rueckgabe: ``(bundle, license_public_key)``. ``bundle`` ist die Datei, die
an den Kunden geht; ``license_public_key`` wandert in die Master-Datenbank,
damit der Master spaeter Client-Anfragen dieser Lizenz verifizieren kann.
"""
module_list = sorted({str(m) for m in modules})
_validate_issue_args(license_type, role, module_list, lifetime)
if not lifetime and int(valid_days) <= 0:
raise ValueError("valid_days muss positiv sein")
moment = now or utcnow()
client_private, client_public = generate_keypair()
payload: dict[str, Any] = {
"fmt": FORMAT_VERSION,
"license_id": license_id or str(uuid.uuid4()),
"seq": int(seq),
"customer": dict(customer),
"type": license_type,
"role": role,
"modules": module_list,
"lifetime": bool(lifetime),
"issued_at": iso(moment),
"not_before": iso(moment),
"expires_at": LIFETIME_EXPIRES_AT
if lifetime
else iso(moment + timedelta(days=int(valid_days))),
"license_pubkey": client_public,
"master_pubkey": master_public_key_b64,
"master_key_id": key_id(master_public_key_b64),
"master_endpoint": (master_endpoint or "").rstrip("/"),
"vendor": dict(vendor),
}
signature = sign(payload, master_private_key_b64, SIG_CONTEXT_LICENSE)
document = {**payload, "signature": signature}
bundle = {
"kind": "tesm-license-bundle",
"fmt": FORMAT_VERSION,
"license": document,
"client_key": client_private,
}
return bundle, client_public
def resign_license(
document: Mapping[str, Any],
*,
master_private_key_b64: str,
changes: Mapping[str, Any],
now: datetime | None = None,
) -> dict[str, Any]:
"""Erzeugt eine neu signierte Revision eines bestehenden Lizenzdokuments.
Wird fuer ``license_update`` im Heartbeat gebraucht (z. B. nachtraeglich
erweiterte Module oder verlaengerte Laufzeit). ``seq`` steigt dabei zwingend,
damit der Client eine aeltere Revision nicht als Update akzeptiert.
"""
payload = {k: v for k, v in document.items() if k != "signature"}
payload.update(changes)
payload["seq"] = int(document.get("seq") or 1) + 1
payload["issued_at"] = iso(now or utcnow())
_validate_issue_args(
str(payload.get("type")),
str(payload.get("role")),
[str(m) for m in payload.get("modules") or ()],
bool(payload.get("lifetime")),
)
payload["signature"] = sign(payload, master_private_key_b64, SIG_CONTEXT_LICENSE)
return payload
def verify_client_request(
request: Mapping[str, Any],
license_public_key: str,
*,
expected_action: str | None = None,
now: datetime | None = None,
seen_nonce: Callable[[str], bool] | None = None,
) -> None:
"""Verifiziert eine Client-Anfrage serverseitig. Wirft bei Problemen.
``seen_nonce`` ist ein Callback, der ``True`` zurueckgibt, wenn die Nonce
bereits verbraucht ist. Der Aufrufer entscheidet ueber die Persistenz
(in dieser Codebasis: Tabelle ``license_nonces``).
"""
if not isinstance(request, Mapping):
raise ProtocolViolation("Anfrage ist kein Objekt")
if int(request.get("fmt") or 0) != FORMAT_VERSION:
raise ProtocolViolation("Nicht unterstuetzte Protokollversion")
action = str(request.get("action") or "")
if action not in ACTIONS:
raise ProtocolViolation("Unbekannte Aktion")
if expected_action and action != expected_action:
raise ProtocolViolation("Aktion passt nicht zum Endpunkt")
nonce = str(request.get("nonce") or "")
if not _NONCE_RE.match(nonce):
raise ProtocolViolation("Nonce hat ein unerwartetes Format")
fingerprint = str(request.get("fingerprint") or "")
if not _FINGERPRINT_RE.match(fingerprint):
raise ProtocolViolation("Fingerprint hat ein unerwartetes Format")
payload = {k: v for k, v in request.items() if k != "signature"}
if not verify(
payload, str(request.get("signature") or ""), license_public_key, SIG_CONTEXT_REQUEST
):
raise SignatureInvalid("Signatur der Anfrage ist ungueltig")
# Frische und Replay erst *nach* der Signaturpruefung: unsignierte Muellanfragen
# sollen keine Nonce-Eintraege in der Datenbank erzeugen koennen.
moment = now or utcnow()
check_freshness(str(request.get("timestamp") or ""), moment, "Anfrage")
if seen_nonce is not None and seen_nonce(nonce):
raise ReplayDetected("Diese Anfrage wurde bereits verarbeitet")
report = request.get("report")
if report is not None:
if not isinstance(report, Mapping):
raise ProtocolViolation("Bericht ist kein Objekt")
total = sum(len(report.get(key) or ()) for key in ("customers", "tickets", "licenses"))
if total > MAX_REPORT_ROWS:
raise ProtocolViolation("Bericht ueberschreitet die Groessengrenze")
def build_master_response(
action: str,
*,
license_id: str,
fingerprint: str,
request_nonce: str,
master_private_key: str,
status: str = "ok",
detail: str = "",
license_update: Mapping[str, Any] | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
"""Baut eine signierte Master-Antwort, gebunden an die Nonce der Anfrage."""
if status not in RESPONSE_STATES:
raise ProtocolViolation(f"Unbekannter Antwortstatus: {status!r}")
payload: dict[str, Any] = {
"fmt": FORMAT_VERSION,
"license_id": license_id,
"fingerprint": fingerprint,
"action": action,
"status": status,
"detail": detail,
"nonce": request_nonce,
"timestamp": iso(now or utcnow()),
}
if license_update is not None:
payload["license_update"] = dict(license_update)
payload["signature"] = sign(payload, master_private_key, SIG_CONTEXT_RESPONSE)
return payload
@@ -16,8 +16,6 @@ from .canonical import (
canonical_bytes, canonical_bytes,
decode_code, decode_code,
encode_code, encode_code,
generate_keypair,
public_from_private,
sign, sign,
verify, verify,
) )
@@ -44,12 +42,10 @@ from .license import (
ROLE_LABELS, ROLE_LABELS,
TRIAL_DEFAULT_DAYS, TRIAL_DEFAULT_DAYS,
LicenseStatus, LicenseStatus,
issue_license,
iso, iso,
key_id, key_id,
license_status, license_status,
parse_iso, parse_iso,
resign_license,
split_bundle, split_bundle,
utcnow, utcnow,
verify_license, verify_license,
@@ -62,9 +58,7 @@ from .protocol import (
REQUEST_MAX_AGE_SECONDS, REQUEST_MAX_AGE_SECONDS,
RESPONSE_STATES, RESPONSE_STATES,
build_client_request, build_client_request,
build_master_response,
new_nonce, new_nonce,
verify_client_request,
verify_master_response, verify_master_response,
) )
@@ -100,26 +94,20 @@ __all__ = [
"TRIAL_DEFAULT_DAYS", "TRIAL_DEFAULT_DAYS",
"UntrustedIssuer", "UntrustedIssuer",
"build_client_request", "build_client_request",
"build_master_response",
"canonical_bytes", "canonical_bytes",
"decode_code", "decode_code",
"encode_code", "encode_code",
"generate_keypair",
"issue_license",
"iso", "iso",
"key_id", "key_id",
"license_status", "license_status",
"machine_identity", "machine_identity",
"new_nonce", "new_nonce",
"parse_iso", "parse_iso",
"public_from_private",
"resign_license",
"sign", "sign",
"split_bundle", "split_bundle",
"system_fingerprint", "system_fingerprint",
"utcnow", "utcnow",
"verify", "verify",
"verify_client_request",
"verify_license", "verify_license",
"verify_master_response", "verify_master_response",
] ]
@@ -45,21 +45,8 @@ def _b64d(value: str, *, field: str) -> bytes:
raise ValueError(f"{field} ist kein gueltiges Base64") from exc raise ValueError(f"{field} ist kein gueltiges Base64") from exc
def generate_keypair() -> tuple[str, str]:
"""Erzeugt ein Ed25519-Schluesselpaar. Rueckgabe: ``(private_b64, public_b64)``."""
private = ed25519.Ed25519PrivateKey.generate()
return (
base64.b64encode(private.private_bytes_raw()).decode("ascii"),
base64.b64encode(private.public_key().public_bytes_raw()).decode("ascii"),
)
def public_from_private(private_key_b64: str) -> str:
"""Leitet den oeffentlichen Teil aus einem privaten Schluessel ab."""
key = ed25519.Ed25519PrivateKey.from_private_bytes(
_b64d(private_key_b64, field="private_key")
)
return base64.b64encode(key.public_key().public_bytes_raw()).decode("ascii")
def sign(payload: Mapping[str, Any], private_key_b64: str, context: bytes) -> str: def sign(payload: Mapping[str, Any], private_key_b64: str, context: bytes) -> str:
@@ -34,7 +34,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Iterable, Mapping, Sequence from typing import Any, Iterable, Mapping, Sequence
from .canonical import SIG_CONTEXT_LICENSE, generate_keypair, sign, verify from .canonical import SIG_CONTEXT_LICENSE, sign, verify
from .errors import InvalidLicenseFile, SignatureInvalid, UntrustedIssuer from .errors import InvalidLicenseFile, SignatureInvalid, UntrustedIssuer
FORMAT_VERSION = 2 FORMAT_VERSION = 2
@@ -147,110 +147,10 @@ class LicenseStatus:
return self.modules_active and name in self.modules return self.modules_active and name in self.modules
def _validate_issue_args(
license_type: str, role: str, modules: Sequence[str], lifetime: bool
) -> None:
if license_type not in LICENSE_TYPES:
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r}")
if role not in LICENSE_ROLES:
raise ValueError(f"Unbekannte Rolle: {role!r}")
unknown = sorted(set(modules) - set(ALL_MODULES))
if unknown:
raise ValueError(f"Unbekannte Module: {', '.join(unknown)}")
if lifetime and license_type == "trial":
# Dritte und letzte Absicherung derselben Regel (UI + Service pruefen
# vorher schon): eine unbefristete Trial-Lizenz waere eine permanente
# Lizenz ohne Module -- ein sinnloser Zustand.
raise ValueError("Trial-Lizenzen koennen nicht unbefristet sein")
if role != "customer" and modules:
raise ValueError("Lizenzserver-Lizenzen tragen keine TESM-Module")
def issue_license(
*,
customer: Mapping[str, Any],
license_type: str,
modules: Iterable[str],
valid_days: int,
master_private_key_b64: str,
master_public_key_b64: str,
master_endpoint: str,
vendor: Mapping[str, Any],
license_id: str | None = None,
role: str = "customer",
lifetime: bool = False,
seq: int = 1,
now: datetime | None = None,
) -> tuple[dict[str, Any], str]:
"""Stellt eine Lizenz aus.
Rueckgabe: ``(bundle, license_public_key)``. ``bundle`` ist die Datei, die
an den Kunden geht; ``license_public_key`` wandert in die Master-Datenbank,
damit der Master spaeter Client-Anfragen dieser Lizenz verifizieren kann.
"""
module_list = sorted({str(m) for m in modules})
_validate_issue_args(license_type, role, module_list, lifetime)
if not lifetime and int(valid_days) <= 0:
raise ValueError("valid_days muss positiv sein")
moment = now or utcnow()
client_private, client_public = generate_keypair()
payload: dict[str, Any] = {
"fmt": FORMAT_VERSION,
"license_id": license_id or str(uuid.uuid4()),
"seq": int(seq),
"customer": dict(customer),
"type": license_type,
"role": role,
"modules": module_list,
"lifetime": bool(lifetime),
"issued_at": iso(moment),
"not_before": iso(moment),
"expires_at": LIFETIME_EXPIRES_AT
if lifetime
else iso(moment + timedelta(days=int(valid_days))),
"license_pubkey": client_public,
"master_pubkey": master_public_key_b64,
"master_key_id": key_id(master_public_key_b64),
"master_endpoint": (master_endpoint or "").rstrip("/"),
"vendor": dict(vendor),
}
signature = sign(payload, master_private_key_b64, SIG_CONTEXT_LICENSE)
document = {**payload, "signature": signature}
bundle = {
"kind": "tesm-license-bundle",
"fmt": FORMAT_VERSION,
"license": document,
"client_key": client_private,
}
return bundle, client_public
def resign_license(
document: Mapping[str, Any],
*,
master_private_key_b64: str,
changes: Mapping[str, Any],
now: datetime | None = None,
) -> dict[str, Any]:
"""Erzeugt eine neu signierte Revision eines bestehenden Lizenzdokuments.
Wird fuer ``license_update`` im Heartbeat gebraucht (z. B. nachtraeglich
erweiterte Module oder verlaengerte Laufzeit). ``seq`` steigt dabei zwingend,
damit der Client eine aeltere Revision nicht als Update akzeptiert.
"""
payload = {k: v for k, v in document.items() if k != "signature"}
payload.update(changes)
payload["seq"] = int(document.get("seq") or 1) + 1
payload["issued_at"] = iso(now or utcnow())
_validate_issue_args(
str(payload.get("type")),
str(payload.get("role")),
[str(m) for m in payload.get("modules") or ()],
bool(payload.get("lifetime")),
)
payload["signature"] = sign(payload, master_private_key_b64, SIG_CONTEXT_LICENSE)
return payload
def split_bundle(bundle: Mapping[str, Any]) -> tuple[dict[str, Any], str]: def split_bundle(bundle: Mapping[str, Any]) -> tuple[dict[str, Any], str]:
@@ -95,7 +95,7 @@ def build_client_request(
return payload return payload
def _check_freshness(timestamp: str, now: datetime, what: str) -> None: def check_freshness(timestamp: str, now: datetime, what: str) -> None:
try: try:
stamp = parse_iso(timestamp) stamp = parse_iso(timestamp)
except (ValueError, TypeError) as exc: except (ValueError, TypeError) as exc:
@@ -106,89 +106,8 @@ def _check_freshness(timestamp: str, now: datetime, what: str) -> None:
raise ReplayDetected(f"{what}: Zeitstempel ist zu alt") raise ReplayDetected(f"{what}: Zeitstempel ist zu alt")
def verify_client_request(
request: Mapping[str, Any],
license_public_key: str,
*,
expected_action: str | None = None,
now: datetime | None = None,
seen_nonce: Callable[[str], bool] | None = None,
) -> None:
"""Verifiziert eine Client-Anfrage serverseitig. Wirft bei Problemen.
``seen_nonce`` ist ein Callback, der ``True`` zurueckgibt, wenn die Nonce
bereits verbraucht ist. Der Aufrufer entscheidet ueber die Persistenz
(in dieser Codebasis: Tabelle ``license_nonces``).
"""
if not isinstance(request, Mapping):
raise ProtocolViolation("Anfrage ist kein Objekt")
if int(request.get("fmt") or 0) != FORMAT_VERSION:
raise ProtocolViolation("Nicht unterstuetzte Protokollversion")
action = str(request.get("action") or "")
if action not in ACTIONS:
raise ProtocolViolation("Unbekannte Aktion")
if expected_action and action != expected_action:
raise ProtocolViolation("Aktion passt nicht zum Endpunkt")
nonce = str(request.get("nonce") or "")
if not _NONCE_RE.match(nonce):
raise ProtocolViolation("Nonce hat ein unerwartetes Format")
fingerprint = str(request.get("fingerprint") or "")
if not _FINGERPRINT_RE.match(fingerprint):
raise ProtocolViolation("Fingerprint hat ein unerwartetes Format")
payload = {k: v for k, v in request.items() if k != "signature"}
if not verify(
payload, str(request.get("signature") or ""), license_public_key, SIG_CONTEXT_REQUEST
):
raise SignatureInvalid("Signatur der Anfrage ist ungueltig")
# Frische und Replay erst *nach* der Signaturpruefung: unsignierte Muellanfragen
# sollen keine Nonce-Eintraege in der Datenbank erzeugen koennen.
moment = now or utcnow()
_check_freshness(str(request.get("timestamp") or ""), moment, "Anfrage")
if seen_nonce is not None and seen_nonce(nonce):
raise ReplayDetected("Diese Anfrage wurde bereits verarbeitet")
report = request.get("report")
if report is not None:
if not isinstance(report, Mapping):
raise ProtocolViolation("Bericht ist kein Objekt")
total = sum(len(report.get(key) or ()) for key in ("customers", "tickets", "licenses"))
if total > MAX_REPORT_ROWS:
raise ProtocolViolation("Bericht ueberschreitet die Groessengrenze")
def build_master_response(
action: str,
*,
license_id: str,
fingerprint: str,
request_nonce: str,
master_private_key: str,
status: str = "ok",
detail: str = "",
license_update: Mapping[str, Any] | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
"""Baut eine signierte Master-Antwort, gebunden an die Nonce der Anfrage."""
if status not in RESPONSE_STATES:
raise ProtocolViolation(f"Unbekannter Antwortstatus: {status!r}")
payload: dict[str, Any] = {
"fmt": FORMAT_VERSION,
"license_id": license_id,
"fingerprint": fingerprint,
"action": action,
"status": status,
"detail": detail,
"nonce": request_nonce,
"timestamp": iso(now or utcnow()),
}
if license_update is not None:
payload["license_update"] = dict(license_update)
payload["signature"] = sign(payload, master_private_key, SIG_CONTEXT_RESPONSE)
return payload
def verify_master_response( def verify_master_response(
@@ -230,7 +149,7 @@ def verify_master_response(
if str(response.get("status") or "") not in RESPONSE_STATES: if str(response.get("status") or "") not in RESPONSE_STATES:
raise ProtocolViolation("Unbekannter Antwortstatus") raise ProtocolViolation("Unbekannter Antwortstatus")
_check_freshness(str(response.get("timestamp") or ""), now or utcnow(), "Antwort") check_freshness(str(response.get("timestamp") or ""), now or utcnow(), "Antwort")
update = response.get("license_update") update = response.get("license_update")
if update is not None: if update is not None:
Binary file not shown.
-211
View File
@@ -1,211 +0,0 @@
"""Gemeinsame Testfixtures.
Jeder Test bekommt eine frische Instanz in einem temporaeren Verzeichnis --
eigene Datenbank, eigene Schluessel, eigenes Protokollverzeichnis. Nichts
beruehrt eine echte Installation.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Iterator
import pytest
@pytest.fixture()
def instance_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
base = tmp_path / "instance"
monkeypatch.setenv("TESM_BASE_DIR", str(base))
monkeypatch.setenv("TESM_LICENSE_BASE_DIR", str(tmp_path / "instance-license"))
# Tests laufen ueber http:// -- ein Secure-Cookie wuerde nie gesetzt.
monkeypatch.setenv("TESM_COOKIE_SECURE", "0")
monkeypatch.setenv("TESM_LICENSE_COOKIE_SECURE", "0")
monkeypatch.setenv("TESM_DEBUG", "0")
monkeypatch.setenv("TESM_LICENSE_DEBUG", "0")
return base
@pytest.fixture()
def core_app(instance_env: Path) -> Iterator[Any]:
"""Minimale App auf Basis von ``tesm_core`` -- ohne Fachlogik der beiden Apps."""
from flask import render_template_string
from tesm_core import load_core_config
from tesm_core.app_factory import create_base_app
from tesm_core.auth.models import login_required
from tesm_core.nav import NavItem, NavTree
from tesm_core.rbac.common import administration_area, observability_area
from tesm_core.rbac.model import Action, Area, PermissionTree, Resource, RolePreset
demo = Area(
key="demo",
label="Demo",
icon="grid",
resources=(
Resource(
key="widgets",
label="Widgets",
actions=(Action.VIEW, Action.CREATE, Action.EDIT, Action.DELETE, Action.SECRETS),
),
),
)
tree = PermissionTree([demo, administration_area(), observability_area()])
nav = NavTree(
[
NavItem(key="index", label="Start", icon="grid", endpoint="index"),
NavItem(
key="widgets", label="Widgets", icon="cpu", endpoint="widgets",
permissions=("widgets.view",),
),
NavItem(
key="administration",
label="Verwaltung",
icon="sliders",
children=(
NavItem(
key="users", label="Benutzer", icon="users", endpoint="admin.users",
permissions=("users.view",),
),
NavItem(
key="groups", label="Gruppen", icon="shield-users", endpoint="admin.groups",
permissions=("groups.view",),
),
NavItem(
key="trash", label="Papierkorb", icon="trash", endpoint="trash.overview",
permissions=("trash.view",),
),
NavItem(
key="diagnostics", label="Diagnose", icon="info",
endpoint="diagnostics.overview", permissions=("diagnostics.view",),
),
),
),
]
)
presets = (
RolePreset(
key="viewer",
name="Benutzer",
description="Nur lesen",
grants=(("widgets", (Action.VIEW,)),),
is_default=True,
),
)
config = load_core_config(
app_key="tesm", app_name="TESM Test", env_prefix="TESM_", default_base=instance_env
)
app = create_base_app(
config=config,
permissions=tree,
nav=nav,
role_presets=presets,
app_short="TT",
app_edition="Test",
enable_license=False,
)
@app.get("/")
def index(): # type: ignore[no-untyped-def]
return render_template_string(
'{% extends "tesm_core/base.html" %}{% block page_title %}Start{% endblock %}'
"{% block content %}<p>Start</p>{% endblock %}"
)
@app.get("/widgets")
@login_required
def widgets(): # type: ignore[no-untyped-def]
return render_template_string(
'{% extends "tesm_core/base.html" %}{% block page_title %}Widgets{% endblock %}'
"{% block content %}<p>Widgets</p>{% endblock %}"
)
app.config.update(TESTING=True)
yield app
@pytest.fixture()
def client(core_app: Any) -> Any:
return core_app.test_client()
@pytest.fixture()
def make_user(core_app: Any):
"""Erzeugt ein Konto direkt in der Datenbank."""
def factory(
username: str = "tester",
password: str = "Sicher-Passwort-2026",
*,
is_admin: bool = False,
permissions: tuple[str, ...] = (),
is_locked: bool = False,
) -> dict[str, Any]:
from tesm_core.db import Database
from tesm_core.extension import core
from tesm_core.security import passwords
extension = core(core_app)
with extension.database.session() as conn, extension.database.transaction(conn):
cursor = conn.execute(
"INSERT INTO users (username, password_hash, is_admin, is_locked, auth_source, "
"created_at, updated_at) VALUES (?,?,?,?,'local',datetime('now'),datetime('now'))",
(username, passwords.hash_password(password), int(is_admin), int(is_locked)),
)
user_id = int(cursor.lastrowid or 0)
if permissions:
group = conn.execute(
"INSERT INTO groups (name, description, created_at, updated_at) "
"VALUES (?,'Testgruppe',datetime('now'),datetime('now'))",
(f"grp-{username}",),
)
group_id = int(group.lastrowid or 0)
conn.executemany(
"INSERT INTO group_permissions (group_id, permission) VALUES (?,?)",
[(group_id, p) for p in extension.permissions.sanitize(permissions)],
)
conn.execute(
"INSERT INTO user_groups (user_id, group_id) VALUES (?,?)", (user_id, group_id)
)
row = Database.one(conn, "SELECT * FROM users WHERE id=?", (user_id,))
assert row is not None
return {**row, "password": password}
return factory
@pytest.fixture()
def login(client: Any):
def do_login(username: str, password: str) -> Any:
return client.post(
"/login",
data={"username": username, "password": password, "csrf_token": _token(client)},
follow_redirects=True,
)
return do_login
def _token(client: Any) -> str:
"""Holt ein CSRF-Token aus der Anmeldeseite."""
page = client.get("/login")
body = page.get_data(as_text=True)
marker = 'name="csrf_token" value="'
start = body.index(marker) + len(marker)
return body[start : body.index('"', start)]
@pytest.fixture()
def csrf_token(client: Any):
def get(path: str = "/login") -> str:
page = client.get(path)
body = page.get_data(as_text=True)
marker = 'name="csrf_token" value="'
if marker not in body:
marker = 'data-csrf="'
start = body.index(marker) + len(marker)
return body[start : body.index('"', start)]
return get
-493
View File
@@ -1,493 +0,0 @@
"""End-to-End: der komplette Lizenz-Lebenszyklus ueber beide Anwendungen hinweg.
Laeuft auf dem Zielhost gegen zwei echte, laufende Installationen:
Lizenzserver: Kunde -> Ticket -> Lizenz ausstellen -> Bundle herunterladen
TESM : Bundle einspielen -> aktivieren (online gegen den Lizenzserver)
-> Module freigeschaltet -> Heartbeat -> Revision -> Widerruf
Das ist der Pfad, der im Vorgaengerprojekt nur von Hand pruefbar war -- und
genau dort sassen die teuren Fehler. Geprueft wird deshalb nicht nur "es geht",
sondern auch: Doppelausstellung wird verhindert, Module sind vor der
Aktivierung wirklich gesperrt, eine neue Revision nimmt Module wieder weg, und
ein Widerruf kommt beim Client an.
Aufruf:
python3 e2e_license.py http://127.0.0.1:8081 http://127.0.0.1:8080 admin 'Passwort'
"""
from __future__ import annotations
import http.cookiejar
import json
import re
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
CSRF_RE = re.compile(r'name="csrf_token" value="([^"]+)"')
BODY_CSRF_RE = re.compile(r'data-csrf="([^"]+)"')
PASSED: list[str] = []
FAILED: list[str] = []
def check(name: str, condition: bool, detail: str = "") -> None:
if condition:
PASSED.append(name)
print(f" [ok] {name}")
else:
FAILED.append(f"{name}: {detail}")
print(f" [FEHL] {name} -- {detail}")
class Response:
def __init__(self, status: int, body: bytes, url: str, headers: dict[str, str]) -> None:
self.status = status
self.raw = body
self.url = url
self.headers = headers
@property
def text(self) -> str:
return self.raw.decode("utf-8", "replace")
class Client:
def __init__(self, base: str, label: str) -> None:
self.base = base.rstrip("/")
self.label = label
jar = http.cookiejar.CookieJar()
# Der Test laeuft auf dem Zielhost gegen dessen eigenes, selbst
# ausgestelltes Zertifikat. Geprueft wird, dass TLS steht und die
# richtige Anwendung antwortet -- nicht die Vertrauenskette.
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar),
urllib.request.HTTPSHandler(context=context),
)
self.opener.addheaders = [("User-Agent", "tesm-e2e-license/2.0")]
def _open(self, request: urllib.request.Request) -> Response:
try:
with self.opener.open(request, timeout=60) as response:
return Response(
response.status, response.read(), response.geturl(), dict(response.headers)
)
except urllib.error.HTTPError as exc:
return Response(exc.code, exc.read(), exc.url, dict(exc.headers))
def get(self, path: str) -> Response:
return self._open(urllib.request.Request(self.base + path))
def text(self, path: str) -> str:
return self.get(path).text
def post(self, path: str, data: dict[str, object]) -> Response:
pairs = [
(key, str(item))
for key, value in data.items()
for item in (value if isinstance(value, (list, tuple)) else [value])
]
body = urllib.parse.urlencode(pairs).encode("utf-8")
request = urllib.request.Request(
self.base + path,
data=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Origin": self.base,
"Referer": self.base + path,
},
)
return self._open(request)
def post_file(
self, path: str, field: str, filename: str, content: bytes, extra: dict[str, str]
) -> Response:
boundary = "----tesm-e2e-boundary-3f88"
parts: list[bytes] = []
for key, value in extra.items():
parts.append(
f'--{boundary}\r\nContent-Disposition: form-data; name="{key}"\r\n\r\n'
f"{value}\r\n".encode()
)
parts.append(
f'--{boundary}\r\nContent-Disposition: form-data; name="{field}"; '
f'filename="{filename}"\r\nContent-Type: application/json\r\n\r\n'.encode()
+ content
+ b"\r\n"
)
parts.append(f"--{boundary}--\r\n".encode())
request = urllib.request.Request(
self.base + path,
data=b"".join(parts),
headers={
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Origin": self.base,
"Referer": self.base + path,
},
)
return self._open(request)
def token(self, path: str) -> str:
body = self.text(path)
match = CSRF_RE.search(body) or BODY_CSRF_RE.search(body)
if not match:
raise AssertionError(f"{self.label}: kein CSRF-Token auf {path}")
return match.group(1)
def login(self, username: str, password: str) -> bool:
token = self.token("/login")
response = self.post(
"/login", {"username": username, "password": password, "csrf_token": token}
)
return response.status == 200 and "/login" not in response.url
def main(
server_base: str,
client_base: str,
username: str,
password: str,
endpoint: str = "",
) -> int:
# Der Endpunkt, den TESM spaeter anruft, muss nicht die Adresse sein, unter
# der ein Mensch den Lizenzserver bedient. Auf einem Host, der beide
# Anwendungen traegt, ist der direkte Weg ueber die Loopback-Adresse der
# ehrlichere -- er umgeht Namensaufloesung und Zertifikatspruefung, die
# zwischen zwei Diensten derselben Maschine nichts beitragen.
endpoint = (endpoint or server_base).rstrip("/")
server = Client(server_base, "Lizenzserver")
tesm = Client(client_base, "TESM")
print(f"\n== Lizenz-Lebenszyklus: {server_base} -> {client_base} ==\n")
print("Anmeldung:")
check("Lizenzserver", server.login(username, password), "fehlgeschlagen")
check("TESM", tesm.login(username, password), "fehlgeschlagen")
if FAILED:
print("\nOhne Anmeldung ist der Rest sinnlos -- Abbruch.")
return 1
# -- Anbieterdaten: der Endpunkt landet spaeter in jeder Lizenz -----------
print("\nAnbieterdaten und Endpunkt:")
token = server.token("/einstellungen/anbieter")
response = server.post(
"/einstellungen/anbieter",
{
"csrf_token": token,
"vendor_name": "WiS GmbH",
"vendor_email": "lizenzen@wis.invalid",
"vendor_phone": "+49 000 000000",
"vendor_address": "Musterweg 1, 12345 Musterstadt",
"master_endpoint": endpoint,
},
)
check(
"Anbieterdaten gespeichert",
"gespeichert" in response.text or "Keine Aenderungen" in response.text,
response.text[:150],
)
token = server.token("/einstellungen/anbieter")
response = server.post(
"/einstellungen/anbieter", {"csrf_token": token, "master_endpoint": "ftp://falsch"}
)
check("Ungueltiger Endpunkt abgelehnt", "muss mit http" in response.text, "keine Meldung")
# -- Kunde ---------------------------------------------------------------
print("\nKunde:")
token = server.token("/kunden/")
response = server.post(
"/kunden/neu",
{
"csrf_token": token,
"name": "Beispiel AG",
"contact_email": "it@beispiel.invalid",
"contact_person": "Frau Muster",
"street": "Hauptstrasse",
"house_number": "7",
"postal_code": "12345",
"city": "Musterstadt",
},
)
check("Kunde angelegt", "angelegt" in response.text, response.text[:150])
match = re.search(r"/kunden/(\d+)$", response.url)
customer_id = match.group(1) if match else ""
check("Kunden-Nummer erhalten", bool(customer_id), response.url)
# -- Ticket ---------------------------------------------------------------
print("\nTicket:")
token = server.token("/tickets/neu")
response = server.post(
"/tickets/neu",
{
"csrf_token": token,
"customer_id": customer_id,
"type": "enterprise",
"modules": ["dhcp", "fileshare", "maintenance"],
"valid_days": "365",
"role": "customer",
"notes": "E2E-Test",
},
)
check("Ticket angelegt", "angelegt" in response.text, response.text[:150])
match = re.search(r"/tickets/([^/]+)$", response.url)
ticket_id = match.group(1) if match else ""
check("Ticket-Kennung erhalten", bool(ticket_id), response.url)
token = server.token("/tickets/neu")
response = server.post(
"/tickets/neu",
{
"csrf_token": token,
"customer_id": customer_id,
"type": "trial",
"valid_days": "30",
"lifetime": "1",
"role": "customer",
},
)
check(
"Testlizenz kann nicht unbefristet sein",
"nicht unbefristet" in response.text,
"Regel greift nicht",
)
# -- Lizenz ausstellen ----------------------------------------------------
print("\nLizenz ausstellen:")
token = server.token(f"/tickets/{ticket_id}")
response = server.post(f"/lizenzen/aus-ticket/{ticket_id}", {"csrf_token": token})
check("Lizenz ausgestellt", "ausgestellt" in response.text, response.text[:200])
match = re.search(r"/lizenzen/([0-9a-f-]{36})$", response.url)
license_id = match.group(1) if match else ""
check("Lizenz-Kennung erhalten", bool(license_id), response.url)
token = server.token(f"/tickets/{ticket_id}")
response = server.post(f"/lizenzen/aus-ticket/{ticket_id}", {"csrf_token": token})
check(
"Doppelklick stellt keine zweite Lizenz aus",
"bereits eine Lizenz offen" in response.text,
"zweite Lizenz entstanden",
)
# -- Bundle herunterladen -------------------------------------------------
print("\nAuslieferung:")
response = server.get(f"/lizenzen/{license_id}/datei")
check("Bundle heruntergeladen", response.status == 200, f"HTTP {response.status}")
check(
"Als Anhang ausgeliefert",
"attachment" in response.headers.get("Content-Disposition", ""),
response.headers.get("Content-Disposition", ""),
)
check(
"Nicht zwischenspeicherbar",
"no-store" in response.headers.get("Cache-Control", ""),
response.headers.get("Cache-Control", ""),
)
bundle_bytes = response.raw
try:
bundle = json.loads(bundle_bytes.decode("utf-8"))
except ValueError:
check("Bundle ist gueltiges JSON", False, bundle_bytes[:120].decode("latin1"))
return 1
check("Bundle ist gueltiges JSON", True)
check(
"Dokument und privater Clientschluessel enthalten",
"license" in bundle and "client_key" in bundle,
str(sorted(bundle))[:100],
)
document = bundle.get("license", {})
check(
"Endpunkt eingebettet",
document.get("master_endpoint") == endpoint,
str(document.get("master_endpoint")),
)
check(
"Alle drei Module enthalten",
set(document.get("modules", [])) == {"dhcp", "fileshare", "maintenance"},
str(document.get("modules")),
)
check("Rolle ist customer", document.get("role") == "customer", str(document.get("role")))
body = server.text(f"/lizenzen/{license_id}")
check("Zustellprotokoll vermerkt den Download", "ownload" in body, "kein Eintrag")
# -- Einspielen in TESM ---------------------------------------------------
print("\nEinspielen in TESM:")
body = tesm.text("/lizenz/")
if "keine Lizenz installiert" in body:
check("Vorher keine Lizenz installiert", True)
else:
# Bei einem wiederholten Lauf liegt noch die Lizenz des Vorlaufs. Das ist
# kein Fehler -- das Einspielen ersetzt sie, und genau das wird gleich
# geprueft.
print(" [info] Es liegt bereits eine Lizenz -- wiederholter Lauf, wird ersetzt.")
token = tesm.token("/lizenz/")
response = tesm.post_file(
"/lizenz/einspielen", "license_file", "lizenz.json", bundle_bytes, {"csrf_token": token}
)
check("Lizenz eingespielt", "eingespielt" in response.text, response.text[:250])
check(
"Aussteller beim ersten Mal gepinnt",
"Aussteller gepinnt" in response.text,
"kein Pinning erkennbar",
)
check("Noch nicht aktiviert", "nicht aktiviert" in response.text, "Zustand unerwartet")
print("\nModule vor der Aktivierung:")
for path in ("/dhcp/", "/wartung/", "/dateifreigaben/"):
check(f"{path} gesperrt", tesm.get(path).status == 404, "erreichbar")
# -- Manipuliertes Bundle -------------------------------------------------
print("\nManipuliertes Bundle:")
tampered = json.loads(bundle_bytes.decode("utf-8"))
tampered["license"]["modules"] = ["dhcp", "fileshare", "maintenance", "erfunden"]
token = tesm.token("/lizenz/")
response = tesm.post_file(
"/lizenz/einspielen",
"license_file",
"boese.json",
json.dumps(tampered).encode("utf-8"),
{"csrf_token": token},
)
check(
"Veraenderte Lizenz wird abgewiesen",
"eingespielt" not in response.text,
"Manipulation akzeptiert!",
)
# -- Aktivierung ----------------------------------------------------------
print("\nAktivierung (online, echter HTTP-Aufruf an den Lizenzserver):")
token = tesm.token("/lizenz/")
response = tesm.post("/lizenz/aktivieren", {"csrf_token": token})
check("Aktivierung erfolgreich", "jetzt aktiv" in response.text, response.text[:300])
check("Zustand aktiviert", "nicht aktiviert" not in response.text, "Zustand unerwartet")
print("\nModule nach der Aktivierung:")
for path, needle in (
("/dhcp/", "DHCP"),
("/wartung/", "Wartung"),
("/dateifreigaben/", "Dateifreigaben"),
):
response = tesm.get(path)
check(
f"{path} freigeschaltet",
response.status == 200 and needle in response.text,
f"HTTP {response.status}",
)
print("\nSicht des Lizenzservers:")
body = server.text(f"/lizenzen/{license_id}")
check("Maschine gebunden", "noch nicht gebunden" not in body, "kein Fingerprint")
check("Aktivierungszeitpunkt vermerkt", "Aktiviert" in body, "fehlt")
# -- Heartbeat ------------------------------------------------------------
print("\nHeartbeat:")
token = tesm.token("/lizenz/")
response = tesm.post("/lizenz/heartbeat", {"csrf_token": token})
check("Heartbeat bestaetigt", "bestaetigt" in response.text, response.text[:250])
# -- Neue Revision: Modul entziehen ---------------------------------------
print("\nNeue Revision (Modul entziehen):")
token = server.token(f"/lizenzen/{license_id}")
response = server.post(
f"/lizenzen/{license_id}/neu-signieren",
{"csrf_token": token, "modules": ["dhcp"], "extend_days": ""},
)
check("Revision signiert", "Revision" in response.text, response.text[:250])
token = tesm.token("/lizenz/")
response = tesm.post("/lizenz/heartbeat", {"csrf_token": token})
check("Heartbeat holt die Revision", "bestaetigt" in response.text, response.text[:200])
check("Entzogenes Modul gesperrt", tesm.get("/wartung/").status == 404, "weiter offen")
check("Verbliebenes Modul offen", tesm.get("/dhcp/").status == 200, "gesperrt")
# -- Deaktivierung und erneute Aktivierung --------------------------------
print("\nDeaktivierung (Systemwechsel):")
token = tesm.token("/lizenz/")
response = tesm.post("/lizenz/deaktivieren", {"csrf_token": token})
check("Deaktivierung erfolgreich", "wurde deaktiviert" in response.text, response.text[:250])
check("Module wieder gesperrt", tesm.get("/dhcp/").status == 404, "weiter offen")
body = server.text(f"/lizenzen/{license_id}")
check("Server kennt die Deaktivierung", "deaktiviert" in body, "unerwartet")
print("\nErneute Aktivierung derselben Maschine:")
token = tesm.token("/lizenz/")
response = tesm.post("/lizenz/aktivieren", {"csrf_token": token})
check("Wieder aktiv", "jetzt aktiv" in response.text, response.text[:250])
check("Modul wieder offen", tesm.get("/dhcp/").status == 200, "gesperrt")
# -- Widerruf -------------------------------------------------------------
print("\nWiderruf durch den Anbieter:")
token = server.token(f"/lizenzen/{license_id}")
response = server.post(
f"/lizenzen/{license_id}/widerrufen", {"csrf_token": token, "reason": "E2E-Test"}
)
check("Widerruf gespeichert", "widerrufen" in response.text, response.text[:250])
token = tesm.token("/lizenz/")
response = tesm.post("/lizenz/heartbeat", {"csrf_token": token})
check("Client erfaehrt den Widerruf", "widerrufen" in response.text, response.text[:250])
check("Module nach Widerruf gesperrt", tesm.get("/dhcp/").status == 404, "weiter offen")
check("Lizenzseite bleibt erreichbar", tesm.get("/lizenz/").status == 200, "gesperrt")
# -- Aufraeumen -------------------------------------------------------
# Der Test endet mit einem Widerruf -- danach sind DHCP, Wartung und
# Dateifreigaben gesperrt. Eine Pruefung, die die Installation unbrauchbar
# zuruecklaesst, ist keine gute Pruefung: wer danach die Wartung sucht,
# findet einen 404 und haelt ihn fuer einen Fehler. Also wird zum Schluss
# eine frische Lizenz ausgestellt und aktiviert.
print("\nAufraeumen -- Installation wieder betriebsbereit machen:")
token = server.token(f"/tickets/{ticket_id}")
response = server.post(f"/lizenzen/aus-ticket/{ticket_id}", {"csrf_token": token})
check("Ersatzlizenz ausgestellt", "ausgestellt" in response.text, response.text[:200])
match = re.search(r"/lizenzen/([0-9a-f-]{36})$", response.url)
replacement_id = match.group(1) if match else ""
check("Kennung der Ersatzlizenz", bool(replacement_id), response.url)
response = server.get(f"/lizenzen/{replacement_id}/datei")
check("Ersatzbundle heruntergeladen", response.status == 200, f"HTTP {response.status}")
token = tesm.token("/lizenz/")
response = tesm.post_file(
"/lizenz/einspielen", "license_file", "lizenz.json", response.raw, {"csrf_token": token}
)
check("Ersatzlizenz eingespielt", "eingespielt" in response.text, response.text[:200])
token = tesm.token("/lizenz/")
response = tesm.post("/lizenz/aktivieren", {"csrf_token": token})
check("Ersatzlizenz aktiv", "jetzt aktiv" in response.text, response.text[:250])
for path in ("/dhcp/", "/wartung/", "/dateifreigaben/"):
check(f"{path} wieder erreichbar", tesm.get(path).status == 200, "weiter gesperrt")
# -- Protokolle -----------------------------------------------------------
print("\nProtokolle und Diagnose:")
body = server.text("/protokolle/aenderungen")
for action in ("license.issued", "license.downloaded", "license.resigned", "license.revoked"):
check(f"Eintrag {action}", action in body, "fehlt")
check(
"Diagnose Lizenzserver: Kette unversehrt",
"unversehrt" in server.text("/diagnose/"),
"gebrochen",
)
body = tesm.text("/protokolle/aenderungen")
for action in ("license.activate", "license.deactivate", "license.heartbeat"):
check(f"TESM-Eintrag {action}", action in body, "fehlt")
check("Diagnose TESM: Kette unversehrt", "unversehrt" in tesm.text("/diagnose/"), "gebrochen")
print(f"\n== Ergebnis: {len(PASSED)} bestanden, {len(FAILED)} fehlgeschlagen ==")
for entry in FAILED:
print(f" FEHLER: {entry}")
return 1 if FAILED else 0
if __name__ == "__main__":
if len(sys.argv) not in (5, 6):
print(__doc__)
raise SystemExit(2)
raise SystemExit(main(*sys.argv[1:]))
-251
View File
@@ -1,251 +0,0 @@
"""End-to-End-Rauchtest des Lizenzservers gegen eine laufende Installation.
Gegenstueck zu ``e2e_smoke.py`` (das prueft TESM). Beide Anwendungen teilen
sich den Kern; hier geht es um die Seiten und Schranken, die nur der
Lizenzserver hat -- Kunden, Tickets, Lizenzen, Kundenportal, Selbstbedienung
und die Maschinen-API.
Aufruf:
python3 e2e_license_server.py https://lizenz.example.org admin 'Passwort'
"""
from __future__ import annotations
import http.cookiejar
import json
import re
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
CSRF_RE = re.compile(r'name="csrf_token" value="([^"]+)"')
BODY_CSRF_RE = re.compile(r'data-csrf="([^"]+)"')
PASSED: list[str] = []
FAILED: list[str] = []
def check(name: str, condition: bool, detail: str = "") -> None:
if condition:
PASSED.append(name)
print(f" [ok] {name}")
else:
FAILED.append(f"{name}: {detail}")
print(f" [FEHL] {name} -- {detail}")
class Client:
def __init__(self, base: str) -> None:
self.base = base.rstrip("/")
self.https = self.base.startswith("https://")
jar = http.cookiejar.CookieJar()
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
tls = urllib.request.HTTPSHandler(context=context)
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar), tls
)
self.opener.addheaders = [("User-Agent", "tesm-e2e-lic/2.0")]
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *_args, **_kwargs): # type: ignore[override]
return None
self.plain = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar), _NoRedirect(), tls
)
self.plain.addheaders = [("User-Agent", "tesm-e2e-lic/2.0")]
def get(self, path: str) -> tuple[int, str, dict[str, str]]:
request = urllib.request.Request(self.base + path)
try:
with self.opener.open(request, timeout=30) as response:
return (
response.status,
response.read().decode("utf-8", "replace"),
dict(response.headers),
)
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", "replace"), dict(exc.headers)
def get_raw(self, path: str) -> int:
try:
with self.plain.open(urllib.request.Request(self.base + path), timeout=30) as response:
return response.status
except urllib.error.HTTPError as exc:
return exc.code
def post(self, path: str, data: dict[str, object]) -> tuple[int, str]:
encoded = urllib.parse.urlencode(
[
(key, str(item))
for key, value in data.items()
for item in (value if isinstance(value, (list, tuple)) else [value])
]
).encode("utf-8")
request = urllib.request.Request(
self.base + path,
data=encoded,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Origin": self.base,
"Referer": self.base + path,
},
)
try:
with self.opener.open(request, timeout=45) as response:
return response.status, response.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", "replace")
def post_json(self, path: str, payload: dict[str, object]) -> tuple[int, str]:
request = urllib.request.Request(
self.base + path,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
try:
with self.opener.open(request, timeout=30) as response:
return response.status, response.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", "replace")
def token(self, path: str) -> str:
_, body, _ = self.get(path)
match = CSRF_RE.search(body) or BODY_CSRF_RE.search(body)
if not match:
raise AssertionError(f"Kein CSRF-Token auf {path}")
return match.group(1)
def main(base: str, username: str, password: str) -> int:
client = Client(base)
print(f"\n== Rauchtest Lizenzserver gegen {base} ==\n")
print("Oeffentlich erreichbar:")
status, body, headers = client.get("/gesundheit")
check(
"Gesundheitsendpunkt",
status == 200 and json.loads(body)["status"] == "ok",
body[:80],
)
check(
"Antwortet als Lizenzserver",
json.loads(body).get("version") == "tesm-license",
body[:80],
)
status, body, headers = client.get("/login")
check("Anmeldeseite", status == 200 and "Anmelden" in body, f"HTTP {status}")
csp = headers.get("Content-Security-Policy", "")
check("CSP mit Nonce", "nonce-" in csp, csp[:80])
check(
"X-Frame-Options",
headers.get("X-Frame-Options") == "DENY",
str(headers.get("X-Frame-Options")),
)
if client.https:
cookie = headers.get("Set-Cookie", "")
check("Sitzungscookie mit Secure", "Secure" in cookie, cookie[:80] or "kein Cookie")
print("\nKundenportal (ohne Anmeldung erreichbar):")
status, body, _ = client.get("/portal/")
check("Portalseite", status == 200 and "Kundenportal" in body, f"HTTP {status}")
check("Fragt nach der Kennung, nicht nach einem Passwort", "Passwort" not in body, "unerwartet")
print("\nSchranken ohne Anmeldung:")
for path in ("/kunden/", "/tickets/", "/lizenzen/", "/verwaltung/benutzer", "/diagnose/"):
status = client.get_raw(path)
check(f"{path} verlangt Anmeldung", status in (302, 401), f"HTTP {status}")
print("\nCSRF:")
status, body = client.post("/login", {"username": "x", "password": "y"})
check("POST ohne Token wird abgewiesen", status == 403, f"HTTP {status}")
print("\nMaschinen-API (eigene Authentisierung, kein Cookie):")
status, body = client.post_json("/api/v1/licenses/activate", {"unsinn": True})
check(
"Unsinniger Antrag wird abgewiesen",
status in (400, 401, 403, 404, 422),
f"HTTP {status}",
)
check(
"Antwort ist maschinenlesbar, nicht HTML",
body.lstrip().startswith("{") and "error" in body,
body[:100],
)
check("Keine Interna in der Antwort", "Traceback" not in body, "Stacktrace nach aussen")
check(
"Kein CSRF-Token noetig (Maschinenschnittstelle)",
"Sicherheitstoken" not in body,
"API verlangt ein Formulartoken",
)
print("\nAnmeldung:")
token = client.token("/login")
status, body = client.post(
"/login", {"username": username, "password": password, "csrf_token": token}
)
check("Anmeldung erfolgreich", status == 200 and "Anmelden" not in body, f"HTTP {status}")
print("\nSeiten (angemeldet):")
pages = [
("/", "Uebersicht"),
("/kunden/", "Kunden"),
("/tickets/", "Tickets"),
("/tickets/neu", "Ticket"),
("/lizenzen/", "Lizenzen"),
("/lizenzen/offline-code", "Offline"),
("/lizenzen/sub-lizenzserver", "Lizenzserver"),
("/eigene-lizenz/", "Lizenz"),
("/einstellungen/system", "System"),
("/einstellungen/anbieter", "Anbieter"),
("/einstellungen/email", "Mail"),
("/einstellungen/netzwerk", "Netzwerk"),
("/einstellungen/webserver", "Webserver"),
("/einstellungen/verzeichnisdienst", "Verzeichnisdienst"),
("/protokolle/aenderungen", "Aenderungsprotokoll"),
("/sicherung/", "Sicherung"),
("/papierkorb/", "Papierkorb"),
("/diagnose/", "Diagnose"),
("/verwaltung/benutzer", "Benutzer"),
("/verwaltung/gruppen", "Gruppen"),
("/konto/sicherheit", "Sicherheit"),
]
for path, needle in pages:
status, body, _ = client.get(path)
check(
f"{path}",
status == 200 and needle in body and "Interner Fehler" not in body,
f"HTTP {status}",
)
print("\nEigene Master-Lizenz:")
status, body, _ = client.get("/eigene-lizenz/")
check("Master-Rolle erkannt", "master" in body.lower(), "Rolle fehlt")
print("\nDiagnose:")
status, body, _ = client.get("/diagnose/")
check("Audit-Kette unversehrt", "unversehrt" in body, "Kette gebrochen?")
check("Signaturschluessel vorhanden", "Signatur" in body, "kein Hinweis")
print("\nAbmeldung:")
token = client.token("/konto/")
client.post("/logout", {"csrf_token": token})
check("Nach Abmeldung gesperrt", client.get_raw("/kunden/") in (302, 401), "weiter offen")
print(f"\n== Ergebnis: {len(PASSED)} bestanden, {len(FAILED)} fehlgeschlagen ==")
for entry in FAILED:
print(f" FEHLER: {entry}")
return 1 if FAILED else 0
if __name__ == "__main__":
if len(sys.argv) != 4:
print(__doc__)
raise SystemExit(2)
raise SystemExit(main(sys.argv[1], sys.argv[2], sys.argv[3]))
-721
View File
@@ -1,721 +0,0 @@
"""Tests der Sicherheitsgrenzen des Kerns.
Jeder Test hier deckt eine Grenze ab, die im Vorgaengerprojekt entweder fehlte
oder nur im Template durchgesetzt wurde.
"""
from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timedelta, timezone
import pytest
from tesm_core import audit
from tesm_core.db import Database, NestedConnectionError
from tesm_core.extension import core
from tesm_core.keystore import DecryptionError, Keystore
from tesm_core.migrations import split_statements
from tesm_core.rbac import service as rbac
from tesm_core.rbac.model import Action, Area, PermissionTree, Resource, RolePreset
from tesm_core.security import passwords, totp
# --------------------------------------------------------------------------- #
# Rechtebaum
# --------------------------------------------------------------------------- #
def _tree() -> PermissionTree:
return PermissionTree(
[
Area(
key="ops",
label="Betrieb",
icon="cpu",
resources=(
Resource(
"devices",
"Geraete",
(Action.VIEW, Action.CREATE, Action.EDIT, Action.DELETE, Action.EXECUTE),
),
Resource("switches", "Switche", (Action.VIEW, Action.EDIT)),
),
),
Area(
key="admin",
label="Verwaltung",
icon="sliders",
resources=(Resource("users", "Benutzer", (Action.VIEW, Action.CREATE)),),
),
]
)
def test_area_columns_only_contain_used_actions():
tree = _tree()
ops = tree.area("ops")
admin = tree.area("admin")
assert Action.EXECUTE in ops.columns()
assert Action.EXECUTE not in admin.columns()
assert Action.DELETE not in admin.columns()
def test_sanitize_adds_view_and_area_switch():
tree = _tree()
result = tree.sanitize(["devices.delete"])
assert result == ["devices.delete", "devices.view", "ops.view"]
def test_sanitize_drops_unknown_permissions():
tree = _tree()
assert tree.sanitize(["gibtsnicht.view", "devices.view"]) == ["devices.view", "ops.view"]
def test_kill_switch_disables_all_children():
tree = _tree()
granted = {"devices.view", "devices.edit", "switches.view"}
assert tree.effective(granted) == set() # ohne ops.view greift nichts
assert tree.effective(granted | {"ops.view"}) == granted | {"ops.view"}
def test_duplicate_resource_keys_rejected():
with pytest.raises(ValueError):
PermissionTree(
[
Area("a", "A", "grid", (Resource("x", "X", (Action.VIEW,)),)),
Area("b", "B", "grid", (Resource("x", "X", (Action.VIEW,)),)),
]
)
def test_resource_without_view_rejected():
with pytest.raises(ValueError):
PermissionTree([Area("a", "A", "grid", (Resource("x", "X", (Action.EDIT,)),))])
def test_preset_expansion():
tree = _tree()
preset = RolePreset(
key="ops-read",
name="Betrieb lesen",
description="",
grants=(("devices", (Action.VIEW,)), ("switches", "*")),
)
assert tree.expand_preset(preset) == [
"devices.view",
"ops.view",
"switches.edit",
"switches.view",
]
# --------------------------------------------------------------------------- #
# Eskalationsschutz
# --------------------------------------------------------------------------- #
class _Actor:
def __init__(self, is_admin: bool, permissions: set[str]):
self.is_admin = is_admin
self.id = 42
self._permissions = permissions
def has(self, *permissions: str) -> bool:
return self.is_admin or all(p in self._permissions for p in permissions)
def test_no_amplification_blocks_unknown_permission():
tree = _tree()
actor = _Actor(False, {"ops.view", "devices.view"})
with pytest.raises(rbac.PermissionDenied) as exc:
rbac.guard_no_amplification(actor, tree, ["devices.delete"])
assert "nicht besitzen" in str(exc.value)
def test_no_amplification_allows_own_permissions():
tree = _tree()
actor = _Actor(False, {"ops.view", "devices.view"})
rbac.guard_no_amplification(actor, tree, ["devices.view", "ops.view"])
def test_admin_may_grant_anything():
tree = _tree()
rbac.guard_no_amplification(_Actor(True, set()), tree, ["devices.delete"])
def test_group_editor_cannot_escalate_via_new_group(core_app, make_user):
"""Der zentrale Fall: ein Gruppenverwalter darf sich nicht selbst hoeherstufen."""
from tesm_core.auth.service import load_user
extension = core(core_app)
row = make_user("gruppenchef", "Ein-gutes-Passwort-1", permissions=("groups.create", "groups.view"))
with core_app.app_context(), extension.database.session() as conn:
actor = load_user(conn, int(row["id"]))
assert actor is not None
with extension.database.transaction(conn):
with pytest.raises(rbac.PermissionDenied):
rbac.create_group(
conn,
name="Hintertuer",
description="",
permissions=["users.create", "administration.view"],
tree=extension.permissions,
actor=actor,
)
def test_last_admin_cannot_be_demoted(core_app, make_user):
from tesm_core.auth.service import load_user
extension = core(core_app)
admin_row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
with core_app.app_context(), extension.database.session() as conn:
actor = load_user(conn, int(admin_row["id"]))
second = conn.execute(
"INSERT INTO users (username, password_hash, is_admin, auth_source, created_at, updated_at) "
"VALUES ('zweiter','x',1,'local',datetime('now'),datetime('now'))"
)
second_id = int(second.lastrowid or 0)
with extension.database.transaction(conn):
# Solange zwei Admins existieren, ist das Herabstufen erlaubt.
rbac.update_user(
conn, second_id, actor=actor, tree=extension.permissions, is_admin=False
)
with extension.database.transaction(conn):
with pytest.raises(rbac.PermissionDenied):
rbac.guard_last_admin(conn, int(admin_row["id"]), action="Herabstufen")
def test_cannot_change_own_admin_flag(core_app, make_user):
from tesm_core.auth.service import load_user
extension = core(core_app)
row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
with core_app.app_context(), extension.database.session() as conn:
actor = load_user(conn, int(row["id"]))
with extension.database.transaction(conn):
with pytest.raises(rbac.PermissionDenied):
rbac.update_user(
conn, int(row["id"]), actor=actor, tree=extension.permissions, is_admin=False
)
def test_non_admin_cannot_touch_admin_account(core_app, make_user):
from tesm_core.auth.service import load_user
extension = core(core_app)
admin_row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
editor_row = make_user("helfer", "Ein-gutes-Passwort-1", permissions=("users.edit", "users.view"))
with core_app.app_context(), extension.database.session() as conn:
actor = load_user(conn, int(editor_row["id"]))
with extension.database.transaction(conn):
with pytest.raises(rbac.PermissionDenied):
rbac.update_user(
conn,
int(admin_row["id"]),
actor=actor,
tree=extension.permissions,
is_locked=True,
)
def test_system_group_permissions_locked(core_app, make_user):
from tesm_core.auth.service import load_user
extension = core(core_app)
row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
with core_app.app_context(), extension.database.session() as conn:
actor = load_user(conn, int(row["id"]))
default = next(g for g in rbac.list_groups(conn) if g.is_default)
with extension.database.transaction(conn):
with pytest.raises(rbac.PermissionDenied):
rbac.update_group(
conn,
default.id,
permissions=[],
tree=extension.permissions,
actor=actor,
)
# Mit ausdruecklicher Freischaltung geht es.
rbac.update_group(
conn,
default.id,
permissions=[],
tree=extension.permissions,
actor=actor,
unlock_system=True,
)
def test_presets_are_not_reset_on_restart(core_app):
"""Anders als im Vorgaenger bleiben Anpassungen an Systemgruppen erhalten."""
extension = core(core_app)
with extension.database.session() as conn:
default = next(g for g in rbac.list_groups(conn) if g.is_default)
with extension.database.transaction(conn):
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (default.id,))
created = rbac.ensure_presets(conn, extension.permissions, extension.role_presets)
assert created == []
assert rbac.group_permissions(conn, default.id) == []
# --------------------------------------------------------------------------- #
# Datenbank
# --------------------------------------------------------------------------- #
def test_second_connection_during_write_transaction_raises(tmp_path):
database = Database(tmp_path / "test.db")
with database.session() as conn:
conn.execute("CREATE TABLE t (a INTEGER)")
with database.transaction(conn):
conn.execute("INSERT INTO t VALUES (1)")
with pytest.raises(NestedConnectionError) as exc:
database.connect()
assert "Schreibtransaktion" in str(exc.value)
def test_nested_transactions_commit_once(tmp_path):
database = Database(tmp_path / "test.db")
with database.session() as conn:
conn.execute("CREATE TABLE t (a INTEGER)")
with database.transaction(conn):
conn.execute("INSERT INTO t VALUES (1)")
with database.transaction(conn):
conn.execute("INSERT INTO t VALUES (2)")
assert Database.value(conn, "SELECT COUNT(*) FROM t") == 2
def test_inner_rollback_keeps_outer(tmp_path):
database = Database(tmp_path / "test.db")
with database.session() as conn:
conn.execute("CREATE TABLE t (a INTEGER)")
with database.transaction(conn):
conn.execute("INSERT INTO t VALUES (1)")
try:
with database.transaction(conn):
conn.execute("INSERT INTO t VALUES (2)")
raise RuntimeError("abbruch")
except RuntimeError:
pass
assert Database.value(conn, "SELECT COUNT(*) FROM t") == 1
def test_foreign_keys_enforced(tmp_path):
database = Database(tmp_path / "test.db")
with database.session() as conn:
conn.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)")
conn.execute("CREATE TABLE child (p INTEGER REFERENCES parent(id))")
with pytest.raises(sqlite3.IntegrityError):
conn.execute("INSERT INTO child VALUES (99)")
def test_wal_mode_active(tmp_path):
database = Database(tmp_path / "test.db")
with database.session() as conn:
assert Database.value(conn, "PRAGMA journal_mode").lower() == "wal"
def test_split_statements_handles_comments_and_strings():
script = """
-- ein Kommentar mit ; Semikolon
CREATE TABLE t (a TEXT DEFAULT 'x;y');
INSERT INTO t VALUES ('a;b'); /* Block; Kommentar */
"""
statements = split_statements(script)
assert len(statements) == 2
assert "x;y" in statements[0]
assert "a;b" in statements[1]
# --------------------------------------------------------------------------- #
# Schluessel und Verschluesselung
# --------------------------------------------------------------------------- #
def test_keystore_roundtrip_and_aad_binding(tmp_path):
keystore = Keystore(tmp_path / "data.keys")
token = keystore.encrypt("geheim", aad="credentials.password:1")
assert keystore.decrypt(token, aad="credentials.password:1") == "geheim"
# Dasselbe Chiffrat an anderer Stelle eingespielt -> Fehler.
with pytest.raises(DecryptionError):
keystore.decrypt(token, aad="credentials.password:2")
def test_keystore_rotation_keeps_old_values_readable(tmp_path):
keystore = Keystore(tmp_path / "data.keys")
old_token = keystore.encrypt("alt", aad="x")
new_id = keystore.rotate()
assert new_id == "2"
assert keystore.decrypt(old_token, aad="x") == "alt"
assert keystore.needs_reencrypt(old_token) is True
new_token = keystore.encrypt("neu", aad="x")
assert keystore.needs_reencrypt(new_token) is False
def test_keystore_file_is_private(tmp_path):
import os
import stat
path = tmp_path / "data.keys"
Keystore(path)
if os.name == "posix":
mode = path.stat().st_mode
assert not mode & (stat.S_IRGRP | stat.S_IROTH)
def test_tampered_ciphertext_rejected(tmp_path):
keystore = Keystore(tmp_path / "data.keys")
token = keystore.encrypt("geheim", aad="x")
prefix, key_id, nonce, ciphertext = token.split(".", 3)
broken = f"{prefix}.{key_id}.{nonce}.{ciphertext[:-4]}AAAA"
with pytest.raises(DecryptionError):
keystore.decrypt(broken, aad="x")
# --------------------------------------------------------------------------- #
# Passwoerter und TOTP
# --------------------------------------------------------------------------- #
def test_argon2_hash_and_verify():
stored = passwords.hash_password("Ein-gutes-Passwort-1")
assert stored.startswith("$argon2id$")
assert passwords.verify_password(stored, "Ein-gutes-Passwort-1") is True
assert passwords.verify_password(stored, "falsch") is False
assert passwords.needs_rehash(stored) is False
def test_bcrypt_legacy_hash_accepted_and_flagged_for_rehash():
bcrypt = pytest.importorskip("bcrypt")
legacy = bcrypt.hashpw(b"Altes-Passwort-1", bcrypt.gensalt(rounds=4)).decode()
assert passwords.is_legacy_hash(legacy) is True
assert passwords.verify_password(legacy, "Altes-Passwort-1") is True
assert passwords.needs_rehash(legacy) is True
def test_password_normalisation_across_platforms():
import unicodedata
nfd = unicodedata.normalize("NFD", "Grüße-aus-München-1")
nfc = unicodedata.normalize("NFC", "Grüße-aus-München-1")
assert nfd != nfc
stored = passwords.hash_password(nfd)
assert passwords.verify_password(stored, nfc) is True
def test_password_policy_rules():
policy = passwords.PasswordPolicy(min_length=12)
assert policy.check("Ein-gutes-Passwort-1") == []
assert any("Zeichen erforderlich" in p for p in policy.check("kurz"))
assert any("Zeichenarten" in p for p in policy.check("aaaaaaaaaaaaaaa"))
assert any("Benutzernamen" in p for p in policy.check("Tester-Passwort-1", username="tester"))
assert any("haeufig" in p for p in policy.check("passwort1234"))
def test_totp_generates_and_verifies():
secret = totp.generate_secret()
code = totp.current_code(secret)
assert totp.verify_code(secret, code) is True
assert totp.verify_code(secret, "000000") is False
assert totp.verify_code(secret, code, now=0) is False
def test_totp_drift_tolerance():
secret = totp.generate_secret()
now = 1_700_000_000
previous = totp.code_at(secret, int(now // totp.PERIOD) - 1)
assert totp.verify_code(secret, previous, now=now) is True
far = totp.code_at(secret, int(now // totp.PERIOD) - 5)
assert totp.verify_code(secret, far, now=now) is False
def test_totp_uri_contains_issuer_and_secret():
secret = totp.generate_secret()
uri = totp.provisioning_uri(secret, account="anna", issuer="TESM Test")
assert uri.startswith("otpauth://totp/")
assert secret in uri
assert "issuer=TESM%20Test" in uri
# --------------------------------------------------------------------------- #
# Aenderungsprotokoll
# --------------------------------------------------------------------------- #
def test_audit_chain_detects_modification(core_app):
extension = core(core_app)
with extension.database.session() as conn:
with extension.database.transaction(conn):
for index in range(5):
audit.log(conn, actor="tester", action="test.event", target=f"ziel-{index}")
assert audit.verify_chain(conn)["ok"] is True
with extension.database.transaction(conn):
conn.execute("UPDATE audit_log SET detail='manipuliert' WHERE id=3")
result = audit.verify_chain(conn)
assert result["ok"] is False
assert result["broken_at"] == 3
assert "Hash" in result["reason"]
def test_audit_chain_detects_deletion(core_app):
extension = core(core_app)
with extension.database.session() as conn:
with extension.database.transaction(conn):
for index in range(5):
audit.log(conn, actor="tester", action="test.event", target=str(index))
with extension.database.transaction(conn):
conn.execute("DELETE FROM audit_log WHERE id=3")
result = audit.verify_chain(conn)
assert result["ok"] is False
def test_audit_archiving_moves_full_days(core_app, tmp_path):
extension = core(core_app)
old_day = (datetime.now(timezone.utc) - timedelta(days=3)).replace(microsecond=0)
with extension.database.session() as conn:
with extension.database.transaction(conn):
for index in range(40):
audit.log(
conn,
actor="tester",
action="test.old",
target=str(index),
ts=old_day + timedelta(seconds=index),
)
for index in range(5):
audit.log(conn, actor="tester", action="test.new", target=str(index))
with extension.database.transaction(conn):
written = audit.archive_old_rows(conn, tmp_path / "archiv", threshold=10, target=5)
assert written
# Uebrig bleiben ausschliesslich Zeilen des laufenden Tages (inkl. der
# Startzeile, die die App beim Migrieren selbst geschrieben hat).
today = datetime.now(timezone.utc).date().isoformat()
remaining = Database.all(conn, "SELECT DISTINCT substr(ts,1,10) AS day FROM audit_log")
assert [row["day"] for row in remaining] == [today]
lines = (tmp_path / "archiv" / written[0].name).read_text(encoding="utf-8").splitlines()
assert len(lines) == 40
assert json.loads(lines[0])["action"] == "test.old"
# --------------------------------------------------------------------------- #
# Sitzungen
# --------------------------------------------------------------------------- #
def test_session_id_rotates_on_login(client, make_user, login):
make_user("anna", "Ein-gutes-Passwort-1")
client.get("/login")
before = client.get_cookie("tesm_session")
login("anna", "Ein-gutes-Passwort-1")
after = client.get_cookie("tesm_session")
assert after is not None
if before is not None:
assert before.value != after.value
def test_session_revocation_takes_effect_immediately(client, core_app, make_user, login):
from tesm_core.security import sessions as session_store
row = make_user("anna", "Ein-gutes-Passwort-1")
login("anna", "Ein-gutes-Passwort-1")
assert client.get("/konto/").status_code == 200
extension = core(core_app)
with extension.database.session() as conn, extension.database.transaction(conn):
session_store.revoke_all_for_user(conn, int(row["id"]), reason="test")
response = client.get("/konto/")
assert response.status_code == 302
assert "/login" in response.headers["Location"]
def test_password_change_revokes_other_sessions(core_app, make_user):
from tesm_core.security import sessions as session_store
extension = core(core_app)
row = make_user("anna", "Ein-gutes-Passwort-1")
first = core_app.test_client()
second = core_app.test_client()
for client_instance in (first, second):
page = client_instance.get("/login")
body = page.get_data(as_text=True)
marker = 'name="csrf_token" value="'
start = body.index(marker) + len(marker)
token = body[start : body.index('"', start)]
client_instance.post(
"/login",
data={"username": "anna", "password": "Ein-gutes-Passwort-1", "csrf_token": token},
follow_redirects=True,
)
with extension.database.session() as conn:
assert len(session_store.active_for_user(conn, int(row["id"]))) == 2
page = first.get("/konto/sicherheit")
body = page.get_data(as_text=True)
marker = 'name="csrf_token" value="'
start = body.index(marker) + len(marker)
token = body[start : body.index('"', start)]
first.post(
"/konto/passwort",
data={
"current_password": "Ein-gutes-Passwort-1",
"new_password": "Neues-Passwort-2026",
"repeat_password": "Neues-Passwort-2026",
"csrf_token": token,
},
follow_redirects=True,
)
assert second.get("/konto/").status_code == 302
assert first.get("/konto/").status_code == 200
def test_reauth_required_for_sensitive_action(client, make_user, login, csrf_token):
make_user("anna", "Ein-gutes-Passwort-1")
login("anna", "Ein-gutes-Passwort-1")
# Frisch angemeldet gilt die Bestaetigung -- danach kuenstlich altern lassen.
from flask import session as flask_session
with client.session_transaction() as sess:
sess["_reauth_at"] = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat(
timespec="seconds"
)
token = csrf_token("/konto/sicherheit")
response = client.post("/konto/zwei-faktor/starten", data={"csrf_token": token})
assert response.status_code == 302
assert "/bestaetigen" in response.headers["Location"]
assert flask_session is not None
# --------------------------------------------------------------------------- #
# Der Rechteweg muss tatsaechlich begehbar sein
# --------------------------------------------------------------------------- #
def test_units_do_not_disable_the_privilege_path():
"""``NoNewPrivileges`` schaltet setuid ab -- und damit sudo, und damit den Helfer.
Auf beiden Testhosts stand "Helfer erreichbar", waehrend in Wahrheit jede
Systemaktion an ``sudo: the "no new privileges" flag is set`` scheiterte:
nginx uebernehmen, Netzwerk aendern, Zertifikat anfordern, Kea schreiben.
Die Haertung hatte genau den Mechanismus abgeschaltet, den sie schuetzen
sollte.
"""
from pathlib import Path
root = Path(__file__).resolve().parent.parent / "deploy" / "systemd"
units = list(root.glob("*.service"))
assert units
for unit in units:
text = unit.read_text(encoding="utf-8")
assert "NoNewPrivileges=yes" not in text, (
f"{unit.name}: verhindert sudo und damit jede Systemaktion."
)
# Gemessen: auch ein eingeschraenktes Bounding Set bricht sudo mit
# "unable to change to root gid" -- inklusive CapabilityBoundingSet=CAP_NET_RAW.
for line in text.splitlines():
if line.startswith("CapabilityBoundingSet="):
raise AssertionError(
f"{unit.name}: {line!r} nimmt sudo CAP_SETUID/CAP_SETGID."
)
# Die eigentliche Grenze bleibt bestehen.
# "strict" sperrt auch den Helfer aus /etc aus -- gemessen.
assert "ProtectSystem=full" in text, unit.name
assert "ProtectSystem=strict" not in text, (
f"{unit.name}: strict sperrt den Helfer aus /etc aus."
)
assert "PrivateTmp=yes" in text, unit.name
assert "User=root" not in text, unit.name
def test_the_helper_is_probed_not_just_looked_for():
""""Datei liegt da" ist keine Auskunft darueber, ob der Aufruf gelingt."""
import inspect
from tesm_core.sysops import SysOps
assert hasattr(SysOps, "selftest")
source = inspect.getsource(SysOps.selftest)
assert 'self.run("service-status", "nginx")' in source
assert 'self.run("nginx-test")' not in source, (
"Ein fehlgeschlagenes nginx -t heisst 'Konfiguration kaputt', nicht 'Helfer kaputt'."
)
# Die beiden Fehlerbilder, die im Betrieb wirklich vorkommen, werden benannt.
assert "no new privileges" in source
assert "a password is required" in source
def test_selftest_explains_a_blocked_sudo(monkeypatch):
from tesm_core.sysops import Result, SysOps
class Blocked(SysOps):
@property
def available(self) -> bool:
return True
def run(self, verb, *args, stdin="", timeout=None):
return Result(
False, "", 'sudo: The "no new privileges" flag is set, which prevents sudo', 1
)
result = Blocked(app_key="tesm").selftest()
assert not result.ok
assert "NoNewPrivileges" in result.message
assert "systemctl" in result.message
def test_selftest_reports_a_missing_sudoers_rule():
from tesm_core.sysops import Result, SysOps
class NeedsPassword(SysOps):
@property
def available(self) -> bool:
return True
def run(self, verb, *args, stdin="", timeout=None):
return Result(False, "", "sudo: a password is required", 1)
result = NeedsPassword(app_key="tesm").selftest()
assert not result.ok
assert "sudoers" in result.message
def test_selftest_says_so_when_the_helper_is_absent(tmp_path):
from tesm_core.sysops import SysOps
result = SysOps(app_key="tesm", helper=tmp_path / "gibt-es-nicht").selftest()
assert not result.ok
assert "fehlt" in result.message
def test_authenticate_keeps_directory_groups(core_app, monkeypatch):
"""Die Verzeichnisgruppen duerfen auf dem Weg nach oben nicht verlorengehen.
``_verify_credentials`` hat sie korrekt geliefert, ``authenticate`` hat aus
dem Ergebnis aber ein neues Objekt gebaut und das Feld dabei fallen lassen.
Sichtbar war davon nichts: die Anmeldung gelang, nur band sich keine
Freigabe mehr ein. Genau solche stillen Verluste faengt dieser Test.
"""
from tesm_core.auth import directory, service
from tesm_core.extension import core
profil = directory.DirectoryProfile(
username="mitarbeiter",
dn="CN=mitarbeiter,OU=Benutzer,DC=firma,DC=local",
group_dns=("CN=GG_Technik,OU=Gruppen,DC=firma,DC=local",),
)
monkeypatch.setattr(directory, "is_enabled", lambda conn: True)
monkeypatch.setattr(
directory, "authenticate", lambda conn, name, pw: directory.DirectoryResult("ok", profile=profil)
)
with core_app.test_request_context("/login"):
with core(core_app).database.session() as conn:
outcome = service.authenticate(conn, "mitarbeiter", "egal", ip="127.0.0.1")
assert outcome.ok, outcome.message
assert outcome.group_dns == profil.group_dns, "die Gruppen sind unterwegs verlorengegangen"
-246
View File
@@ -1,246 +0,0 @@
"""Rauchtest des Grundgeruests: Start, Anmeldung, Rechte, CSRF, Header."""
from __future__ import annotations
import re
def test_app_starts_and_health_ok(client):
response = client.get("/gesundheit")
assert response.status_code == 200
assert response.get_json()["status"] == "ok"
def test_login_page_renders_and_sets_csrf(client):
response = client.get("/login")
assert response.status_code == 200
body = response.get_data(as_text=True)
assert "Anmelden" in body
assert 'name="csrf_token"' in body
def test_security_headers_present(client):
response = client.get("/login")
csp = response.headers["Content-Security-Policy"]
assert "default-src 'self'" in csp
assert "'unsafe-inline'" not in csp.split("script-src")[1].split(";")[0]
assert re.search(r"nonce-[A-Za-z0-9_-]{10,}", csp)
assert response.headers["X-Frame-Options"] == "DENY"
assert response.headers["X-Content-Type-Options"] == "nosniff"
assert "camera=()" in response.headers["Permissions-Policy"]
def test_login_and_logout(client, make_user, login):
make_user("anna", "Ein-gutes-Passwort-1")
response = login("anna", "Ein-gutes-Passwort-1")
assert response.status_code == 200
assert "Willkommen" in response.get_data(as_text=True)
page = client.get("/konto/")
assert page.status_code == 200
assert "anna" in page.get_data(as_text=True)
def test_wrong_password_is_generic(client, make_user, login):
make_user("anna", "Ein-gutes-Passwort-1")
response = login("anna", "falsch")
body = response.get_data(as_text=True)
assert "Benutzername oder Passwort ist falsch" in body
# Kein Hinweis darauf, dass das Konto existiert.
assert "gesperrt" not in body
def test_unknown_user_same_message(client, login):
body = login("gibtsnicht", "irgendwas").get_data(as_text=True)
assert "Benutzername oder Passwort ist falsch" in body
def test_locked_account_rejected(client, make_user, login):
make_user("gesperrt", "Ein-gutes-Passwort-1", is_locked=True)
body = login("gesperrt", "Ein-gutes-Passwort-1").get_data(as_text=True)
assert "gesperrt" in body
def test_rate_limit_blocks_after_many_attempts(client, make_user, login):
make_user("anna", "Ein-gutes-Passwort-1")
messages = [login("anna", "falsch").get_data(as_text=True) for _ in range(9)]
assert any("voruebergehend gesperrt" in body for body in messages)
def test_protected_page_redirects_to_login(client):
response = client.get("/widgets")
assert response.status_code == 302
assert "/login" in response.headers["Location"]
def test_permission_required_blocks_without_grant(client, make_user, login):
make_user("anna", "Ein-gutes-Passwort-1")
login("anna", "Ein-gutes-Passwort-1")
assert client.get("/verwaltung/benutzer").status_code == 403
def test_permission_granted_allows_access(client, make_user, login):
make_user("bob", "Ein-gutes-Passwort-1", permissions=("users.view",))
login("bob", "Ein-gutes-Passwort-1")
response = client.get("/verwaltung/benutzer")
assert response.status_code == 200
assert "Benutzer" in response.get_data(as_text=True)
def test_admin_sees_everything(client, make_user, login):
make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
login("root", "Ein-gutes-Passwort-1")
for path in ("/verwaltung/benutzer", "/verwaltung/gruppen", "/papierkorb/", "/diagnose/"):
assert client.get(path).status_code == 200, path
def test_csrf_missing_token_is_rejected(client, make_user, login):
make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
login("root", "Ein-gutes-Passwort-1")
response = client.post("/verwaltung/benutzer/neu", data={"username": "boese"})
assert response.status_code == 403
assert "Sicherheitstoken" in response.get_data(as_text=True)
def test_csrf_wrong_origin_is_rejected(client, make_user, login, csrf_token):
make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
login("root", "Ein-gutes-Passwort-1")
token = csrf_token("/verwaltung/benutzer")
response = client.post(
"/verwaltung/benutzer/neu",
data={"username": "boese", "csrf_token": token},
headers={"Origin": "https://angreifer.example"},
)
assert response.status_code == 403
def test_nav_hides_pages_without_permission(client, make_user, login):
make_user("anna", "Ein-gutes-Passwort-1")
login("anna", "Ein-gutes-Passwort-1")
body = client.get("/").get_data(as_text=True)
assert "Diagnose" not in body
assert "Widgets" not in body
def test_nav_shows_pages_with_permission(client, make_user, login):
make_user("bob", "Ein-gutes-Passwort-1", permissions=("widgets.view",))
login("bob", "Ein-gutes-Passwort-1")
body = client.get("/").get_data(as_text=True)
assert "Widgets" in body
def test_default_group_created_from_preset(core_app):
from tesm_core.extension import core
from tesm_core.rbac import service as rbac
extension = core(core_app)
with extension.database.session() as conn:
groups = rbac.list_groups(conn)
names = {group.name for group in groups}
assert "Benutzer" in names
default = next(group for group in groups if group.name == "Benutzer")
assert default.is_default is True
assert "widgets.view" in default.permissions
assert "demo.view" in default.permissions # Bereichsschalter wird mitgesetzt
def test_error_page_shows_request_id(client):
response = client.get("/gibt-es-nicht")
assert response.status_code == 404
body = response.get_data(as_text=True)
assert "404" in body
assert "Vorgangsnummer" in body
# --------------------------------------------------------------------------- #
# Verzeichnisgruppen: suchen statt abtippen
# --------------------------------------------------------------------------- #
def test_group_search_is_open_to_fileshare_editors():
"""Wer Freigaben zuordnet, muss die Gruppen finden koennen.
Sonst braeuchte er zusaetzlich das Recht, den Verzeichnisdienst zu
konfigurieren -- eine Rechteausweitung fuer eine reine Lesesuche.
"""
import inspect
from tesm_core.views import hostsettings
source = inspect.getsource(hostsettings.directory_group_search)
assert "fileshare.edit" in source
assert 'mode="any"' in source or "mode='any'" in source
def test_the_picker_never_forces_typing_a_dn():
"""Ein abgetippter DN ist ein Tippfehler, der erst bei der Anmeldung auffaellt."""
from pathlib import Path
root = Path(__file__).resolve().parent.parent
picker = (
root / "packages" / "tesm-core" / "src" / "tesm_core" / "templates"
/ "tesm_core" / "_group_picker.html"
).read_text(encoding="utf-8")
assert "directory_group_search" in picker
assert "data-group-query" in picker
# Tippen bleibt trotzdem moeglich: ohne Dienstkonto gibt es keine Suche.
assert 'name="{{ dn_field }}"' in picker
for template in (
root / "packages" / "tesm-core" / "src" / "tesm_core" / "templates"
/ "tesm_core" / "settings_directory.html",
root / "apps" / "tesm" / "src" / "tesm" / "templates" / "tesm" / "fileshare_mappings.html",
):
text = template.read_text(encoding="utf-8")
assert "group_picker(" in text, template.name
def test_transport_choice_carries_its_port():
"""Umgestellter Transport mit altem Port ergibt einen Fehler ohne Aussage."""
from pathlib import Path
root = Path(__file__).resolve().parent.parent
page = (
root / "packages" / "tesm-core" / "src" / "tesm_core" / "templates"
/ "tesm_core" / "settings_directory.html"
).read_text(encoding="utf-8")
assert 'data-behavior="transport-port"' in page
assert 'data-port-target="#ldap-port"' in page
script = (
root / "packages" / "tesm-core" / "src" / "tesm_core" / "static"
/ "tesm_core" / "js" / "app.js"
).read_text(encoding="utf-8")
assert "transport-port" in script
assert 'ldaps: "636"' in script
assert 'starttls: "389"' in script
# Ein absichtlich abweichender Port darf nicht ueberschrieben werden.
assert "known.includes(port.value)" in script
def test_permission_matrix_has_no_nameless_checkbox(client, make_user, login):
"""In der Rechtematrix darf kein Kaestchen ohne ``name`` stehen.
Fuer nicht vorgesehene Aktionen stand dort ein deaktiviertes Kaestchen.
Das JS hat beim Einschalten des Bereichs *alle* Kaestchen der Zeile
freigegeben -- auch dieses. Es liess sich anhaken, uebertrug wegen des
fehlenden ``name`` aber nichts: es sah erteilt aus und war nach dem
Speichern verschwunden. Ohne Eingabefeld kann das nicht wieder passieren.
"""
import re
make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
login("root", "Ein-gutes-Passwort-1")
body = client.get("/verwaltung/gruppen").get_data(as_text=True)
if "permission-matrix" not in body:
gruppe = re.search(r'href="(/verwaltung/gruppen/\d+)"', body)
assert gruppe, "keine Gruppe zum Pruefen gefunden"
body = client.get(gruppe.group(1)).get_data(as_text=True)
assert "permission-matrix" in body, "Rechtematrix nicht gefunden"
kaestchen = re.findall(r"<input[^>]*type=\"checkbox\"[^>]*>", body)
assert kaestchen, "keine Kaestchen in der Matrix"
ohne_namen = [k for k in kaestchen if "name=" not in k]
assert not ohne_namen, f"Kaestchen ohne name: {ohne_namen[:3]}"
# Der Platzhalter ist jetzt reine Darstellung.
assert "matrix__na" in body
-703
View File
@@ -1,703 +0,0 @@
"""Tests des Lizenzservers: Ausstellung, Aktivierung, Portal, Rollen.
Deckt die Regeln ab, die der Vorgaenger schmerzhaft gelernt hat -- und die
Haertungen, die hier neu sind (Replay, Nonce, Rollen-Sperre, Portal-TTL).
"""
from __future__ import annotations
import json
from typing import Any
import pytest
import tesm_licensing as lic
@pytest.fixture()
def ls_app(instance_env: Any):
from tesm_license import create_app
return create_app(TESTING=True)
@pytest.fixture()
def ls_client(ls_app: Any):
return ls_app.test_client()
@pytest.fixture()
def ls_admin(ls_app: Any):
from tesm_core.extension import core
from tesm_core.security import passwords
extension = core(ls_app)
with extension.database.session() as conn, extension.database.transaction(conn):
conn.execute(
"INSERT INTO users (username, password_hash, is_admin, auth_source, created_at, "
"updated_at) VALUES ('root',?,1,'local',datetime('now'),datetime('now'))",
(passwords.hash_password("Ein-gutes-Passwort-1"),),
)
return {"username": "root", "password": "Ein-gutes-Passwort-1"}
def _token(client: Any, path: str = "/login") -> str:
body = client.get(path).get_data(as_text=True)
for marker in ('name="csrf_token" value="', 'data-csrf="'):
if marker in body:
start = body.index(marker) + len(marker)
return body[start : body.index('"', start)]
raise AssertionError(f"Kein CSRF-Token in {path}")
@pytest.fixture()
def ls_login(ls_client: Any, ls_admin: dict[str, str]):
def do() -> Any:
return ls_client.post(
"/login",
data={
"username": ls_admin["username"],
"password": ls_admin["password"],
"csrf_token": _token(ls_client),
},
follow_redirects=True,
)
return do
@pytest.fixture()
def bootstrapped(ls_app: Any, ls_client: Any, ls_login: Any):
"""Angemeldeter Admin mit Signaturschluessel und Vendor-Angaben."""
from tesm_core.extension import core
ls_login()
extension = core(ls_app)
with ls_app.app_context(), extension.database.session() as conn:
with extension.database.transaction(conn):
extension.settings.set(conn, "vendor_name", "WiS GmbH", actor="test")
extension.settings.set(
conn, "master_endpoint", "https://lizenz.example.invalid", actor="test"
)
return ls_client
def _create_customer(client: Any, name: str = "Beispiel AG") -> int:
token = _token(client, "/kunden/")
client.post(
"/kunden/neu",
data={
"csrf_token": token,
"name": name,
"contact_email": "it@example.invalid",
"contact_person": "Frau Muster",
},
follow_redirects=True,
)
return 1
def _create_ticket(client: Any, customer_id: int = 1, **overrides: Any) -> str:
token = _token(client, "/tickets/neu")
data = {
"csrf_token": token,
"customer_id": str(customer_id),
"type": "standard",
"valid_days": "365",
}
data.update(overrides)
response = client.post("/tickets/neu", data=data, follow_redirects=False)
assert response.status_code == 302, response.get_data(as_text=True)[:400]
return response.headers["Location"].rstrip("/").split("/")[-1]
# --------------------------------------------------------------------------- #
# Start
# --------------------------------------------------------------------------- #
def test_app_boots(ls_app):
from tesm_core.extension import core
components = {row["component"] for row in core(ls_app).migration_status()}
assert components == {"core", "tesm_license"}
def test_dashboard_requires_login(ls_client):
"""Anders als TESMs Geraeteboard ist diese Startseite intern."""
response = ls_client.get("/")
assert response.status_code == 302
assert "/login" in response.headers["Location"]
LS_PAGES = [
"/",
"/kunden/",
"/tickets/",
"/tickets/neu",
"/lizenzen/",
"/lizenzen/offline-code",
"/lizenzen/sub-lizenzserver",
"/einstellungen/system",
"/einstellungen/anbieter",
"/einstellungen/email",
"/einstellungen/netzwerk",
"/einstellungen/webserver",
"/einstellungen/verzeichnisdienst",
"/eigene-lizenz/",
"/eigene-lizenz/signaturschluessel",
"/sicherung/",
"/papierkorb/",
"/diagnose/",
"/protokolle/laufend",
"/protokolle/aenderungen",
"/verwaltung/benutzer",
"/verwaltung/gruppen",
]
@pytest.mark.parametrize("path", LS_PAGES)
def test_pages_render(bootstrapped, path):
response = bootstrapped.get(path)
assert response.status_code == 200, f"{path} -> {response.status_code}"
assert "Interner Fehler" not in response.get_data(as_text=True)
def test_portal_pages_public(ls_client):
assert ls_client.get("/portal/").status_code == 200
assert ls_client.get("/api/v1/licenses/hinweis").get_json()["service"] == "tesm-license"
# --------------------------------------------------------------------------- #
# Kunde, Ticket, Lizenz
# --------------------------------------------------------------------------- #
def test_customer_ticket_license_flow(bootstrapped, ls_app):
from tesm_core.extension import core
from tesm_license.services import issuance
client = bootstrapped
_create_customer(client)
ticket_id = _create_ticket(client, type="enterprise")
detail = client.get(f"/tickets/{ticket_id}").get_data(as_text=True)
assert "Lizenz ausstellen" in detail
response = client.post(
f"/lizenzen/aus-ticket/{ticket_id}",
data={"csrf_token": _token(client, f"/tickets/{ticket_id}")},
follow_redirects=True,
)
assert "ausgestellt" in response.get_data(as_text=True)
extension = core(ls_app)
with extension.database.session() as conn:
rows = issuance.list_licenses(conn)
assert len(rows) == 1
document = json.loads(str(rows[0]["license_document"]))
lic.verify_license(document)
assert set(document["modules"]) == set(lic.ALL_MODULES)
assert document["role"] == "customer"
assert rows[0]["client_key_encrypted"]
assert "client_key" not in json.dumps(document)
def test_only_one_open_license_per_ticket(bootstrapped, ls_app):
from tesm_core.extension import core
from tesm_license.services import issuance
client = bootstrapped
_create_customer(client)
ticket_id = _create_ticket(client)
token = _token(client, f"/tickets/{ticket_id}")
client.post(f"/lizenzen/aus-ticket/{ticket_id}", data={"csrf_token": token}, follow_redirects=True)
second = client.post(
f"/lizenzen/aus-ticket/{ticket_id}", data={"csrf_token": token}, follow_redirects=True
)
assert "bereits eine Lizenz offen" in second.get_data(as_text=True)
extension = core(ls_app)
with extension.database.session() as conn:
assert len(issuance.list_licenses(conn)) == 1
def test_trial_override_does_not_change_ticket(bootstrapped, ls_app):
from tesm_core.extension import core
from tesm_license.services import issuance
client = bootstrapped
_create_customer(client)
ticket_id = _create_ticket(client, type="standard", valid_days="730")
client.post(
f"/lizenzen/aus-ticket/{ticket_id}",
data={"csrf_token": _token(client, f"/tickets/{ticket_id}"), "as_trial": "1"},
follow_redirects=True,
)
extension = core(ls_app)
with extension.database.session() as conn:
ticket = issuance.get_ticket(conn, ticket_id)
row = issuance.list_licenses(conn)[0]
assert ticket["type"] == "standard"
assert ticket["valid_days"] == 730
assert row["type"] == "trial"
assert set(json.loads(str(row["modules"]))) == set(lic.ALL_MODULES)
def test_trial_lifetime_rejected(bootstrapped):
client = bootstrapped
_create_customer(client)
response = client.post(
"/tickets/neu",
data={
"csrf_token": _token(client, "/tickets/neu"),
"customer_id": "1",
"type": "trial",
"valid_days": "30",
"lifetime": "1",
},
follow_redirects=True,
)
assert "nicht unbefristet" in response.get_data(as_text=True)
def test_ticket_delete_blocked_with_open_license(bootstrapped):
client = bootstrapped
_create_customer(client)
ticket_id = _create_ticket(client)
token = _token(client, f"/tickets/{ticket_id}")
client.post(f"/lizenzen/aus-ticket/{ticket_id}", data={"csrf_token": token}, follow_redirects=True)
response = client.post(
f"/tickets/{ticket_id}/loeschen", data={"csrf_token": token}, follow_redirects=True
)
assert "offene Lizenz" in response.get_data(as_text=True)
def test_customer_delete_blocked_with_tickets(bootstrapped):
client = bootstrapped
_create_customer(client)
_create_ticket(client)
response = client.post(
"/kunden/1/loeschen",
data={"csrf_token": _token(client, "/kunden/1")},
follow_redirects=True,
)
assert "Ticket" in response.get_data(as_text=True)
def test_blocked_ticket_prevents_issuing(bootstrapped):
client = bootstrapped
_create_customer(client)
ticket_id = _create_ticket(client)
token = _token(client, f"/tickets/{ticket_id}")
client.post(f"/tickets/{ticket_id}/sperre", data={"csrf_token": token}, follow_redirects=True)
response = client.post(
f"/lizenzen/aus-ticket/{ticket_id}", data={"csrf_token": token}, follow_redirects=True
)
assert "gesperrt" in response.get_data(as_text=True)
# --------------------------------------------------------------------------- #
# Rollen-Sperre
# --------------------------------------------------------------------------- #
def test_license_server_role_downgraded_without_master_license(bootstrapped, ls_app):
"""Ohne eigene Master-Lizenz wird die Rolle still auf 'customer' gesetzt."""
from tesm_core.extension import core
from tesm_license.services import issuance
client = bootstrapped
_create_customer(client)
ticket_id = _create_ticket(client, role="license_server")
extension = core(ls_app)
with extension.database.session() as conn:
ticket = issuance.get_ticket(conn, ticket_id)
assert ticket["role"] == "customer"
def test_ticket_new_hides_license_server_option(bootstrapped):
body = bootstrapped.get("/tickets/neu").get_data(as_text=True)
assert "Lizenzserver-Lizenz" not in body
# --------------------------------------------------------------------------- #
# Aktivierungsprotokoll
# --------------------------------------------------------------------------- #
@pytest.fixture()
def issued_license(bootstrapped, ls_app):
from tesm_core.extension import core
from tesm_license.services import issuance
client = bootstrapped
_create_customer(client)
ticket_id = _create_ticket(client, type="enterprise")
client.post(
f"/lizenzen/aus-ticket/{ticket_id}",
data={"csrf_token": _token(client, f"/tickets/{ticket_id}")},
follow_redirects=True,
)
extension = core(ls_app)
with ls_app.app_context(), extension.database.session() as conn:
row = issuance.list_licenses(conn)[0]
bundle = issuance.delivery_bundle(
conn, str(row["license_id"]), keystore=extension.keystore
)
document, client_key = lic.split_bundle(bundle)
return {"document": document, "client_key": client_key, "row": row}
def test_activation_round_trip_over_api(ls_client, issued_license):
document = issued_license["document"]
request = lic.build_client_request(
"activate", document, issued_license["client_key"], hostname="kunde-01"
)
response = ls_client.post("/api/v1/licenses/activate", json=request)
assert response.status_code == 200, response.get_json()
payload = response.get_json()
lic.verify_master_response(
payload,
document["master_pubkey"],
expected_license_id=document["license_id"],
expected_fingerprint=request["fingerprint"],
expected_action="activate",
expected_nonce=request["nonce"],
)
assert payload["status"] == "ok"
def test_replayed_activation_is_rejected(ls_client, issued_license):
document = issued_license["document"]
request = lic.build_client_request("activate", document, issued_license["client_key"])
assert ls_client.post("/api/v1/licenses/activate", json=request).status_code == 200
replay = ls_client.post("/api/v1/licenses/activate", json=request)
assert replay.status_code == 409
assert replay.get_json()["error"] == "replay"
def test_forged_signature_rejected(ls_client, issued_license):
document = issued_license["document"]
other_private, _ = lic.generate_keypair()
request = lic.build_client_request("activate", document, other_private)
response = ls_client.post("/api/v1/licenses/activate", json=request)
assert response.status_code == 400
assert response.get_json()["error"] == "invalid_signature"
def test_activation_bound_to_fingerprint(ls_client, ls_app, issued_license):
from tesm_core.extension import core
document = issued_license["document"]
request = lic.build_client_request("activate", document, issued_license["client_key"])
ls_client.post("/api/v1/licenses/activate", json=request)
# Zweite Maschine: anderer Fingerprint, gueltige Signatur.
second = lic.build_client_request("activate", document, issued_license["client_key"])
second["fingerprint"] = "b" * 64
second["signature"] = lic.sign(
{k: v for k, v in second.items() if k != "signature"},
issued_license["client_key"],
lic.SIG_CONTEXT_REQUEST,
)
response = ls_client.post("/api/v1/licenses/activate", json=second)
assert response.status_code == 409
assert response.get_json()["error"] == "already_bound_elsewhere"
assert core(ls_app) is not None
def test_heartbeat_requires_active_license(ls_client, issued_license):
document = issued_license["document"]
request = lic.build_client_request("heartbeat", document, issued_license["client_key"])
response = ls_client.post("/api/v1/licenses/heartbeat", json=request)
assert response.status_code == 409
assert response.get_json()["error"] == "not_active"
def test_heartbeat_after_activation_delivers_update(ls_client, ls_app, issued_license):
from tesm_core.extension import core
from tesm_license.services import issuance
from tesm_license.services import master_key
document = issued_license["document"]
activate = lic.build_client_request("activate", document, issued_license["client_key"])
ls_client.post("/api/v1/licenses/activate", json=activate)
# Der Anbieter erweitert die Lizenz nachtraeglich.
extension = core(ls_app)
with ls_app.app_context(), extension.database.session() as conn:
key = master_key.ensure(extension.registry["master_key_path"])
with extension.database.transaction(conn):
issuance.resign_license(
conn,
document["license_id"],
master_private_key=key.private_key,
changes={"modules": ["dhcp"]},
)
beat = lic.build_client_request("heartbeat", document, issued_license["client_key"])
response = ls_client.post("/api/v1/licenses/heartbeat", json=beat)
assert response.status_code == 200
payload = response.get_json()
assert payload["status"] == "ok"
assert payload["license_update"]["seq"] == document["seq"] + 1
lic.verify_license(payload["license_update"])
def test_revoked_license_reported_signed(ls_client, ls_app, issued_license):
from tesm_core.extension import core
from tesm_license.services import issuance
document = issued_license["document"]
activate = lic.build_client_request("activate", document, issued_license["client_key"])
ls_client.post("/api/v1/licenses/activate", json=activate)
extension = core(ls_app)
with ls_app.app_context(), extension.database.session() as conn:
with extension.database.transaction(conn):
issuance.revoke_license(conn, document["license_id"], reason="Test")
beat = lic.build_client_request("heartbeat", document, issued_license["client_key"])
response = ls_client.post("/api/v1/licenses/heartbeat", json=beat)
assert response.status_code == 200
payload = response.get_json()
assert payload["status"] == "revoked"
# Die Antwort ist signiert -- der Client loescht erst nach eigener Pruefung.
lic.verify_master_response(
payload,
document["master_pubkey"],
expected_license_id=document["license_id"],
expected_fingerprint=beat["fingerprint"],
expected_action="heartbeat",
expected_nonce=beat["nonce"],
)
def test_offline_code_path_matches_api(bootstrapped, issued_license):
"""Der Offline-Weg laeuft durch dieselbe Verarbeitung wie die API."""
document = issued_license["document"]
request = lic.build_client_request("activate", document, issued_license["client_key"])
code = lic.encode_code(request)
response = bootstrapped.post(
"/lizenzen/offline-code",
data={"csrf_token": _token(bootstrapped, "/lizenzen/offline-code"), "code": code},
follow_redirects=True,
)
body = response.get_data(as_text=True)
assert "Antwortcode" in body
start = body.index('id="response-code">') + len('id="response-code">')
answer = body[start : body.index("</pre>", start)].strip()
decoded = lic.decode_code(answer)
lic.verify_master_response(
decoded,
document["master_pubkey"],
expected_license_id=document["license_id"],
expected_fingerprint=request["fingerprint"],
expected_action="activate",
expected_nonce=request["nonce"],
)
assert decoded["status"] == "ok"
def test_unknown_license_rejected(ls_client, issued_license):
document = dict(issued_license["document"])
document["license_id"] = "gibt-es-nicht"
request = lic.build_client_request("activate", document, issued_license["client_key"])
response = ls_client.post("/api/v1/licenses/activate", json=request)
assert response.status_code == 404
# --------------------------------------------------------------------------- #
# Self-Service und Portal
# --------------------------------------------------------------------------- #
def test_self_service_page_and_issue(bootstrapped, ls_app):
client = bootstrapped
_create_customer(client)
ticket_id = _create_ticket(client)
fresh = ls_app.test_client() # ohne Anmeldung
page = fresh.get(f"/lizenz/{ticket_id}")
assert page.status_code == 200
assert "Lizenz erstellen" in page.get_data(as_text=True)
token = _token(fresh, f"/lizenz/{ticket_id}")
created = fresh.post(
f"/lizenz/{ticket_id}/ausstellen", data={"csrf_token": token}, follow_redirects=True
)
assert "Lizenz wurde erstellt" in created.get_data(as_text=True)
def test_self_service_404_for_license_server_ticket(bootstrapped, ls_app):
from tesm_core.extension import core
from tesm_license.services import issuance
client = bootstrapped
_create_customer(client)
ticket_id = _create_ticket(client)
extension = core(ls_app)
with extension.database.session() as conn, extension.database.transaction(conn):
conn.execute(
"UPDATE license_tickets SET role='license_server' WHERE id=?", (ticket_id,)
)
fresh = ls_app.test_client()
assert fresh.get(f"/lizenz/{ticket_id}").status_code == 404
def test_portal_answer_is_identical_for_unknown_email(ls_client):
token = _token(ls_client, "/portal/")
known = ls_client.post(
"/portal/", data={"csrf_token": token, "email": "gibt@es.nicht"}, follow_redirects=True
).get_data(as_text=True)
assert "Falls diese E-Mail-Adresse bei uns hinterlegt ist" in known
def test_portal_token_is_single_use(bootstrapped, ls_app):
from tesm_core.extension import core
client = bootstrapped
_create_customer(client)
# Token direkt anlegen (Mailversand ist im Test nicht konfiguriert).
extension = core(ls_app)
with extension.database.session() as conn, extension.database.transaction(conn):
conn.execute(
"INSERT INTO portal_access_tokens (token, customer_id, created_at, expires_at) "
"VALUES ('testtoken', 1, datetime('now'), datetime('now', '+30 minutes'))"
)
fresh = ls_app.test_client()
first = fresh.get("/portal/zugang/testtoken", follow_redirects=True)
assert "Ihre Lizenzen" in first.get_data(as_text=True)
second = ls_app.test_client()
again = second.get("/portal/zugang/testtoken", follow_redirects=True)
assert "ungueltig oder abgelaufen" in again.get_data(as_text=True)
def test_portal_expired_token_rejected(bootstrapped, ls_app):
from tesm_core.extension import core
_create_customer(bootstrapped)
extension = core(ls_app)
with extension.database.session() as conn, extension.database.transaction(conn):
conn.execute(
"INSERT INTO portal_access_tokens (token, customer_id, created_at, expires_at) "
"VALUES ('alt', 1, datetime('now','-2 hours'), datetime('now','-1 hours'))"
)
fresh = ls_app.test_client()
response = fresh.get("/portal/zugang/alt", follow_redirects=True)
assert "ungueltig oder abgelaufen" in response.get_data(as_text=True)
# --------------------------------------------------------------------------- #
# Ampel
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"days,expected",
[(120, "green"), (91, "green"), (90, "orange"), (31, "orange"), (29, "red"), (-1, "red")],
)
def test_traffic_light_boundaries(days, expected):
from datetime import timedelta
from tesm_license.services.issuance import display_state
now = lic.utcnow()
row = {"expires_at": lic.iso(now + timedelta(days=days, hours=1)), "lifetime": 0}
assert display_state(row, now=now)["color"] == expected
def test_lifetime_is_always_green():
from tesm_license.services.issuance import display_state
state = display_state({"expires_at": "2000-01-01T00:00:00Z", "lifetime": 1})
assert state["color"] == "green"
assert state["is_expired"] is False
def test_unparsable_date_fails_safe():
from tesm_license.services.issuance import display_state
assert display_state({"expires_at": "kaputt", "lifetime": 0})["color"] == "red"
# --------------------------------------------------------------------------- #
# Endpunkt: ohne ihn keine Lizenz
# --------------------------------------------------------------------------- #
def test_issuing_without_an_endpoint_is_refused():
"""Der Endpunkt wird mitsigniert -- fehlt er, ist die Lizenz dauerhaft offline.
Nachtraeglich laesst er sich nicht eintragen: er steht in der signierten
Nutzlast. Eine Warnung waere zu spaet, denn der Schaden entsteht in dem
Moment, in dem die Datei den Server verlaesst.
"""
import pytest as _pytest
from tesm_license.services import issuance
assert "master_endpoint" in issuance.issue_from_ticket.__code__.co_varnames
source = __import__("inspect").getsource(issuance.issue_from_ticket)
assert 'if not str(master_endpoint or "").strip():' in source
assert "IssuanceError" in source
assert _pytest is not None
def test_offline_activation_has_a_navigation_entry():
"""Ohne Eintrag findet niemand die Seite -- der Offline-Weg waere unbenutzbar."""
from tesm_license.navigation import NAVIGATION
endpoints = set()
def walk(items):
for item in items:
if item.endpoint:
endpoints.add(item.endpoint)
if item.children:
walk(item.children)
walk(NAVIGATION.items)
assert "licenses.manual_code" in endpoints
def test_revision_pending_survives_a_partial_row():
"""Die Funktion sitzt in Listen, Detailseiten und im Portal.
Nicht jede Abfrage waehlt jede Spalte. Eine fehlende darf keine Ausnahme
werfen -- sonst faellt eine ganze Seite wegen einer Nebensaechlichkeit aus.
"""
from tesm_license.services.issuance import revision_pending
assert revision_pending({"revision_at": "2026-01-02", "status": "active"}) is True
assert revision_pending({"revision_at": None}) is False
assert revision_pending({}) is False
assert revision_pending({"revision_at": "2026-01-02", "status": "revoked"}) is False
# Abgeholt: der Kontakt ist juenger als die Revision.
assert (
revision_pending(
{
"revision_at": "2026-01-02T00:00:00+00:00",
"status": "active",
"last_heartbeat_at": "2026-01-03T00:00:00+00:00",
}
)
is False
)
# Noch nicht abgeholt.
assert (
revision_pending(
{
"revision_at": "2026-01-04T00:00:00+00:00",
"status": "active",
"last_heartbeat_at": "2026-01-03T00:00:00+00:00",
}
)
is True
)
-408
View File
@@ -1,408 +0,0 @@
"""Phase-0-Tests: das Lizenzprotokoll isoliert, ohne Flask, ohne Datenbank.
Deckt bewusst auch die Angriffsfaelle ab, die im Vorgaengerprojekt nicht
abgesichert waren: Signatur-Wiederverwendung ueber Domaenengrenzen, Replay
alter Anfragen/Antworten, Downgrade eines nachgelieferten Lizenz-Updates und
Ausstellung durch einen nicht gepinnten Aussteller.
"""
from __future__ import annotations
import copy
from datetime import timedelta
import pytest
import tesm_licensing as lic
VENDOR = {
"name": "WiS GmbH",
"phone": "+49 000 000",
"email": "lizenzen@example.invalid",
"address": "Musterweg 1, 12345 Musterstadt",
"logo_base64": "",
}
CUSTOMER = {"name": "Beispiel AG", "contact_email": "it@example.invalid", "customer_ref": "K-1"}
@pytest.fixture()
def master_keys() -> tuple[str, str]:
return lic.generate_keypair()
def issue(master_keys, **overrides):
priv, pub = master_keys
params = dict(
customer=CUSTOMER,
license_type="standard",
modules=[],
valid_days=365,
master_private_key_b64=priv,
master_public_key_b64=pub,
master_endpoint="https://lizenz.example.invalid/",
vendor=VENDOR,
)
params.update(overrides)
return lic.issue_license(**params)
# --------------------------------------------------------------------------- #
# Lizenzdatei
# --------------------------------------------------------------------------- #
def test_issued_license_verifies_and_splits(master_keys):
_, pub = master_keys
bundle, client_pub = issue(master_keys, modules=["dhcp", "maintenance"])
document, client_priv = lic.split_bundle(bundle)
lic.verify_license(document, trusted_master_keys=[pub])
assert document["license_pubkey"] == client_pub
assert lic.public_from_private(client_priv) == client_pub
# Der private Schluessel darf nicht im Lizenzdokument stehen.
assert "client_key" not in document
assert "license_privkey" not in document
status = lic.license_status(document, issuer_pinned=True)
assert status.modules == {"dhcp", "maintenance"}
assert status.modules_active is True
assert status.expired is False
assert status.days_left == 365
assert status.warnings == ()
def test_tampered_field_breaks_signature(master_keys):
bundle, _ = issue(master_keys)
document, _ = lic.split_bundle(bundle)
document["modules"] = ["dhcp", "fileshare", "maintenance"]
with pytest.raises(lic.SignatureInvalid):
lic.verify_license(document)
def test_swapped_master_key_is_detected(master_keys):
"""Ein Angreifer, der Payload *und* master_pubkey ersetzt, faellt am Pinning auf."""
bundle, _ = issue(master_keys)
document, _ = lic.split_bundle(bundle)
evil_priv, evil_pub = lic.generate_keypair()
forged = lic.resign_license(
document, master_private_key_b64=evil_priv, changes={"modules": list(lic.ALL_MODULES)}
)
forged["master_pubkey"] = evil_pub
forged["master_key_id"] = lic.key_id(evil_pub)
forged["signature"] = lic.sign(
{k: v for k, v in forged.items() if k != "signature"},
evil_priv,
lic.SIG_CONTEXT_LICENSE,
)
# In sich konsistent -- genau das war die Schwaeche von V1.
lic.verify_license(forged)
# Mit gepinntem Aussteller fliegt es auf.
_, real_pub = master_keys
with pytest.raises(lic.UntrustedIssuer):
lic.verify_license(forged, trusted_master_keys=[real_pub])
def test_key_id_mismatch_rejected(master_keys):
bundle, _ = issue(master_keys)
document, _ = lic.split_bundle(bundle)
document["master_key_id"] = "0" * 16
with pytest.raises(lic.InvalidLicenseFile):
lic.verify_license(document)
def test_license_signature_not_reusable_as_response(master_keys):
"""Domaenentrennung: Lizenz-Signatur darf nicht als Antwort-Signatur gelten."""
_, pub = master_keys
bundle, _ = issue(master_keys)
document, _ = lic.split_bundle(bundle)
payload = {k: v for k, v in document.items() if k != "signature"}
assert lic.verify(payload, document["signature"], pub, lic.SIG_CONTEXT_LICENSE) is True
assert lic.verify(payload, document["signature"], pub, lic.SIG_CONTEXT_RESPONSE) is False
assert lic.verify(payload, document["signature"], pub, lic.SIG_CONTEXT_REQUEST) is False
def test_lifetime_license(master_keys):
bundle, _ = issue(master_keys, license_type="enterprise", lifetime=True, valid_days=0)
document, _ = lic.split_bundle(bundle)
lic.verify_license(document)
status = lic.license_status(document, issuer_pinned=True)
assert status.lifetime is True
assert status.expires_at is not None
assert status.days_left is None
assert status.expired is False
assert status.modules_active is True
def test_trial_lifetime_forbidden(master_keys):
with pytest.raises(ValueError):
issue(master_keys, license_type="trial", lifetime=True, valid_days=0)
def test_license_server_role_rejects_modules(master_keys):
with pytest.raises(ValueError):
issue(master_keys, role="license_server", modules=["dhcp"])
def test_grace_period_and_expiry(master_keys):
bundle, _ = issue(master_keys, valid_days=10, modules=["dhcp"])
document, _ = lic.split_bundle(bundle)
issued = lic.parse_iso(document["issued_at"])
warn = lic.license_status(document, now=issued + timedelta(days=9), issuer_pinned=True)
assert warn.expiring_soon is True and warn.expired is False and warn.modules_active is True
grace = lic.license_status(document, now=issued + timedelta(days=25), issuer_pinned=True)
assert grace.expired is True
assert grace.grace_active is True
assert grace.modules_active is True # Kulanz laeuft noch
assert grace.days_since_expiry == 15
dead = lic.license_status(document, now=issued + timedelta(days=45), issuer_pinned=True)
assert dead.grace_active is False
assert dead.modules_active is False
def test_not_before_blocks_future_license(master_keys):
bundle, _ = issue(master_keys)
document, _ = lic.split_bundle(bundle)
issued = lic.parse_iso(document["issued_at"])
status = lic.license_status(document, now=issued - timedelta(hours=1), issuer_pinned=True)
assert status.not_yet_valid is True
assert status.modules_active is False
def test_status_without_pinning_warns(master_keys):
bundle, _ = issue(master_keys)
document, _ = lic.split_bundle(bundle)
status = lic.license_status(document)
assert any("gepinnt" in w for w in status.warnings)
def test_role_defaults_to_customer_when_missing(master_keys):
bundle, _ = issue(master_keys)
document, _ = lic.split_bundle(bundle)
document.pop("role")
assert lic.license_status(document).role == "customer"
def test_v1_bundle_compatibility(master_keys):
"""Eine alte Datei mit eingebettetem license_privkey laesst sich noch aufteilen."""
bundle, _ = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
legacy = {**document, "license_privkey": client_priv}
doc2, priv2 = lic.split_bundle(legacy)
assert priv2 == client_priv
assert "license_privkey" not in doc2
# --------------------------------------------------------------------------- #
# Aktivierungsprotokoll
# --------------------------------------------------------------------------- #
def test_activation_round_trip(master_keys):
master_priv, master_pub = master_keys
bundle, license_pub = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
request = lic.build_client_request("activate", document, client_priv, hostname="tesm-kunde")
lic.verify_client_request(request, license_pub, expected_action="activate")
response = lic.build_master_response(
"activate",
license_id=request["license_id"],
fingerprint=request["fingerprint"],
request_nonce=request["nonce"],
master_private_key=master_priv,
)
lic.verify_master_response(
response,
master_pub,
expected_license_id=document["license_id"],
expected_fingerprint=request["fingerprint"],
expected_action="activate",
expected_nonce=request["nonce"],
current_seq=document["seq"],
)
def test_offline_code_round_trip_is_identical(master_keys):
master_priv, master_pub = master_keys
bundle, license_pub = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
request = lic.build_client_request("activate", document, client_priv)
code = lic.encode_code(request)
# Ein per Mail transportierter Code darf Umbrueche/Whitespace enthalten.
wrapped = "\n".join(code[i : i + 48] for i in range(0, len(code), 48))
assert lic.decode_code(wrapped) == request
lic.verify_client_request(lic.decode_code(wrapped), license_pub)
response = lic.build_master_response(
"activate",
license_id=request["license_id"],
fingerprint=request["fingerprint"],
request_nonce=request["nonce"],
master_private_key=master_priv,
)
lic.verify_master_response(
lic.decode_code(lic.encode_code(response)),
master_pub,
expected_license_id=document["license_id"],
expected_fingerprint=request["fingerprint"],
expected_action="activate",
expected_nonce=request["nonce"],
)
def test_request_signature_requires_correct_license_key(master_keys):
bundle, _ = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
other_priv, other_pub = lic.generate_keypair()
request = lic.build_client_request("heartbeat", document, client_priv)
with pytest.raises(lic.SignatureInvalid):
lic.verify_client_request(request, other_pub)
assert other_priv # nur zur Vollstaendigkeit
def test_request_replay_is_rejected(master_keys):
bundle, license_pub = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
request = lic.build_client_request("activate", document, client_priv)
used: set[str] = set()
def seen(nonce: str) -> bool:
if nonce in used:
return True
used.add(nonce)
return False
lic.verify_client_request(request, license_pub, seen_nonce=seen)
with pytest.raises(lic.ReplayDetected):
lic.verify_client_request(request, license_pub, seen_nonce=seen)
def test_stale_request_is_rejected(master_keys):
bundle, license_pub = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
old = lic.utcnow() - timedelta(seconds=lic.REQUEST_MAX_AGE_SECONDS + 60)
request = lic.build_client_request("heartbeat", document, client_priv, now=old)
with pytest.raises(lic.ReplayDetected):
lic.verify_client_request(request, license_pub)
def test_unsigned_request_does_not_burn_nonce(master_keys):
"""Nonce-Verbrauch erst nach gueltiger Signatur -- sonst waere die Nonce-Tabelle
ein kostenloser DoS-Vektor."""
bundle, license_pub = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
request = lic.build_client_request("activate", document, client_priv)
request["signature"] = lic.sign(
{"boese": True}, lic.generate_keypair()[0], lic.SIG_CONTEXT_REQUEST
)
calls: list[str] = []
with pytest.raises(lic.SignatureInvalid):
lic.verify_client_request(request, license_pub, seen_nonce=lambda n: calls.append(n) or False)
assert calls == []
def test_response_bound_to_request_nonce(master_keys):
master_priv, master_pub = master_keys
bundle, _ = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
first = lic.build_client_request("activate", document, client_priv)
response = lic.build_master_response(
"activate",
license_id=first["license_id"],
fingerprint=first["fingerprint"],
request_nonce=first["nonce"],
master_private_key=master_priv,
)
second = lic.build_client_request("activate", document, client_priv)
with pytest.raises(lic.ReplayDetected):
lic.verify_master_response(
response,
master_pub,
expected_license_id=document["license_id"],
expected_fingerprint=second["fingerprint"],
expected_action="activate",
expected_nonce=second["nonce"],
)
def test_license_update_downgrade_rejected(master_keys):
master_priv, master_pub = master_keys
bundle, _ = issue(master_keys, modules=["dhcp"])
document, client_priv = lic.split_bundle(bundle)
updated = lic.resign_license(
document, master_private_key_b64=master_priv, changes={"modules": ["dhcp", "fileshare"]}
)
assert updated["seq"] == document["seq"] + 1
lic.verify_license(updated, trusted_master_keys=[master_pub])
request = lic.build_client_request("heartbeat", updated, client_priv)
stale = lic.build_master_response(
"heartbeat",
license_id=request["license_id"],
fingerprint=request["fingerprint"],
request_nonce=request["nonce"],
master_private_key=master_priv,
license_update=document, # alte Revision
)
with pytest.raises(lic.ReplayDetected):
lic.verify_master_response(
stale,
master_pub,
expected_license_id=updated["license_id"],
expected_fingerprint=request["fingerprint"],
expected_action="heartbeat",
expected_nonce=request["nonce"],
current_seq=updated["seq"],
)
def test_report_size_limit(master_keys):
bundle, license_pub = issue(master_keys, role="license_server")
document, client_priv = lic.split_bundle(bundle)
report = {"customers": [{"name": f"K{i}"} for i in range(lic.MAX_REPORT_ROWS + 1)]}
request = lic.build_client_request("heartbeat", document, client_priv, report=report)
with pytest.raises(lic.ProtocolViolation):
lic.verify_client_request(request, license_pub)
def test_malformed_fingerprint_and_nonce_rejected(master_keys):
bundle, license_pub = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
base = lic.build_client_request("activate", document, client_priv)
bad_fp = copy.deepcopy(base)
bad_fp["fingerprint"] = "nicht-hex"
with pytest.raises(lic.ProtocolViolation):
lic.verify_client_request(bad_fp, license_pub)
bad_nonce = copy.deepcopy(base)
bad_nonce["nonce"] = "kurz"
with pytest.raises(lic.ProtocolViolation):
lic.verify_client_request(bad_nonce, license_pub)
def test_wrong_action_for_endpoint(master_keys):
bundle, license_pub = issue(master_keys)
document, client_priv = lic.split_bundle(bundle)
request = lic.build_client_request("heartbeat", document, client_priv)
with pytest.raises(lic.ProtocolViolation):
lic.verify_client_request(request, license_pub, expected_action="activate")
def test_fingerprint_is_stable_and_hex():
first = lic.system_fingerprint()
assert first == lic.system_fingerprint()
assert len(first) == 64
int(first, 16)
-326
View File
@@ -1,326 +0,0 @@
"""Prueft das Repository selbst -- die Fehlerklassen, die erst beim Ausrollen auffallen.
Der Vorgaenger hat sich hier zweimal die Finger verbrannt: CRLF-Zeilenenden aus
einer Windows-Buildmaschine machten Shell-Skripte auf dem Ziel unbrauchbar
("bad interpreter"), und Schluesseldateien landeten versehentlich im Repo.
Beides faellt hier auf, bevor ein Paket gebaut wird.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SKIP_DIRS = {
".git",
".venv",
"__pycache__",
"instance",
".pytest_cache",
".ruff_cache",
"node_modules",
}
TEXT_SUFFIXES = {
".py", ".sh", ".html", ".css", ".js", ".json", ".toml", ".md",
".yaml", ".yml", ".cfg", ".ini", ".txt", ".service", ".conf",
}
#: Dateien ohne Endung, die trotzdem Text sind.
EXTENSIONLESS_TEXT = {"tesm-helper", "tesm", "tesm-license"}
SECRET_NAMES = {
"data.keys",
"secret.key",
"license.json",
"license_key.json",
"master_signing_key.json",
"fernet.key",
"known_hosts",
}
SECRET_SUFFIXES = {".db", ".db-wal", ".db-shm", ".pem", ".p12", ".pfx"}
def _repo_files() -> list[Path]:
files: list[Path] = []
for path in REPO_ROOT.rglob("*"):
if not path.is_file():
continue
if any(part in SKIP_DIRS for part in path.relative_to(REPO_ROOT).parts):
continue
files.append(path)
return files
def _text_files() -> list[Path]:
return [
path
for path in _repo_files()
if path.suffix in TEXT_SUFFIXES or path.name in EXTENSIONLESS_TEXT
]
def test_no_crlf_line_endings():
"""CRLF im Paket macht Shell-Skripte auf dem Linux-Ziel unbrauchbar."""
offenders = [
str(path.relative_to(REPO_ROOT))
for path in _text_files()
if b"\r\n" in path.read_bytes()
]
assert not offenders, (
"Diese Dateien haben CRLF-Zeilenenden und wuerden auf dem Zielsystem brechen:\n "
+ "\n ".join(offenders)
)
def test_no_secrets_committed():
"""Schluessel, Datenbanken und Lizenzdateien gehoeren nie ins Repo."""
offenders = [
str(path.relative_to(REPO_ROOT))
for path in _repo_files()
if path.name in SECRET_NAMES or path.suffix in SECRET_SUFFIXES
]
assert not offenders, "Geheimnisse im Repository:\n " + "\n ".join(offenders)
def test_shell_scripts_have_shebang_and_strict_mode():
scripts = [path for path in _repo_files() if path.suffix == ".sh" or path.name == "tesm-helper"]
assert scripts, "Es wurden keine Shell-Skripte gefunden -- Test prueft ins Leere."
for script in scripts:
text = script.read_text(encoding="utf-8")
assert text.startswith("#!"), f"{script.name}: Shebang fehlt"
assert "set -euo pipefail" in text, f"{script.name}: 'set -euo pipefail' fehlt"
def test_helper_validates_every_verb():
"""Jedes Verb des privilegierten Helfers muss im case-Block auftauchen.
Der Helfer ist die einzige Stelle mit Root-Rechten. Ein Verb, das die
Anwendung kennt, der Helfer aber nicht, waere entweder tot -- oder,
schlimmer, ein Hinweis auf eine ungeprueft durchgereichte Aktion.
"""
from tesm_core.sysops import HELPER_VERBS
helper = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8")
# case-Muster koennen mehrere Verben mit | buendeln.
patterns: set[str] = set()
for line in helper.splitlines():
match = re.match(r"^\s{2,4}([a-z|-]+)\)\s*$", line)
if match:
patterns.update(match.group(1).split("|"))
missing = [verb for verb in HELPER_VERBS if verb not in patterns]
assert not missing, f"Verben fehlen im Helfer: {missing}"
def test_helper_has_no_unquoted_argument_passthrough():
"""Der Helfer darf Argumente nie ungeprueft an eine Shell weiterreichen."""
helper = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8")
for forbidden in ("eval ", "bash -c", "sh -c"):
assert forbidden not in helper, f"Der Helfer enthaelt {forbidden!r}"
def test_helper_blocks_path_traversal_everywhere():
"""Jede Pfadpruefung im Helfer muss ".." abweisen.
Ohne diese Sperre laesst sich eine Positivliste ueber den Basisnamen
unterlaufen: ``/etc/nginx/sites-available/../../../root/tesm`` hat den
zulaessigen Basisnamen ``tesm``, liest aber ``/root/tesm``.
"""
helper = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8")
checks = re.findall(
r"^check_\w*(?:path|target|webroot)\w*\(\) \{(.*?)^\}", helper, re.S | re.M
)
assert checks, "Es wurden keine Pfadpruefungen gefunden -- Test prueft ins Leere."
for body in checks:
assert '*".."*' in body, "Pfadpruefung ohne ..-Sperre: " + body
@pytest.mark.skipif(shutil.which("bash") is None, reason="bash nicht verfuegbar")
def test_helper_accepts_instances_but_no_invented_keys():
"""Der Namensraum einer Instanz ist eng begrenzt.
Er landet ungefiltert in Pfaden wie ``/etc/nginx/sites-available/<key>``.
"""
script = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8")
start = script.index("check_app_key() {")
end = script.index(chr(10) + "}", start) + 2
prelude = 'APP_KEYS_ALLOWED=("tesm" "tesm-license")' + chr(10) + "fail() { exit 1; }" + chr(10)
snippet = prelude + script[start:end]
def accepted(key: str) -> bool:
command = snippet + chr(10) + 'check_app_key "' + key + '"'
return subprocess.run(["bash", "-c", command], capture_output=True).returncode == 0
for good in ("tesm", "tesm-license", "tesm-opus", "tesm-license-opus", "tesm-test2"):
assert accepted(good), good
for bad in ("", "foo", "tesm-", "tesm-../etc", "../tesm", "tesm-GROSS", "tesm-" + "x" * 20):
assert not accepted(bad), bad
def test_systemd_units_are_hardened():
units = list((REPO_ROOT / "deploy" / "systemd").glob("*.service"))
assert units
for unit in units:
text = unit.read_text(encoding="utf-8")
assert "User=root" not in text, f"{unit.name} laeuft als root"
# ProtectSystem=full, nicht strict: strict sperrt auch den privilegierten
# Helfer aus /etc aus -- gemessen, siehe test_core_security.py.
for directive in ("ProtectSystem=full", "PrivateTmp=yes"):
assert directive in text, f"{unit.name}: {directive} fehlt"
# NoNewPrivileges waere hier kontraproduktiv: es schaltet setuid ab und
# damit sudo -- der Dienst koennte den privilegierten Helfer nicht mehr
# aufrufen, und jede Systemaktion aus der Oberflaeche waere tot. Genau
# so war es, und die Diagnoseseite meldete trotzdem "Helfer erreichbar".
assert "NoNewPrivileges=yes" not in text, (
f"{unit.name}: NoNewPrivileges=yes verhindert sudo und damit den Helfer."
)
def test_sudoers_grants_only_the_helper():
for name in ("tesm", "tesm-license"):
raw = (REPO_ROOT / "deploy" / "sudoers" / name).read_text(encoding="utf-8")
# Kommentare erklaeren, was bewusst *nicht* getan wird -- sie duerfen den
# Test nicht ausloesen.
rules = [line for line in raw.splitlines() if line.strip() and not line.startswith("#")]
body = "\n".join(rules)
assert "NOPASSWD: ALL" not in body, f"{name}: zu weite sudo-Regel"
assert "/usr/local/lib/tesm/tesm-helper" in body
for line in rules:
if "NOPASSWD" in line:
assert line.strip().endswith("/usr/local/lib/tesm/tesm-helper"), (
f"{name}: NOPASSWD-Regel erlaubt mehr als den Helfer: {line}"
)
@pytest.mark.parametrize("app", ["tesm", "tesm-license"])
def test_nginx_template_serves_static_from_app_root(app: str):
"""Der alias-Pfad ist genau der Fehler, der im Vorgaenger jahrelang ueberlebte."""
text = (REPO_ROOT / "deploy" / "nginx" / f"{app}.conf").read_text(encoding="utf-8")
assert f"alias /srv/{app}/static/;" in text
assert f"alias /srv/{app}/static-core/;" in text
def test_documentation_referenced_by_the_units_exists():
"""``Documentation=`` in der Unit muss auf eine Datei zeigen, die es gibt.
Sonst laeuft ``systemctl help tesm`` ins Leere -- und schlimmer: der
Verweis suggeriert eine Anleitung, die niemand geschrieben hat.
"""
for unit in (REPO_ROOT / "deploy" / "systemd").glob("*.service"):
for line in unit.read_text(encoding="utf-8").splitlines():
if not line.startswith("Documentation=file:"):
continue
target = line.split("file:", 1)[1].strip()
# /srv/<app>/docs/X -> docs/X im Repository
relative = target.split("/docs/", 1)[1]
assert (REPO_ROOT / "docs" / relative).is_file(), (
f"{unit.name} verweist auf {target}, docs/{relative} fehlt"
)
def test_readme_links_point_to_existing_files():
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
for target in re.findall(r"\]\((docs/[^)#]+)\)", readme):
assert (REPO_ROOT / target).is_file(), f"README verweist auf {target}"
def _templates() -> list[Path]:
return [
path
for path in _repo_files()
if path.suffix == ".html" and "templates" in path.parts
]
def test_no_html_entities_inside_escaped_macro_calls():
"""Makros escapen ihren Text -- Entities darin erscheinen woertlich.
Auf der Wartungsseite stand deshalb ``&bdquo;Linux-Client&ldquo;`` als
sichtbarer Text. Der Fehler ist unauffaellig, weil dieselbe Schreibweise
im uebrigen Vorlagentext voellig richtig ist.
"""
# Genau bis zum schliessenden "}}" des Aufrufs -- ein greedy Ausdruck
# verschluckte sonst den nachfolgenden Seiteninhalt und meldete dessen
# Tabellenmarkup als Treffer.
pattern = re.compile(
r"\{\{\s*(?:alert|empty_state|pill|stat)\((?:[^{}]|\{[^{}]*\})*?\)\s*"
r"(?:\|\s*safe\s*)?\}\}",
re.S,
)
offenders: list[str] = []
for path in _templates():
for match in pattern.finditer(path.read_text(encoding="utf-8")):
call = match.group(0)
# Frueher stand hier eine Ausnahme fuer "| safe". Sie war falsch:
# das Filter wirkt auf das *Ergebnis* des Makros, escaped wurde aber
# schon beim Einsetzen des Arguments. Auf der Geraeteseite stand
# deshalb woertlich "<em>und</em>" im Text.
if re.search(r"&[a-zA-Z]{2,10};|</?[a-z]{1,10}>", call):
offenders.append(
f"{path.relative_to(REPO_ROOT)}: {' '.join(call.split())[:100]}"
)
assert not offenders, (
"HTML im escapten Makrotext -- das erscheint woertlich auf der Seite:\n "
+ "\n ".join(offenders)
)
def test_refresh_regions_never_point_at_a_whole_page():
"""``data-refresh-url`` schreibt die Antwort in ``innerHTML``.
Zeigt die URL auf eine vollstaendige Seite, landet die ganze Seite in
einem Ausschnitt -- bei jedem Durchlauf eine Ebene tiefer. Genau das ist
auf der Wartungsseite passiert: die Karte "Auftraege" holte sich
``maintenance.overview``, also sich selbst.
Erlaubt sind nur Endpunkte, deren Name sie als Ausschnitt ausweist.
"""
fragment_markers = ("fragment", "tiles", "output", "status", "partial", "ausschnitt")
pattern = re.compile(r"data-refresh-url=\"\{\{\s*url_for\('([^']+)'")
found = 0
for path in _templates():
for match in pattern.finditer(path.read_text(encoding="utf-8")):
endpoint = match.group(1)
found += 1
assert any(marker in endpoint.lower() for marker in fragment_markers), (
f"{path.relative_to(REPO_ROOT)}: data-refresh-url zeigt auf {endpoint!r} -- "
"das sieht nach einer ganzen Seite aus, nicht nach einem Ausschnitt."
)
assert found, "Keine Aktualisierungsbereiche gefunden -- Test prueft ins Leere."
def test_refresh_loop_treats_zero_as_off():
"""``Number(x || 15000)`` macht aus einer 0 klammheimlich 15000."""
script = (
REPO_ROOT
/ "packages"
/ "tesm-core"
/ "src"
/ "tesm_core"
/ "static"
/ "tesm_core"
/ "js"
/ "app.js"
).read_text(encoding="utf-8")
assert "refreshInterval || " not in script
assert "interval <= 0" in script, "Ein Intervall von 0 muss die Aktualisierung abschalten."
def test_licensing_package_is_shared_not_copied():
"""Es darf keine zweite Kopie des Lizenzprotokolls geben.
Genau diese Doppelung -- zwei byte-identisch zu haltende ``licensing.py`` --
war im Vorgaenger die gefaehrlichste Invariante.
"""
copies = [
str(path.relative_to(REPO_ROOT))
for path in _repo_files()
if path.name == "licensing.py"
]
assert not copies, f"Kopien des Lizenzmoduls gefunden: {copies}"
-521
View File
@@ -1,521 +0,0 @@
"""Die nginx-Vorlage und die Trennung mehrerer Instanzen auf einem Host.
Zwei Fehlerklassen werden hier festgenagelt:
1. Der alias-Fehler des Vorgaengers -- statische Dateien zeigten auf ein
Verzeichnis, das es nicht gab, und die Oberflaeche kam jahrelang ohne
Design. Die erzeugte Datei muss die richtigen Pfade enthalten.
2. Zwei Installationen derselben Anwendung auf einem Host. Solange alles
ueber den ``app_key`` benannt wurde, hat eine Testinstanz beim Speichern
der Webserver-Einstellungen die Site-Datei der Produktion ueberschrieben
-- und beide teilten sich dasselbe Sitzungscookie, weil Cookies keine
Ports unterscheiden.
"""
from __future__ import annotations
import re
import pytest
from tesm_core.system import webserver
from tesm_core.sysops import HELPER_VERBS, Result, SysOps
def make_config(**overrides) -> webserver.WebConfig:
base = {
"app_key": "tesm",
"upstream": "127.0.0.1:5000",
"static_root": "/srv/tesm/static",
}
return webserver.WebConfig(**{**base, **overrides})
# --------------------------------------------------------------------------- #
# Nur HTTP
# --------------------------------------------------------------------------- #
def test_http_only_has_no_tls_block():
text = webserver.render(make_config())
assert "listen 80 default_server;" in text
assert "ssl_certificate" not in text
assert "return 308" not in text
def test_static_aliases_point_into_the_installation():
text = webserver.render(make_config())
assert "alias /srv/tesm/static/;" in text
assert "alias /srv/tesm/static-core/;" in text
def test_proxy_passes_the_host_including_port():
"""``$host`` verwirft den Port -- damit scheitert die Origin-Pruefung.
Genau daran ist die Anmeldung hinter dem Reverse Proxy auf einem
abweichenden Port real gescheitert.
"""
text = webserver.render(make_config())
assert "proxy_set_header Host $http_host;" in text
assert "proxy_set_header Host $host;" not in text
assert "proxy_set_header X-Forwarded-Port $server_port;" in text
def test_acme_path_is_always_reachable():
text = webserver.render(
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
)
# Auch bei aktiver Umleitung -- sonst kann Let's Encrypt nie erneuern.
assert "/.well-known/acme-challenge/" in text
challenge = text.index("acme-challenge")
redirect = text.index("return 308")
assert challenge < redirect
# --------------------------------------------------------------------------- #
# HTTPS
# --------------------------------------------------------------------------- #
def test_https_listens_on_443_and_redirects_80():
text = webserver.render(
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
)
assert "listen 443 ssl default_server http2;" in text
assert "listen [::]:443 ssl default_server http2;" in text
assert "return 308 https://$host$request_uri;" in text
assert "ssl_protocols TLSv1.2 TLSv1.3;" in text
def test_hsts_only_when_asked():
without = webserver.render(
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
)
assert "Strict-Transport-Security" not in without
with_hsts = webserver.render(
make_config(
https_enabled=True, hsts_enabled=True, cert_path="/c.pem", key_path="/k.pem"
)
)
assert "Strict-Transport-Security" in with_hsts
# HSTS ohne TLS waere ein Selbstschuss: der Browser merkt sich, dass er die
# Seite nur noch ueber HTTPS aufrufen darf.
assert "ssl_certificate" in with_hsts
def test_hsts_is_ignored_without_https():
text = webserver.render(make_config(hsts_enabled=True))
assert "Strict-Transport-Security" not in text
def test_redirect_can_be_switched_off():
text = webserver.render(
make_config(
https_enabled=True, redirect_http=False, cert_path="/c.pem", key_path="/k.pem"
)
)
assert "return 308" not in text
# Dann muss Port 80 die Anwendung weiter bedienen.
assert text.count("proxy_pass http://127.0.0.1:5000;") >= 2
def test_deviating_ports_are_rendered_and_redirect_keeps_the_port():
text = webserver.render(
make_config(
https_enabled=True,
cert_path="/c.pem",
key_path="/k.pem",
http_port=8080,
https_port=8443,
)
)
assert "listen 8080 default_server;" in text
assert "listen 8443 ssl default_server http2;" in text
# Ohne den Port im Ziel liefe die Umleitung ins Leere.
assert "return 308 https://$host:8443$request_uri;" in text
def test_standard_port_leaves_no_suffix():
assert make_config(https_port=443).https_suffix == ""
assert make_config(https_port=8443).https_suffix == ":8443"
def test_websocket_location_only_when_configured():
assert "proxy_set_header Upgrade" not in webserver.render(make_config())
text = webserver.render(make_config(websocket_path="/ws/"))
assert "location /ws/ {" in text
assert "proxy_read_timeout 3600s;" in text
def _location_blocks(text: str) -> list[list[str]]:
"""Zerlegt die erzeugte Datei in ihre ``location``-Bloecke."""
blocks: list[list[str]] = []
current: list[str] | None = None
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("location ") and stripped.endswith("{"):
current = []
elif stripped == "}" and current is not None:
blocks.append(current)
current = None
elif current is not None and stripped and not stripped.startswith("#"):
current.append(stripped)
return blocks
@pytest.mark.parametrize(
"config",
[
make_config(websocket_path="/ws/"),
make_config(
websocket_path="/ws/", https_enabled=True, cert_path="/c.pem", key_path="/k.pem"
),
make_config(https_enabled=True, hsts_enabled=True, cert_path="/c.pem", key_path="/k.pem"),
],
)
def test_no_directive_appears_twice_in_one_block(config: webserver.WebConfig):
"""nginx weist eine doppelte Direktive im selben Block ab.
Auf POETEST real passiert: der Websocket-Block erbte ``proxy_read_timeout
120s`` aus dem gemeinsamen Teil und setzte danach ``3600s``. nginx meldete
"directive is duplicate" und liess sich nicht mehr starten -- nicht nur
fuer diese Site, sondern fuer alle.
"""
for block in _location_blocks(webserver.render(config)):
directives = [line.split()[0] for line in block if line.endswith(";")]
repeated = {
name
for name in directives
# Wiederholung ist nur bei den Direktiven zulaessig, die nginx als
# Liste versteht.
if directives.count(name) > 1 and name not in {"proxy_set_header", "add_header"}
}
assert not repeated, f"Mehrfach im selben Block: {sorted(repeated)}"
def test_websocket_block_keeps_the_long_timeout():
"""Die lange Wartezeit ist der Zweck des Blocks -- sie darf nicht verlorengehen."""
blocks = {
block[0]: block
for block in _location_blocks(webserver.render(make_config(websocket_path="/ws/")))
}
websocket = next(b for b in blocks.values() if "proxy_set_header Upgrade $http_upgrade;" in b)
assert "proxy_read_timeout 3600s;" in websocket
assert "proxy_read_timeout 120s;" not in websocket
def test_http2_uses_the_old_spelling_by_default():
"""``http2 on;`` kennt erst nginx 1.25.1.
Auf POETEST (nginx 1.24) fuehrte die neue Direktive zu "unknown directive"
-- und damit zu einem Webserver, der sich nicht mehr starten liess. Die
alte Schreibweise laeuft ueberall.
"""
text = webserver.render(
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
)
assert "listen 443 ssl default_server http2;" in text
assert "http2 on;" not in text
def test_http2_directive_on_request():
text = webserver.render(
make_config(
https_enabled=True, cert_path="/c.pem", key_path="/k.pem", http2_directive=True
)
)
assert " http2 on;" in text
assert "listen 443 ssl default_server;" in text
assert "ssl http2;" not in text
def test_stapling_only_with_a_real_certificate():
"""Ein selbstsigniertes Zertifikat hat keinen OCSP-Responder."""
self_signed = webserver.render(
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
)
assert "ssl_stapling" not in self_signed
public = webserver.render(
make_config(
https_enabled=True,
domain="tesm.example.org",
cert_path="/c.pem",
key_path="/k.pem",
)
)
assert "ssl_stapling on;" in public
# --------------------------------------------------------------------------- #
# Domain
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"value", ["tesm.example.org", "a.b.c.de", "TESM.Example.ORG", "tesm.example.org."]
)
def test_valid_domains(value: str):
assert webserver.validate_domain(value) == value.lower().rstrip(".")
@pytest.mark.parametrize(
"value", ["kein-punkt", "-start.de", "ende-.de", "raum .de", "a..b", "http://x.de"]
)
def test_invalid_domains(value: str):
with pytest.raises(ValueError):
webserver.validate_domain(value)
def test_empty_domain_becomes_catch_all():
assert make_config().server_name == "_"
assert make_config(domain="tesm.example.org").server_name == "tesm.example.org"
def test_only_the_nameless_site_claims_default_server():
"""Sonst entscheidet der Dateiname in sites-enabled, wer die Vorgabe wird.
``server_name _;`` ist bei nginx kein Sonderfall -- ohne
``default_server`` gewinnt schlicht die zuerst geladene Datei. Bei zwei
Anwendungen auf einem Host waere das reiner Zufall.
"""
nameless = webserver.render(make_config())
assert "listen 80 default_server;" in nameless
named = webserver.render(make_config(domain="lizenz.example.org"))
assert "default_server" not in named
assert "server_name lizenz.example.org;" in named
# --------------------------------------------------------------------------- #
# Selbstsigniertes Zertifikat
# --------------------------------------------------------------------------- #
class FakeSysOps:
"""Merkt sich den Aufruf, statt den Helfer wirklich zu starten."""
def __init__(self, result: Result) -> None:
self.result = result
self.calls: list[tuple[str, ...]] = []
self.available = True
self.site_key = "tesm-opus"
def issue_self_signed(self, common_name: str, *alt_names: str) -> Result:
self.calls.append((common_name, *alt_names))
return self.result
def test_self_signed_returns_both_paths():
sysops = FakeSysOps(Result(True, "/etc/tesm/certs/tesm/fullchain.pem\n/etc/tesm/certs/tesm/privkey.pem\n"))
result = webserver.create_self_signed(
sysops, common_name="poetest", alt_names=["poetest.local", "192.168.82.202"]
)
assert result["ok"]
assert result["cert_path"].endswith("fullchain.pem")
assert result["key_path"].endswith("privkey.pem")
assert sysops.calls == [("poetest", "poetest.local", "192.168.82.202")]
def test_self_signed_rejects_a_hostile_name():
"""Der Name landet in einem openssl-Argument -- er wird hier geprueft."""
sysops = FakeSysOps(Result(True, "a\nb\n"))
for hostile in ("a b", "a;rm -rf /", "$(whoami)", "../../etc", ""):
result = webserver.create_self_signed(sysops, common_name=hostile)
assert not result["ok"], hostile
assert sysops.calls == []
def test_self_signed_drops_unusable_alternative_names():
sysops = FakeSysOps(Result(True, "a\nb\n"))
webserver.create_self_signed(
sysops, common_name="poetest", alt_names=["poetest", "", "bad name", "ok.local"]
)
assert sysops.calls == [("poetest", "ok.local")]
def test_self_signed_reports_a_failing_helper():
sysops = FakeSysOps(Result(False, "", "openssl fehlt", 2))
result = webserver.create_self_signed(sysops, common_name="poetest")
assert not result["ok"]
assert "openssl fehlt" in result["detail"]
def test_self_signed_rejects_an_unexpected_answer():
sysops = FakeSysOps(Result(True, "nur eine Zeile\n"))
result = webserver.create_self_signed(sysops, common_name="poetest")
assert not result["ok"]
def test_certificate_info_survives_an_unreadable_file(tmp_path, monkeypatch):
"""Ein unlesbares Zertifikat darf die Einstellungsseite nicht abwerfen.
Auf POETEST real passiert: das Zertifikatsverzeichnis war 0750 root:root,
der Dienstbenutzer bekam beim blossen ``stat`` einen PermissionError -- und
die Seite "Webserver und TLS" antwortete mit HTTP 500.
"""
target = tmp_path / "fullchain.pem"
target.write_text("egal", encoding="utf-8")
def deny(*_args, **_kwargs):
raise PermissionError(13, "Permission denied")
monkeypatch.setattr("pathlib.Path.is_file", deny)
info = webserver.certificate_info(str(target))
assert info["present"] is True
assert "nicht lesen" in info["error"]
def test_certificate_info_reports_a_missing_file():
assert webserver.certificate_info("/gibt/es/nicht.pem") == {"present": False}
def test_certificate_info_reports_garbage_without_raising(tmp_path):
target = tmp_path / "fullchain.pem"
target.write_text("kein Zertifikat", encoding="utf-8")
info = webserver.certificate_info(str(target))
assert info["present"] is True
assert info["error"]
def test_local_names_are_usable_in_a_certificate():
names = webserver.local_names()
assert names, "Es sollte mindestens der Hostname gefunden werden."
for name in names:
assert re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", name), name
# --------------------------------------------------------------------------- #
# Instanztrennung
# --------------------------------------------------------------------------- #
def test_apply_config_is_one_call_with_rollback():
"""Getrennte Aufrufe liessen eine abgelehnte Konfiguration aktiviert liegen.
``nginx-write`` verlinkt sofort nach ``sites-enabled``. Schlug danach
``nginx -t`` fehl, blieb die kaputte Datei aktiv -- der laufende nginx
merkte nichts, aber der naechste Reload durch irgendwen legte den
Webserver lahm. Die Uebernahme muss deshalb *eine* privilegierte Operation
sein, die sich selbst zuruecknimmt.
"""
calls: list[str] = []
class Recording(SysOps):
def run(self, verb: str, *args: str, stdin: str = "", timeout: int | None = None) -> Result:
calls.append(verb)
return Result(True, "uebernommen")
result = webserver.apply_config(Recording(app_key="tesm"), "server {}")
assert result["ok"]
assert calls == ["nginx-apply"]
assert "nginx-write" not in calls
def test_apply_config_reports_a_rejection_without_claiming_success():
class Failing(SysOps):
def run(self, verb: str, *args: str, stdin: str = "", timeout: int | None = None) -> Result:
return Result(False, "", "unknown directive \"http2\"", 2)
result = webserver.apply_config(Failing(app_key="tesm"), "server {}")
assert not result["ok"]
assert "alte Stand" in result["message"]
assert "http2" in result["detail"]
def test_sysops_writes_system_files_under_the_site_key():
"""Sonst ueberschreibt eine Testinstanz die Systemdateien der Produktion."""
calls: list[tuple[str, ...]] = []
class Recording(SysOps):
def run(self, verb: str, *args: str, stdin: str = "", timeout: int | None = None) -> Result:
calls.append((verb, *args))
return Result(True)
sysops = Recording(app_key="tesm", site_key="tesm-opus")
sysops.write_nginx("x")
sysops.write_netplan("x")
sysops.write_logrotate("x")
sysops.issue_self_signed("poetest")
assert calls == [
("nginx-write", "tesm-opus"),
("netplan-write", "tesm-opus"),
("logrotate-write", "tesm-opus"),
("selfsigned-issue", "tesm-opus", "poetest"),
]
def test_upstream_follows_the_configured_bind_address(monkeypatch):
"""Die nginx-Site muss auf *diese* Instanz zeigen, nicht auf Port 5000.
Mit fest verdrahteter 5000 erzeugte eine Instanz auf Port 5100 eine
Konfiguration, die Anfragen an die *andere* Installation weiterreichte --
auf POETEST reproduziert: HTTPS stand, lieferte aber die alte Anwendung.
"""
from tesm_core.config import load_core_config
monkeypatch.setenv("TESM_BIND", "127.0.0.1:5100")
config = load_core_config(app_key="tesm", app_name="TESM", env_prefix="TESM_")
assert config.bind == "127.0.0.1:5100"
text = webserver.render(make_config(upstream=config.bind))
assert "proxy_pass http://127.0.0.1:5100;" in text
assert "127.0.0.1:5000" not in text
def test_bind_address_is_validated(monkeypatch):
from tesm_core.config import load_core_config
monkeypatch.setenv("TESM_BIND", "127.0.0.1:5100; rm -rf /")
with pytest.raises(ValueError):
load_core_config(app_key="tesm", app_name="TESM", env_prefix="TESM_")
def test_site_key_is_validated(monkeypatch):
from tesm_core.config import load_core_config
monkeypatch.setenv("TESM_SITE_KEY", "../../etc/nginx")
with pytest.raises(ValueError):
load_core_config(app_key="tesm", app_name="TESM", env_prefix="TESM_")
def test_site_key_defaults_to_the_app_key():
assert SysOps(app_key="tesm").site_key == "tesm"
def test_self_signed_verb_is_known():
assert "selfsigned-issue" in HELPER_VERBS
def test_service_state_finds_unit_without_suffix(monkeypatch):
"""``list-unit-files`` braucht ein Muster mit Endung.
Ohne sie fand die Abfrage nichts und der Zustand lautete
"installiert: nein" -- auch bei laufendem Dienst. Auf der DHCP-Seite lud
das zum Klick auf "Kea installieren" ein.
"""
import tesm_core.sysops as sysops_modul
from tesm_core.sysops import Result, SysOps
# Ohne systemctl bricht die Abfrage vorher ab -- auf dem Entwicklungsrechner
# gibt es keines.
monkeypatch.setattr(sysops_modul.shutil, "which", lambda name: "/bin/" + name)
gesehen: list[list[str]] = []
class Attrappe(SysOps):
def __init__(self):
pass
def read_command(self, argv):
gesehen.append(list(argv))
if "list-unit-files" in argv:
treffer = "kea-dhcp4-server.service" in argv
return Result(True, "kea-dhcp4-server.service enabled" if treffer else "", "")
return Result(True, "active", "")
zustand = Attrappe().service_state("kea-dhcp4-server")
assert zustand["installed"] == "ja", gesehen
# Eine bereits vollstaendige Unit bleibt unveraendert.
assert Attrappe().service_state("kea-dhcp4-server.service")["installed"] == "ja"