server läuft
This commit is contained in:
1 parent
c8df9d7651
commit
42f0c0accb
21 files changed
+4185
-10
No files matched your search
@@ -148,7 +148,33 @@ Klassische Ingress-Lösungen wie Nginx Proxy Manager (NPM) erfordern eine manuel
|
|||||||
Jeder Payload vom Agenten an den Server muss vom Server gegen die zugewiesene Policy geprüft werden:
|
Jeder Payload vom Agenten an den Server muss vom Server gegen die zugewiesene Policy geprüft werden:
|
||||||
|
|
||||||
* **Domain Spoofing Protection:** Beansprucht ein Agent ein Label `Host('kunde.de')`, prüft der Server, ob `kunde.de` im Feld `allowed_domains` der Mandanten-Lizenz hinterlegt ist. Falls nicht: Ablehnung mit Sicherheits-Audit-Log.
|
* **Domain Spoofing Protection:** Beansprucht ein Agent ein Label `Host('kunde.de')`, prüft der Server, ob `kunde.de` im Feld `allowed_domains` der Mandanten-Lizenz hinterlegt ist. Falls nicht: Ablehnung mit Sicherheits-Audit-Log.
|
||||||
* **Route Limits:** Übersteigt die Anzahl der deklarierten Routen das Limit (`max_routes`), werden überzählige Routen verworfen.
|
* **Route Limits:** Übersteigt die Anzahl der deklarierten Routen das Limit (`max_routes`), werden überzählige Routen verworfen (deterministisch: nach Router-Namen sortiert, die hinteren fallen weg).
|
||||||
|
|
||||||
|
Umsetzung in `pkg/policy`. Jeder Verstoß entfernt nur den betroffenen Router und wird als Violation (Audit-Log) gemeldet:
|
||||||
|
|
||||||
|
* **Regel-Analyse statt Textsuche:** Traefik-Regeln werden geparst (Matcher, `&&`, `||`, `!`, Klammern).
|
||||||
|
* Jeder `||`-Zweig muss durch `Host(...)` eingeschränkt sein. ``Host(`kunde.de`) || PathPrefix(`/`)`` würde sonst den Verkehr **aller** Mandanten abgreifen.
|
||||||
|
* Negierte Hosts schränken nicht ein.
|
||||||
|
* `HostRegexp` ist nicht erlaubt, weil sich ein Regex nicht gegen `allowed_domains` prüfen lässt.
|
||||||
|
* **Namensraum:** Router, Services und Middlewares heißen in der ausgelieferten Konfiguration `<tenant>__<node>__<name>`.
|
||||||
|
* Mandanten können sich damit weder überschreiben noch gegenseitig referenzieren.
|
||||||
|
* IDs: alphanumerisch mit `-`/`_`, nicht am Rand, kein `__`.
|
||||||
|
* Das Präfix `sultan__` und die Mandanten-ID `sultan` sind reserviert.
|
||||||
|
* **Fremde Provider:** Referenzen mit `@` (z. B. `api@internal` = Traefik-Dashboard) sind verboten, außer sie stehen in einer Freigabeliste des Admins (z. B. `auth@file`).
|
||||||
|
* **Middleware-Ziele begrenzen (geplant):** Middlewares mit eigenem Netzwerkziel (z. B. `forwardAuth`, Redirects auf andere Hosts) sollen auf das eigene Backend des Dienstes beschränkt werden können.
|
||||||
|
* Grenze: das Tupel `ip:port`, `[ipv6]:port` bzw. `fqdn:port`, eventuell plus höchstens ein Pfadsegment.
|
||||||
|
* Redirect/Auth auf einen Host derselben Zone (z. B. `acronym.meine.home.lab` → `acronym-service.meine.home.lab`) soll möglich bleiben, um Zertifikate zu erleichtern.
|
||||||
|
* Aktivierung **per Label**; zunächst im **Beobachtungsmodus** (nur Audit-Log, keine Sperre).
|
||||||
|
* Zunächst werden nur die mitgelieferten Redirects (to-www/to-non-www) angeboten; alles andere wird **geduldet**.
|
||||||
|
* **Umgesetzt ist die Andockstelle** `policy.Engine.MiddlewareTargetCheck(mwName, def, backend)`:
|
||||||
|
* nil (Default): alles dulden
|
||||||
|
* mit Prüffunktion und `EnforceMiddlewareTargets = false`: Verstoß nur melden (`Observed`)
|
||||||
|
* mit `true`: Router entfernen
|
||||||
|
* Regeln und Label-Name folgen später.
|
||||||
|
* **Middleware-Gates:**
|
||||||
|
* Ohne Feature `custom_middleware` sind nur Basistypen erlaubt: redirectScheme, redirectRegex, stripPrefix(Regex), addPrefix, replacePath(Regex), compress, headers.
|
||||||
|
* `chain` und `errors` sind **immer** verboten, weil sie per Namen auf andere Middlewares/Services verweisen und den Namensraum umgehen könnten.
|
||||||
|
* Nicht referenzierte Services/Middlewares werden nicht ausgeliefert.
|
||||||
|
|
||||||
### 3.4 Bedrohungsmodell: Erstkontakt (TOFU) und Server-Events
|
### 3.4 Bedrohungsmodell: Erstkontakt (TOFU) und Server-Events
|
||||||
|
|
||||||
@@ -191,6 +217,55 @@ Jeder Payload vom Agenten an den Server muss vom Server gegen die zugewiesene Po
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
* **Signatur:** Ed25519 mit dem Server-Master-Key.
|
||||||
|
* Signiert wird `SULTAN-LICENSE-V1\n` + JSON der Felder in fester Reihenfolge (`tenant_id`, `tier`, `max_routes`, `allowed_domains`, `features`, `valid_until`).
|
||||||
|
* `valid_until` geht als RFC 3339 in UTC mit ganzen Sekunden ein.
|
||||||
|
* Jede Änderung eines Felds, auch die Reihenfolge der Domains, macht die Signatur ungültig.
|
||||||
|
* Das Präfix trennt Lizenz-Signaturen von künftigen anderen Nutzungen desselben Master-Keys (z. B. signierte Server-Events, 3.4).
|
||||||
|
* **Einlesen:** Unbekannte Felder werden abgelehnt; `features` fehlt → leere Liste.
|
||||||
|
* **`allowed_domains`:** Kleinbuchstaben, ohne Punkt am Ende, IDN als Punycode (`xn--…`).
|
||||||
|
* `shop.de` deckt **genau** `shop.de`.
|
||||||
|
* `*.firma.de` deckt **jede Subdomain beliebiger Tiefe**, aber **nicht** `firma.de` selbst.
|
||||||
|
* Wildcards nur ganz vorne; `*.de` (Wildcard auf TLD) und `*` sind unzulässig.
|
||||||
|
* **Ablauf:** Der Zeitpunkt `valid_until` selbst gilt als abgelaufen. „Verbleibende Tage“ (Checkmk) = abgerundete volle Tage, nach Ablauf negativ.
|
||||||
|
* **Routen:** `max_routes = -1` bedeutet unbegrenzt.
|
||||||
|
|
||||||
|
### 4.1a Leases (Entscheidung 2026-10-01)
|
||||||
|
|
||||||
|
* Eine **Lease ist eine kurzlaufende, signierte Kopie der Lizenz** (gleiches Format, eigenes `valid_until`). Sie ist der „Ausweis“, mit dem ein Knoten Routen bekommt.
|
||||||
|
* **Kommerziell:** Laufzeit **7 Tage**, Erneuerung nach rund 2/3 (ab Tag 4–5); bei Wartung/Baustelle 10 Tage, Erneuerung ab Tag 5. Angelehnt an ACME (90 Tage, Erneuerung nach ~60). Die Lease endet spätestens mit dem bezahlten Abo-Zyklus (`valid_until` der Lizenz).
|
||||||
|
* **Community:** Laufzeit **max. 14 Tage**, Erneuerung nach **ca. 7 Tagen mit zufälliger Streuung** (damit nicht alle Agents gleichzeitig erneuern). Der Server stellt Lizenz und Lease selbständig neu aus (re-license). Es gibt kein Mahnwesen.
|
||||||
|
* Eine Wartung des Servers darf damit bis zu 7 Tage dauern: Bis die Lease abläuft, wird nichts invalidiert, keine Authentifizierung, keine Policy, keine Route.
|
||||||
|
* Ist das Backend in dieser Zeit nicht erreichbar, zeigt der Lander eine 50x-Seite („geht gerade nicht …“) mit Hinweis auf den Pro-Service.
|
||||||
|
* **Unterbrechung jederzeit serverseitig möglich:** Agent und Traefik pollen bzw. halten den Stream, also wirkt eine Unterbrechung sofort (`Hub.Disconnect`, keine Erneuerung, Policy-Stufe).
|
||||||
|
* Gründe: Security-Breach, DDoS, Domain-Verstoß.
|
||||||
|
* Nur kommerziell zusätzlich: Verstoß gegen Nutzungsrichtlinien (z. B. strafbare Inhalte), Zahlungsausfall, unterbrochenes Abo.
|
||||||
|
* **Eskalation (nur kommerziell), analog zu Mahnstufen:** Karenzzeit mit Wasserzeichen → Firemark → Paywall-Landingpage („Kauf mich!“), siehe Stufen in 5.2.
|
||||||
|
|
||||||
|
### 4.1b Agent-Konfiguration `/etc/sultan.d/*.yaml` (Entscheidung 2026-10-01)
|
||||||
|
|
||||||
|
* **Format: YAML.** Eine Datei beschreibt einen oder mehrere Dienste mit allem, was Traefik-Labels hergeben würden, plus Komfortschaltern. Beispiel (Schema noch auszuarbeiten):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
- name: kunde-a
|
||||||
|
url: http://127.0.0.1:8080 # lokaler Dienst (oder: interface + port, s. u.)
|
||||||
|
hosts: [kunde-a.de, www.kunde-a.de]
|
||||||
|
https: true
|
||||||
|
redirect_http_to_https: true
|
||||||
|
redirect_www: to_non_www # none | to_www | to_non_www
|
||||||
|
healthcheck:
|
||||||
|
interval: 30s
|
||||||
|
expect_status: 200
|
||||||
|
expect_title: "KundeA - Willkommen in KundeA's Online-Welt" # optional
|
||||||
|
expect_contains: { tag: h2, text: "Impressum" } # optional
|
||||||
|
labels: # optional: rohe traefik.*-Labels für alles Weitere
|
||||||
|
traefik.http.middlewares.x.headers.stsseconds: "31536000"
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Healthcheck → `service_down`:** Der Agent ruft den lokalen Dienst ab und erwartet `200 OK`, optional auch einfache Inhaltsprüfungen (`<title>`, Tag mit Text). Ein Fehlschlag meldet `service_down`, die Policy setzt `maintenance`.
|
||||||
|
* **Backend-Adresse/NetBird:** Interface (z. B. `wt0`) und Port werden **konfiguriert**; die IP wird über das Interface automatisch ermittelt. Ports lassen sich nicht zuverlässig erraten. Als Vorschlag dienen typische Ports: tcp/8080, tcp/80, tcp/443, tcp/3000, tcp/8000, tcp/8443.
|
||||||
|
|
||||||
### 4.2 Stream Event Protocol (Agent <-> Server)
|
### 4.2 Stream Event Protocol (Agent <-> Server)
|
||||||
|
|
||||||
Beide Richtungen nutzen dasselbe JSON-Event-Format. Server→Agent läuft über den SSE-Stream; jedes Event ist ein SSE-Rahmen `id: <event_id>`, `event: <type>`, `data: <JSON in einer Zeile>`, gefolgt von einer Leerzeile (max. 1 MiB). Agent→Server läuft über signierte HTTPS-Requests. `node_id` setzt der Server immer selbst aus der geprüften Identität.
|
Beide Richtungen nutzen dasselbe JSON-Event-Format. Server→Agent läuft über den SSE-Stream; jedes Event ist ein SSE-Rahmen `id: <event_id>`, `event: <type>`, `data: <JSON in einer Zeile>`, gefolgt von einer Leerzeile (max. 1 MiB). Agent→Server läuft über signierte HTTPS-Requests. `node_id` setzt der Server immer selbst aus der geprüften Identität.
|
||||||
@@ -215,7 +290,7 @@ Liefert ein verlängertes Krypto-Lease an den Agenten zurück.
|
|||||||
|
|
||||||
### 4.3 SQLite Datenbankschema (Server State)
|
### 4.3 SQLite Datenbankschema (Server State)
|
||||||
|
|
||||||
Der Server persistiert Mandanten, Tokens und Richtlinien lokal in SQLite (`/var/lib/sultan/sultan.db`).
|
Der Server persistiert Mandanten, Tokens und Richtlinien lokal in SQLite (`<data-dir>/sultan.db`, Default-Datenverzeichnis `./suldata`).
|
||||||
|
|
||||||
* **Treiber:** `modernc.org/sqlite` (reines Go, kein cgo → statisches Single Binary).
|
* **Treiber:** `modernc.org/sqlite` (reines Go, kein cgo → statisches Single Binary).
|
||||||
* **Pragmas:** `foreign_keys=ON` (sonst greift `ON DELETE CASCADE` nicht), WAL, `busy_timeout`, Schreib-Transaktionen `IMMEDIATE`.
|
* **Pragmas:** `foreign_keys=ON` (sonst greift `ON DELETE CASCADE` nicht), WAL, `busy_timeout`, Schreib-Transaktionen `IMMEDIATE`.
|
||||||
@@ -277,6 +352,38 @@ sultan
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Umsetzung:** `main.go` ist eine dünne Hülle (Signale → `pkg/cli.Run`); `pkg/cli` parst Befehle und Flags; `pkg/server` verdrahtet Store, Zertifikat, Master-Key, Hub, Verifier, Registry und Policy.
|
||||||
|
|
||||||
|
**Datenverzeichnis:** Default **`./suldata`** (relativ zum Arbeitsverzeichnis), für Server **und** Agent; per `--data-dir` änderbar. Es wird mit Rechten 0700 angelegt und enthält Schlüssel. Es gehört gesichert und nie ins Git (steht in `.gitignore`).
|
||||||
|
|
||||||
|
**`sultan server` – Flags und Defaults:**
|
||||||
|
|
||||||
|
| Flag | Default | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `--data-dir` | `./suldata` | `sultan.db`, `server.key`/`server.crt`, `master.key` |
|
||||||
|
| `--agent-addr` | `:8443` | Agent-Port, nur TLS 1.3 |
|
||||||
|
| `--provider-addr` | `127.0.0.1:8080` | Traefik-Provider, Klartext. Bewusst Loopback, weil die Konfiguration Backend-IPs aller Mandanten enthält. Im Container `:8080` setzen und nur auf `127.0.0.1` veröffentlichen. |
|
||||||
|
| `--interceptor-url` | `http://127.0.0.1:8081` | Sultan-Interceptor/Lander |
|
||||||
|
| `--refresh` | `1m` | Neubewertung aller Knoten (zeitabhängige Stufen, Mandantenänderungen) |
|
||||||
|
|
||||||
|
**HTTP-Routen des Servers:**
|
||||||
|
|
||||||
|
| Port | Route | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| Agent (TLS) | `POST /api/v1/enroll` | Body `{token, node_id, hostname}`, signiert mit neuem Schlüssel → 201 `{node_id, tenant_id, master_public_key}`; Token ungültig/verbraucht/abgelaufen → 403 ohne Details; ID/Schlüssel vergeben → 409 |
|
||||||
|
| Agent (TLS) | `GET /api/v1/stream` | SSE-Stream (signiert); Stream offen = Knoten `online` |
|
||||||
|
| Agent (TLS) | `POST /api/v1/state` | vollständiger Schnappschuss `{workloads: [{name, host, labels}], down_services}`; nur bei offenem Stream (sonst 409) → 204 |
|
||||||
|
| Provider | `GET /v1/traefik-config` | `?format=json\|yaml` |
|
||||||
|
| Provider | `GET /healthz` | Lebenszeichen |
|
||||||
|
|
||||||
|
**Lebenszyklus eines Knotens:**
|
||||||
|
* Enrollment legt ihn `offline` an.
|
||||||
|
* Stream verbunden → `online`.
|
||||||
|
* Stream getrennt → Routen sofort entfernt, `offline`.
|
||||||
|
* `drained` bleibt bei beidem unverändert.
|
||||||
|
|
||||||
|
Der Agent muss deshalb nach jedem (Wieder-)Verbinden seinen Zustand erneut senden.
|
||||||
|
|
||||||
### 5.1 Agenten-Subsysteme
|
### 5.1 Agenten-Subsysteme
|
||||||
|
|
||||||
1. **Watcher Engine:**
|
1. **Watcher Engine:**
|
||||||
@@ -311,6 +418,37 @@ sultan
|
|||||||
3. **Policy & Interception Engine:**
|
3. **Policy & Interception Engine:**
|
||||||
* **Sperrung bei Zahlungsverzug:** Modifiziert den Traefik-Router auf einen internen Redirect zu einer Bezahlseite (`HTTP 402 / Payment Required`).
|
* **Sperrung bei Zahlungsverzug:** Modifiziert den Traefik-Router auf einen internen Redirect zu einer Bezahlseite (`HTTP 402 / Payment Required`).
|
||||||
* **Notfall-Wartung:** Schlägt das Backend fehl oder meldet der Agent `service_down`, liefert Traefik eine statische Statusseite mit `Retry-After: 60` (`HTTP 503`).
|
* **Notfall-Wartung:** Schlägt das Backend fehl oder meldet der Agent `service_down`, liefert Traefik eine statische Statusseite mit `Retry-After: 60` (`HTTP 503`).
|
||||||
|
* **Stufen (`pkg/policy.StageFor`)**, Reihenfolge der Prüfung:
|
||||||
|
|
||||||
|
| Bedingung | Stufe | Wirkung |
|
||||||
|
|---|---|---|
|
||||||
|
| Status `quarantined` | `blocked` | keine Routen |
|
||||||
|
| Status `expired` oder Lizenz abgelaufen | `landing` | Informations-Landingpage |
|
||||||
|
| Tier `community` | `normal` | **kein Mahnwesen** |
|
||||||
|
| Status `suspended_payment` oder Mahnstufe ≥ 3 | `paywall` | Kunde ist offline: freundliche Einladung des Hosters, den Sultan-Service und weitere Angebote kennenzulernen (402), **kein Durchlass** |
|
||||||
|
| Mahnstufe 2 | `firemark` | Hinweisseite, dann Weiterleitung zum Original (längere Wartezeit) |
|
||||||
|
| Mahnstufe 1 | `watermark` | Hinweisseite, dann Weiterleitung zum Original (kurze Wartezeit) |
|
||||||
|
| Healthcheck meldet Dienst down | `maintenance` (je Router) | 503, `Retry-After: 60` |
|
||||||
|
|
||||||
|
* **Sultan-Interceptor / Web-Lander** (eigener Dienst, nur Loopback, z. B. `127.0.0.1:8081`): Bei jeder Stufe außer `normal` leitet die Policy den Router auf den Service `sultan__interceptor` um.
|
||||||
|
* Regel, TLS und die Middlewares des Mandanten bleiben erhalten.
|
||||||
|
* Ein `headers`-Middleware setzt `X-Sultan-Stage`, `X-Sultan-Tenant` und `X-Sultan-Tier`. Traefik überschreibt damit vom Besucher gesendete Werte.
|
||||||
|
* Der Lander liefert nur eine eigene HTML-Seite je Stufe. Er verändert keine Originalseiten und braucht deshalb kein Upstream.
|
||||||
|
* **Mahnhinweis mit Durchlass (watermark/firemark):**
|
||||||
|
1. Der Lander zeigt den Hinweis und leitet per `<meta http-equiv="refresh" content="<x>;url=<ursprüngliche URL>">` zurück. Wartezeit konfigurierbar, Default **5 s (Stufe 1)** und **10 s (Stufe 2)**.
|
||||||
|
2. Dabei setzt er das Cookie `sultan_ack=<stufe>` auf der Mandanten-Domain, gültig **60 Minuten** (durchschnittlicher Besuch). Der Hinweis darf also mehrmals am Tag erscheinen.
|
||||||
|
3. Die Policy erzeugt dafür einen zweiten Router `sultan__<tenant>__<node>__<router>` mit der Regel ``(<Originalregel>) && HeaderRegexp(`Cookie`, `(^|;\s*)sultan_ack=<stufe>(;|$)`)``. Er führt direkt zum Original, sonst würde die Weiterleitung wieder beim Lander landen (Schleife).
|
||||||
|
4. Priorität: Ohne gesetzte Priorität gewinnt der Durchlass über die längere Regel, sonst über Priorität+1.
|
||||||
|
5. Eine bestätigte niedrigere Stufe gilt nicht für eine höhere.
|
||||||
|
6. Das Cookie ist bewusst kein Sicherheitsmerkmal: Wer es selbst setzt, überspringt nur den Hinweis. `paywall`, `landing` und `maintenance` haben keinen Durchlass.
|
||||||
|
* **Eskalation in der Praxis:** Bei Stufe 1 und 2 alarmiert Checkmk. Zwei Alarme genügen; spätestens mit Stufe 3 ist der Kunde offline.
|
||||||
|
* **Lander-Inhalte:**
|
||||||
|
* **Eingebaut:** je Stufe eine minimale statische HTML-Seite, ohne CSS. Kurzer, nicht technischer Text in **de_DE und en_US**, was gerade funktioniert und was nicht.
|
||||||
|
* **Eigene Vorlagen (optional):** Liegen in einem vereinbarten, per Docker gemounteten Verzeichnis Vorlagen, werden diese gerendert und ausgeliefert.
|
||||||
|
* Format: Jinja2-artig; in Go umgesetzt über eine kompatible Template-Engine oder `html/template`, noch festzulegen.
|
||||||
|
* Pro Lander-Seite höchstens **1× HTML-Vorlage, 1× CSS, 1× Grafik (WebP oder SVG)**.
|
||||||
|
* Bewusst flach: kein allgemeiner Webserver.
|
||||||
|
* **Mahnstufen-Zeitplan (`policy.DunningSchedule`):** Der Server berechnet die Stufe aus dem Fälligkeitsdatum der offenen Zahlung, angelehnt an Bank-Zahlungsfristen: **Stufe 1 ab 8 Tagen, Stufe 2 ab 25, Stufe 3 ab 32 Tagen** Verzug. Die Werte sind konfigurierbar.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -376,12 +514,13 @@ services:
|
|||||||
image: sultan:latest
|
image: sultan:latest
|
||||||
container_name: sultan-server
|
container_name: sultan-server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["sultan", "server", "--data-dir=/var/lib/sultan"]
|
# Im Container auf allen Interfaces lauschen; nach außen nur 127.0.0.1 veröffentlichen.
|
||||||
|
command: ["sultan", "server", "--data-dir=/suldata", "--provider-addr=:8080"]
|
||||||
ports:
|
ports:
|
||||||
- "8443:8443" # Agent Inbound (TLS Stream)
|
- "8443:8443" # Agent Inbound (TLS Stream)
|
||||||
- "127.0.0.1:8080:8080" # Traefik Config Provider & Checkmk Metrics
|
- "127.0.0.1:8080:8080" # Traefik Config Provider & Checkmk Metrics – Klartext, nie öffentlich
|
||||||
volumes:
|
volumes:
|
||||||
- ./sultan-data:/var/lib/sultan # sultan.db, server.key (0600), server.crt – server.key sichern!
|
- ./suldata:/suldata # sultan.db, server.key/.crt, master.key – server.key und master.key sichern!
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -400,7 +539,7 @@ services:
|
|||||||
- "--token=sltn_enroll_<secret>.<server-fingerprint>" # Ausgabe von `sultan token`
|
- "--token=sltn_enroll_<secret>.<server-fingerprint>" # Ausgabe von `sultan token`
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- ./agent-data:/var/lib/sultan # node.key (0600) und known_servers – persistent halten!
|
- ./suldata:/suldata # node.key (0600) und known_servers – persistent halten! (Agent mit --data-dir=/suldata)
|
||||||
|
|
||||||
kunde-wordpress:
|
kunde-wordpress:
|
||||||
image: wordpress:latest
|
image: wordpress:latest
|
||||||
@@ -434,4 +573,73 @@ services:
|
|||||||
| 2026-10-01 | Authentifizierung sperrt nur `quarantined`; übrige Sperren über Routing-Policy. | Agent braucht den Kanal weiterhin (Lease, Reaktivierung). |
|
| 2026-10-01 | Authentifizierung sperrt nur `quarantined`; übrige Sperren über Routing-Policy. | Agent braucht den Kanal weiterhin (Lease, Reaktivierung). |
|
||||||
| 2026-10-01 | **Kein Klartext für Agent-Verkehr**: TLS 1.3 only, Signer nur für `https`, Verifier nur mit TLS. | Signaturen und Labels dürfen nie unverschlüsselt übertragen werden. |
|
| 2026-10-01 | **Kein Klartext für Agent-Verkehr**: TLS 1.3 only, Signer nur für `https`, Verifier nur mit TLS. | Signaturen und Labels dürfen nie unverschlüsselt übertragen werden. |
|
||||||
| 2026-10-01 | **Token-Format `sltn_enroll_<secret>.<fingerprint>`**, Pinning mit geprüftem Erstkontakt statt blindem TOFU. | Schließt die Man-in-the-Middle-Lücke beim Erstkontakt (3.4). Entscheidet zugleich den früheren Widerspruch `sltn_token_` vs. `sltn_enroll_`. |
|
| 2026-10-01 | **Token-Format `sltn_enroll_<secret>.<fingerprint>`**, Pinning mit geprüftem Erstkontakt statt blindem TOFU. | Schließt die Man-in-the-Middle-Lücke beim Erstkontakt (3.4). Entscheidet zugleich den früheren Widerspruch `sltn_token_` vs. `sltn_enroll_`. |
|
||||||
|
| 2026-10-01 | Lizenz-Signatur mit Versionspräfix `SULTAN-LICENSE-V1`; Wildcard `*.domain` deckt alle Subdomain-Ebenen, nicht die Apex-Domain; `*.<tld>` verboten. | Eindeutige, manipulationssichere Kodierung; Wildcard-Semantik entspricht der Erwartung „Mandant besitzt die Zone“, ohne versehentlich ganze TLDs freizugeben. |
|
||||||
|
| 2026-10-01 | **Leases**: signierte Lizenz-Kopie, 7 Tage (Wartung 10), Erneuerung nach ~2/3; kommerziell an Abo-Zyklus gebunden; Community automatisch neu ausgestellt; jederzeit serverseitig unterbrechbar. | Kurze Laufzeit begrenzt Schaden bei Missbrauch; Sperren wirken über Polling ohnehin sofort (ACME-Analogie). |
|
||||||
|
| 2026-10-01 | **Eskalatives Mahnwesen nur kommerziell**: Wasserzeichen → Firemark → Paywall (402); umgesetzt über einen Sultan-Interceptor, zu dem die Policy umleitet. | Mehrstufige Dringlichkeit analog zu Mahnstufen; Traefik selbst kann Seiteninhalte nicht verändern. |
|
||||||
|
| 2026-10-01 | **Policy parst Regeln** und verlangt Host-Einschränkung in jedem `\|\|`-Zweig; `HostRegexp` verboten; Namensraum `<tenant>__<node>__<name>`; `@`-Referenzen nur per Freigabeliste; `chain`/`errors` verboten. | Verhindert Catch-all-Übernahmen, Zugriff auf `api@internal` und Querverweise zwischen Mandanten. |
|
||||||
|
| 2026-10-01 | `/etc/sultan.d` als **YAML** mit Komfortschaltern + rohen Labels; Healthcheck (Status + einfache Inhaltsprüfung) im YAML; Interface/Port konfiguriert. | Für Bare-Metal-Admins lesbarer als Labels; Ports sind nicht sicher erratbar. |
|
||||||
|
| 2026-10-01 | **Benachrichtigung:** Community per Webhook; kommerziell SMTP an Mandanten + Ticketsystem (Zammad per Webhook/API). | Homelab braucht keinen Mailserver; kommerziell nachvollziehbare Kommunikation. |
|
||||||
|
| 2026-10-01 | **Admin-Zugriff:** Community nur lokale CLI; kommerziell zusätzlich Admin-API (Rechnungssystem, Ansible). | Automatisierte Lizenzpflege im kommerziellen Betrieb. |
|
||||||
|
| 2026-10-01 | **Community-Lease 14 Tage**, Erneuerung ~7 Tage mit Streuung; Wartung bis 7 Tage ohne Invalidierung, solange 50x-Lander mit Pro-Hinweis. | Homelab-Betrieb robust gegen Server-Wartung; Upsell statt Ausfall. |
|
||||||
|
| 2026-10-01 | **Mahnstufen 8/25/32 Tage** (Bank-Zahlungsfristen), vom Server berechnet. **Watermark/Firemark als Hinweisseite mit `meta refresh`** statt Einblendung ins Original; Durchlass per Cookie `sultan_ack=<stufe>` über einen zweiten Router. | Deutlich einfacher als Seiten umzuschreiben; Cookie verhindert die Weiterleitungsschleife. |
|
||||||
|
| 2026-10-01 | **Middleware-Zielbegrenzung** vorgesehen (Backend-Tupel `host:port`, per Label, erst Beobachtungsmodus). | SSRF-Risiko durch `forwardAuth` u. ä.; Regeln erst nach Beobachtung festlegen. |
|
||||||
|
| 2026-10-01 | **Admin-API mit API-Token**; TLS des Admin-Interfaces über ACME (öffentliches Zertifikat via Traefik), kein Pinning nötig. | Gängige Praxis für Rechnungssystem/Ansible; ACME ist auf dem Ingress ohnehin vorhanden. |
|
||||||
|
| 2026-10-01 | Mahnhinweis-Weiterleitung **5 s / 10 s**, Cookie **60 min**; Stufe 3 ohne Durchlass = Kunde offline mit Hoster-Einladung. | Hinweis pro Besuch statt pro Request; Eskalation endet mit Abschaltung nach zwei Checkmk-Alarmen. |
|
||||||
|
| 2026-10-01 | **Lander:** eingebaute minimale HTML-Seiten (de_DE/en_US, ohne CSS); optional eigene Vorlagen aus gemountetem Verzeichnis, max. 1 HTML-Vorlage + 1 CSS + 1 Grafik pro Seite. | Funktioniert ohne Konfiguration; Anpassung möglich ohne komplexen Webserver. |
|
||||||
|
| 2026-10-01 | Middleware-Zielbegrenzung als **Andockstelle mit Beobachtungs-/Durchsetzungsmodus**, Default dulden. | Erst beobachten, dann Regeln festlegen. |
|
||||||
|
| 2026-10-01 | **Registry**: `service_state` = vollständiger Schnappschuss je Knoten (Workloads mit Host + Labels, Liste ausgefallener Services); Workloads einzeln übersetzt, bei Namenskonflikt innerhalb eines Knotens gewinnt der alphabetisch erste; fail-static bei Directory-Ausfall; Verstöße nur beim ersten Auftreten melden. | Robust gegen Teilausfälle; Audit-Log ohne Wiederholungsflut. |
|
||||||
|
| 2026-10-01 | **Lander-Vorlagen mit Go `html/template`**; die eingebauten Seiten lassen sich als Beispiel exportieren (`dump-landing`). | Eingebaut, ohne Abhängigkeit, schützt automatisch gegen eingeschleustes HTML; Syntax erlernbar. |
|
||||||
|
| 2026-10-01 | **Datenverzeichnis `./suldata`** für Server und Agent; Ports `:8443`/`8080` als konfigurierbare Defaults, Provider-Port auf Loopback. | Einheitlich, einfach zu mounten; Klartext-Port nicht versehentlich öffentlich. |
|
||||||
|
| 2026-10-01 | **`sultan server`**: Knoten-Lebendigkeit = offener Stream; Routen beim Trennen sofort entfernt; `POST /api/v1/state` nur bei offenem Stream. | Keine verwaisten Routen; einfaches, eindeutiges Modell. |
|
||||||
| geplant | Signierte Server-Events; geordneter Zertifikatswechsel; optionale Root-CA. | Zweite Sicherheitsschicht neben dem TLS-Pin; Betrieb ohne Aussperren. |
|
| geplant | Signierte Server-Events; geordneter Zertifikatswechsel; optionale Root-CA. | Zweite Sicherheitsschicht neben dem TLS-Pin; Betrieb ohne Aussperren. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Umsetzungsstand & Roadmap
|
||||||
|
|
||||||
|
**Umgesetzt (Go-Packages, testgetrieben):**
|
||||||
|
|
||||||
|
| Package | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `pkg/traefik` | Labels → Traefik-Konfiguration, Merge, JSON/YAML |
|
||||||
|
| `pkg/stream` | SSE-Hub (Server) und Client (Agent) inkl. Keepalive, Backoff, Disconnect |
|
||||||
|
| `pkg/store` | SQLite: Mandanten, Knoten, Enrollment |
|
||||||
|
| `pkg/auth` | Ed25519-Request-Signatur, Verifier, Schlüsseldateien |
|
||||||
|
| `pkg/crypto` | Server-Zertifikat, TLS 1.3, Pinning, Enrollment-Token |
|
||||||
|
| `pkg/license` | Lizenz ausstellen, prüfen, Domain/Routen/Feature-Checks |
|
||||||
|
| `pkg/policy` | Regel-Parser, Domain-/Routen-/Feature-/Referenz-Prüfung, Namensraum, Stufen & Interceptor-Umleitung, Mahnstufen-Zeitplan, Andockstelle Middleware-Ziele |
|
||||||
|
| `pkg/registry` | Aggregator: Zustand je Knoten → Labels → Policy → Merge; `/v1/traefik-config`-Handler; Audit-Callback |
|
||||||
|
| `pkg/server` | `sultan server`: Datenverzeichnis, Master-Key, HTTP-Routen beider Ports, Knotenstatus, Refresh-Schleife, Graceful Shutdown, Audit-Log über `slog` |
|
||||||
|
| `pkg/cli`, `main.go` | Befehle `server`, `version`, `help` (`agent`/`token` als Platzhalter) |
|
||||||
|
|
||||||
|
**Offen – Bausteine:**
|
||||||
|
|
||||||
|
1. **Sultan-Interceptor / Web-Lander** (5.2): eigener Dienst auf Loopback; je `X-Sultan-Stage` eine HTML-Seite (eingebaut de_DE/en_US oder eigene `html/template`-Vorlage aus dem Datenverzeichnis; Befehl **`dump-landing`** exportiert die eingebauten Seiten als Vorlage zum Anpassen): Mahnhinweis mit `meta refresh` (5/10 s) + Cookie `sultan_ack` (60 min), Paywall/Hoster-Einladung (402), Landing, Wartung (503, `Retry-After: 60`; Community mit Pro-Hinweis). Audit-Log für Policy-Violations (Callback der Registry).
|
||||||
|
2. **Server-Ergänzungen:** `GET /metrics/checkmk`; Rate-Limit für `/enroll`; Gnadenfrist vor dem Entfernen von Routen beim Trennen (derzeit sofort); Konfiguration auch per Umgebungsvariablen.
|
||||||
|
3. *(entfällt – umgesetzt in `pkg/server`)*
|
||||||
|
4. **Leases:**
|
||||||
|
* Lease bei Enrollment ausstellen und per `lease_renew` erneuern (Master-Key und Übergabe des Public Keys beim Enrollment sind umgesetzt).
|
||||||
|
* Persistenz → Migration v2: `features` und offene Zahlung (`payment_overdue_since`, daraus die Mahnstufe per `DunningSchedule`) fehlen im Schema; Leases (aktuelle Lease je Knoten).
|
||||||
|
* Lease-Laufzeiten (kommerziell 7/10 Tage, Community 14 Tage mit Erneuerung ~7 Tage + Streuung), Bindung an Abo-Zyklus (4.1a).
|
||||||
|
5. **Agent:**
|
||||||
|
* Docker-Watcher (Events API über `/var/run/docker.sock`)
|
||||||
|
* File-Provider `/etc/sultan.d/*.yaml` per inotify (Schema 4.1b ausarbeiten, Übersetzung in Labels)
|
||||||
|
* Healthchecks (Status, `<title>`, Tag mit Text) → `service_down`
|
||||||
|
* State-Serializer: Ziel-IP LAN oder NetBird `100.64.x.y`
|
||||||
|
* Senden von `service_state`; Behandlung von `node_command`
|
||||||
|
6. **Lizenzfristen & Benachrichtigung:** periodischer Job (Status → `expired` an Tag 0, Mahnstufen), Checkmk-Ausgabe (6.1); Community: Webhook; kommerziell: SMTP an Mandanten + Zammad (Webhook/API).
|
||||||
|
7. **Admin-API (nur kommerziell):** Mandanten, Lizenzen, Zahlungsstatus für Rechnungssystem und Ansible; Authentifizierung per API-Token, TLS über ACME.
|
||||||
|
8. **CLI:**
|
||||||
|
* `sultan agent` (Default `--data-dir=./suldata`)
|
||||||
|
* `sultan token`: Mandant anlegen, Token erzeugen, Lizenz ausstellen, Knoten auflisten/drainen/löschen. Arbeitet direkt auf der SQLite-DB des Servers (WAL erlaubt parallelen Zugriff).
|
||||||
|
* `sultan dump-landing`: eingebaute Lander-Seiten als Vorlage exportieren.
|
||||||
|
9. **Betrieb:**
|
||||||
|
* Strukturiertes Logging (`log/slog`), Graceful Shutdown, Versionsinfo
|
||||||
|
* Dockerfile (statisch, distroless), systemd-Unit
|
||||||
|
* CI (`go vet`, `go test -race`, Linter), Releases
|
||||||
|
10. **Sicherheit (geplant, 3.4):** signierte Server-Events, geordneter Zertifikatswechsel, optionale Root-CA.
|
||||||
|
|
||||||
|
**Offene Fachfragen (vor Umsetzung klären):**
|
||||||
|
|
||||||
|
* Lander-Vorlagen: genauer Unterordner im Datenverzeichnis (z. B. `./suldata/landing/`) und Namensschema der Dateien.
|
||||||
|
* Middleware-Zielbegrenzung: genaue Grenze (Pfadsegment? Zonen-Redirects?), Label-Name, Wechsel vom Beobachtungs- in den Sperrmodus.
|
||||||
@@ -25,3 +25,8 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
|
||||||
|
# sultan: Datenverzeichnis (enthält server.key, master.key, node.key!) und Binary
|
||||||
|
/suldata/
|
||||||
|
suldata/
|
||||||
|
/sultan
|
||||||
@@ -9,17 +9,26 @@ Maßgeblich ist aktuell [.ai/sultan-system-architecture.md](.ai/sultan-system-ar
|
|||||||
|
|
||||||
## Projektstatus
|
## Projektstatus
|
||||||
|
|
||||||
Frühe Phase: Go-Modul `sultan` (Go 1.25), noch kein `main`/Subcommand. Packages:
|
Go-Modul `sultan` (Go 1.25). `main.go` ist nur eine Hülle um `pkg/cli`; umgesetzt sind `sultan server|version|help`, `agent`/`token` sind Platzhalter. Packages:
|
||||||
- [pkg/traefik](pkg/traefik) – übersetzt flache `traefik.*`-Labels (`FromLabels`) in Traefiks dynamische Konfiguration, führt Knoten-Configs zusammen (`Merge`) und rendert JSON/YAML (`Marshal`) für `/v1/traefik-config`. Traefik *pollt* diesen Endpunkt über seinen HTTP-Provider; Sultan schreibt nie in Traefik und emuliert keinen Docker-Socket.
|
- [pkg/traefik](pkg/traefik) – übersetzt flache `traefik.*`-Labels (`FromLabels`) in Traefiks dynamische Konfiguration, führt Knoten-Configs zusammen (`Merge`) und rendert JSON/YAML (`Marshal`) für `/v1/traefik-config`. Traefik *pollt* diesen Endpunkt über seinen HTTP-Provider; Sultan schreibt nie in Traefik und emuliert keinen Docker-Socket.
|
||||||
- [pkg/stream](pkg/stream) – Server→Agent-Kanal als **SSE**: `Hub` (Server, ein Stream pro Knoten, Keepalive `:\n\n`, Neuverbindung ersetzt alte mit `ErrReplaced`, `OnConnect`/`OnDisconnect` nur bei echtem Online/Offline), `Client` (Agent, Idle-Timeout 90s, `Run` mit exponentiellem Backoff, `Prepare` zum Signieren), `Decoder`. Authentifizierung macht der Aufrufer vor `Hub.Serve`. Agent→Server (`service_state`) läuft *nicht* über diesen Stream, sondern über eigene Requests.
|
- [pkg/stream](pkg/stream) – Server→Agent-Kanal als **SSE**: `Hub` (Server, ein Stream pro Knoten, Keepalive `:\n\n`, Neuverbindung ersetzt alte mit `ErrReplaced`, `OnConnect`/`OnDisconnect` nur bei echtem Online/Offline), `Client` (Agent, Idle-Timeout 90s, `Run` mit exponentiellem Backoff, `Prepare` zum Signieren), `Decoder`. Authentifizierung macht der Aufrufer vor `Hub.Serve`. Agent→Server (`service_state`) läuft *nicht* über diesen Stream, sondern über eigene Requests.
|
||||||
- [pkg/store](pkg/store) – SQLite-Persistenz (Schema Spec 4.3) über `modernc.org/sqlite` (reines Go, kein cgo → statisches Binary). Migrationen sind eine append-only Liste in `store.go`, Version in `PRAGMA user_version`; neuere DB-Version → `ErrSchemaTooNew`. Zeitstempel werden aus Go gesetzt (injizierbare Uhr `Store.now`) und als UTC-Text fester Breite gespeichert, damit SQL-Vergleiche funktionieren – keine `CURRENT_TIMESTAMP`-Defaults verwenden. Enrollment-Tokens nur als SHA-256; `Enroll` verbraucht Token und legt Knoten atomar an. Fehler: `ErrNotFound`/`ErrExists`/`ErrInvalid`/`ErrTokenUsed`/`ErrTokenExpired`.
|
- [pkg/store](pkg/store) – SQLite-Persistenz (Schema Spec 4.3) über `modernc.org/sqlite` (reines Go, kein cgo → statisches Binary). Migrationen sind eine append-only Liste in `store.go`, Version in `PRAGMA user_version`; neuere DB-Version → `ErrSchemaTooNew`. Zeitstempel werden aus Go gesetzt (injizierbare Uhr `Store.now`) und als UTC-Text fester Breite gespeichert, damit SQL-Vergleiche funktionieren – keine `CURRENT_TIMESTAMP`-Defaults verwenden. Enrollment-Tokens nur als SHA-256; `Enroll` verbraucht Token und legt Knoten atomar an. Fehler: `ErrNotFound`/`ErrExists`/`ErrInvalid`/`ErrTokenUsed`/`ErrTokenExpired`.
|
||||||
- [pkg/auth](pkg/auth) – Ed25519-Request-Signatur, **in jedem Tier Pflicht** (kein Bypass, auch nicht für `community`/Homelab). `Signer.Sign` (passt als `stream.Client.Prepare`) signiert Methode, Pfad+Query, Zeitstempel, Nonce und Body-Hash (`X-Sultan-*`-Header). `Verifier.Verify` prüft in der Reihenfolge TLS → Signatur → Zeitfenster (±5min) → Nonce → Store, liefert `Identity{Node, Tenant}`; die Knoten-ID für `Hub.Serve` kommt *nur* daraus. `VerifyUnregistered` für Enrollment (Besitznachweis des Schlüssels). Gesperrt (`ErrForbidden`) wird nur bei Quarantäne; Zahlungsverzug/Ablauf/Drain regelt die Policy über das Routing. `StatusCode(err)` für HTTP-Antworten. Schlüsseldateien: PKCS#8-PEM, 0600, werden nie überschrieben.
|
- [pkg/auth](pkg/auth) – Ed25519-Request-Signatur, **in jedem Tier Pflicht** (kein Bypass, auch nicht für `community`/Homelab). `Signer.Sign` (passt als `stream.Client.Prepare`) signiert Methode, Pfad+Query, Zeitstempel, Nonce und Body-Hash (`X-Sultan-*`-Header). `Verifier.Verify` prüft in der Reihenfolge TLS → Signatur → Zeitfenster (±5min) → Nonce → Store, liefert `Identity{Node, Tenant}`; die Knoten-ID für `Hub.Serve` kommt *nur* daraus. `VerifyUnregistered` für Enrollment (Besitznachweis des Schlüssels). Gesperrt (`ErrForbidden`) wird nur bei Quarantäne; Zahlungsverzug/Ablauf/Drain regelt die Policy über das Routing. `StatusCode(err)` für HTTP-Antworten. Schlüsseldateien: PKCS#8-PEM, 0600, werden nie überschrieben.
|
||||||
- **Kein Klartext:** `Signer` signiert nur `https`-URLs, `Verifier` lehnt Requests ohne `r.TLS` ab (`ErrInsecureTransport`, 426). Tests für Agent↔Server laufen daher über TLS-Testserver.
|
- **Kein Klartext:** `Signer` signiert nur `https`-URLs, `Verifier` lehnt Requests ohne `r.TLS` ab (`ErrInsecureTransport`, 426). Tests für Agent↔Server laufen daher über TLS-Testserver.
|
||||||
- [pkg/crypto](pkg/crypto) – Transport: `LoadOrCreateServerCert(dataDir)` (selbstsigniert ECDSA P-256, keine CA, 20 Jahre, `server.key` 0600; fehlt eine der Dateien → Fehler statt Neuerzeugung), `ServerTLSConfig` (nur TLS 1.3), `ClientTLSConfig(server, *KnownServers, expectedFP)` – Pinning im Handshake: Pin gewinnt; ohne Pin nur mit Token-Fingerprint; ohne beides `ErrUnknownServer` (**kein blindes TOFU**). Enrollment-Token `sltn_enroll_<secret>.<fingerprint>` (`NewEnrollmentToken`/`ParseEnrollmentToken`); in den Store kommt nur `Token.Secret`. Paketname kollidiert mit Stdlib-`crypto` – bei Bedarf beim Import aliasen.
|
- [pkg/crypto](pkg/crypto) – Transport: `LoadOrCreateServerCert(dataDir)` (selbstsigniert ECDSA P-256, keine CA, 20 Jahre, `server.key` 0600; fehlt eine der Dateien → Fehler statt Neuerzeugung), `ServerTLSConfig` (nur TLS 1.3), `ClientTLSConfig(server, *KnownServers, expectedFP)` – Pinning im Handshake: Pin gewinnt; ohne Pin nur mit Token-Fingerprint; ohne beides `ErrUnknownServer` (**kein blindes TOFU**). Enrollment-Token `sltn_enroll_<secret>.<fingerprint>` (`NewEnrollmentToken`/`ParseEnrollmentToken`); in den Store kommt nur `Token.Secret`. Paketname kollidiert mit Stdlib-`crypto` – bei Bedarf beim Import aliasen.
|
||||||
|
- [pkg/license](pkg/license) – Lizenz nach Spec 4.1: `Issuer.Issue` (normalisiert + signiert mit Master-Key, Präfix `SULTAN-LICENSE-V1`), `Parse` (strikt, unbekannte Felder abgelehnt) / `Verify`, `AllowsDomain` (`*.x.de` = alle Subdomains, nicht Apex; `*.<tld>` verboten), `AllowsRoutes` (-1 = unbegrenzt), `HasFeature`, `Expired`/`DaysLeft`.
|
||||||
|
- [pkg/policy](pkg/policy) – `HostsFromRule` (echter Parser für Traefik-Regeln; jeder `||`-Zweig muss Host-beschränkt sein, `HostRegexp` verboten), `StageFor` (blocked/landing/paywall/firemark/watermark, Mahnwesen nur kommerziell) und `Engine.Apply`: prüft Router (Domain, Referenzen, Middleware-Gates, Routenlimit), Namensraum `<tenant>__<node>__<name>`, leitet bei Stufe ≠ normal oder Healthcheck-Ausfall zu `sultan__interceptor` um (Header `X-Sultan-Stage/Tenant/Tier`); bei watermark/firemark zusätzlich Durchlass-Router `sultan__<tenant>__<node>__<router>` per Cookie `sultan_ack=<stufe>`. `DunningSchedule` (8/25/32 Tage) berechnet die Mahnstufe. Andockstelle `MiddlewareTargetCheck` (+ `EnforceMiddlewareTargets`) für die geplante Middleware-Zielbegrenzung; nil = dulden.
|
||||||
|
- [pkg/registry](pkg/registry) – Aggregator: `Update(nodeID, NodeState)` (vollständiger Schnappschuss: Workloads mit Host+Labels, `DownServices`), `Remove`, `Refresh` (zeitabhängige Stufen, Directory-Änderungen; fail-static bei Fehlern, `ErrUnknownNode` entfernt), `Config`, `Handler` für `/v1/traefik-config`. `OnViolation` meldet jeden Verstoß nur beim ersten Auftreten. Mandant/Lizenz kommen über das `Directory`-Interface (Server-Implementierung: `pkg/server/directory.go`). Verstöße entfernen nur den Router und landen in `Result.Violations`. Ergebnisse mehrerer Knoten mit `traefik.Merge` kombinieren.
|
||||||
|
|
||||||
|
- [pkg/server](pkg/server) – `sultan server`: `New` (Datenverzeichnis 0700, DB, Zertifikat, `master.key`), `Start`/`Run`/`Shutdown` (Hub zuerst schließen, sonst hängen SSE-Requests), Routen beider Ports (`handlers.go`), Knotenstatus über Hub-Callbacks, Refresh-Schleife, Audit-Log „Policy-Verstoß“ per `slog`. Tests laufen gegen echte Ports (`127.0.0.1:0`) mit Test-Agent (Pinning + Signatur).
|
||||||
|
- [pkg/cli](pkg/cli) – `Run(ctx, args, stdout, stderr) int`; Flags/Defaults von `sultan server`; Version per `-ldflags "-X sultan/pkg/cli.Version=…"`.
|
||||||
|
|
||||||
|
Was noch fehlt (Lander, Agent, `sultan token`, Leases, Checkmk, Betrieb) und offene Fachfragen: Spec Abschnitt 9.
|
||||||
|
|
||||||
Tests werden zuerst als Vorgaben geschrieben, dann implementiert. Zeitabhängige Tests nutzen kurze Intervalle über die Konfigurationsfelder bzw. das injizierbare `Client.sleep`; mit `-race -count=30` auf Stabilität prüfen.
|
Tests werden zuerst als Vorgaben geschrieben, dann implementiert. Zeitabhängige Tests nutzen kurze Intervalle über die Konfigurationsfelder bzw. das injizierbare `Client.sleep`; mit `-race -count=30` auf Stabilität prüfen.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
go build -o sultan . && ./sultan server # Daten in ./suldata (gitignored, enthält Schlüssel!)
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go test -race ./... # alle Tests
|
go test -race ./... # alle Tests
|
||||||
go test -run TestFromLabels_Errors ./pkg/traefik # einzelner Test
|
go test -run TestFromLabels_Errors ./pkg/traefik # einzelner Test
|
||||||
@@ -49,7 +58,7 @@ Sultan ersetzt manuell gepflegtes Ingress-Routing (z. B. Nginx Proxy Manager): B
|
|||||||
|
|
||||||
**Stream-Events** (JSON: `event_id`, `timestamp`, `type`, `node_id`, `payload`): `service_state` (Agent→Server, vollständiger Label-Snapshot, nicht Delta), `node_command` (`re_enroll`/`drain`/`maintenance`), `lease_renew`, `error`.
|
**Stream-Events** (JSON: `event_id`, `timestamp`, `type`, `node_id`, `payload`): `service_state` (Agent→Server, vollständiger Label-Snapshot, nicht Delta), `node_command` (`re_enroll`/`drain`/`maintenance`), `lease_renew`, `error`.
|
||||||
|
|
||||||
**Persistenz:** SQLite unter `<data-dir>/sultan.db` (Default `/var/lib/sultan`), Tabellen `tenants`, `nodes`, `enrollment_tokens` (Tokens nur als Hash gespeichert).
|
**Persistenz:** SQLite unter `<data-dir>/sultan.db` (Default-Datenverzeichnis `./suldata`, auch beim Agent), Tabellen `tenants`, `nodes`, `enrollment_tokens` (Tokens nur als Hash gespeichert).
|
||||||
|
|
||||||
**Policy/Lizenzablauf:** Mandanten-Status steuert das generierte Routing – Zahlungsverzug → Router auf Bezahlseite (402), Backend down/`service_down` → Statusseite 503 mit `Retry-After: 60`. Checkmk-Schwellen: WARN bei 30, CRIT bei 14 Tagen Restlaufzeit, bei Ablauf Umleitung auf Landingpage.
|
**Policy/Lizenzablauf:** Mandanten-Status steuert das generierte Routing – Zahlungsverzug → Router auf Bezahlseite (402), Backend down/`service_down` → Statusseite 503 mit `Retry-After: 60`. Checkmk-Schwellen: WARN bei 30, CRIT bei 14 Tagen Restlaufzeit, bei Ablauf Umleitung auf Landingpage.
|
||||||
|
|
||||||
|
|||||||
@@ -46,16 +46,18 @@ Die ausführliche Architektur steht in [.ai/sultan-system-architecture.md](.ai/s
|
|||||||
git clone ssh://git@git.everdc.eu:10022/chmln-app-dev/sultan.git
|
git clone ssh://git@git.everdc.eu:10022/chmln-app-dev/sultan.git
|
||||||
cd sultan
|
cd sultan
|
||||||
go build -o sultan .
|
go build -o sultan .
|
||||||
|
./sultan version
|
||||||
sudo install -m 0755 sultan /usr/local/bin/sultan
|
sudo install -m 0755 sultan /usr/local/bin/sultan
|
||||||
```
|
```
|
||||||
|
|
||||||
### Server starten (Ingress-Knoten)
|
### Server starten (Ingress-Knoten)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sultan server --data-dir=/var/lib/sultan
|
sultan server # Daten landen in ./suldata
|
||||||
|
sultan server --help # alle Optionen
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Server lauscht auf `:8443` für Agents und auf `127.0.0.1:8080` für Traefik. Traefik bindest du über den HTTP-Provider an:
|
Der Server lauscht auf `:8443` für Agents und auf `127.0.0.1:8080` für Traefik (beides per `--agent-addr` / `--provider-addr` änderbar). Im Verzeichnis `./suldata` liegen Datenbank und Schlüssel – bitte sichern 🔑 und nie einchecken. Traefik bindest du über den HTTP-Provider an:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
--providers.http.endpoint=http://127.0.0.1:8080/v1/traefik-config
|
--providers.http.endpoint=http://127.0.0.1:8080/v1/traefik-config
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// sultan – verteiltes Ingress-Routing mit ZeroTrust-Service-Discovery.
|
||||||
|
// Die Befehle sind in pkg/cli implementiert.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"sultan/pkg/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
code := cli.Run(ctx, os.Args[1:], os.Stdout, os.Stderr)
|
||||||
|
stop()
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
// Package cli implementiert die Befehlszeile von sultan:
|
||||||
|
//
|
||||||
|
// sultan server Registry, Stream-Hub, Policy-Engine, Traefik-Provider
|
||||||
|
// sultan agent (noch nicht implementiert)
|
||||||
|
// sultan token (noch nicht implementiert)
|
||||||
|
// sultan version
|
||||||
|
//
|
||||||
|
// Run ist ohne Prozessgrenzen testbar; main ruft nur Run auf.
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sultan/pkg/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version wird beim Bauen gesetzt: -ldflags "-X sultan/pkg/cli.Version=1.2.3".
|
||||||
|
var Version = "dev"
|
||||||
|
|
||||||
|
const usage = `sultan – verteiltes Ingress-Routing mit ZeroTrust-Service-Discovery
|
||||||
|
|
||||||
|
Befehle:
|
||||||
|
sultan server Server starten (Registry, Agent-Port, Traefik-Provider)
|
||||||
|
sultan agent Agent starten (noch nicht implementiert)
|
||||||
|
sultan token Mandanten, Einladungen, Lizenzen verwalten (noch nicht implementiert)
|
||||||
|
sultan version Version ausgeben
|
||||||
|
sultan help diese Hilfe
|
||||||
|
|
||||||
|
Hilfe zu einem Befehl: sultan <befehl> --help
|
||||||
|
`
|
||||||
|
|
||||||
|
// Run führt die Befehlszeile aus und liefert den Exit-Code (0 ok, 1 Fehler,
|
||||||
|
// 2 Bedienfehler). ctx endet z. B. bei SIGTERM.
|
||||||
|
func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fmt.Fprint(stderr, usage)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
fmt.Fprint(stdout, usage)
|
||||||
|
return 0
|
||||||
|
case "version":
|
||||||
|
fmt.Fprintf(stdout, "sultan %s\n", Version)
|
||||||
|
return 0
|
||||||
|
case "server":
|
||||||
|
return runServer(ctx, args[1:], stdout, stderr)
|
||||||
|
case "agent", "token":
|
||||||
|
fmt.Fprintf(stderr, "sultan %s: noch nicht implementiert (siehe Roadmap)\n", args[0])
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(stderr, "unbekannter Befehl %q\n\n%s", args[0], usage)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runServer(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||||
|
cfg, err := parseServerFlags(args, stderr)
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(stderr, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
cfg.Logger = slog.New(slog.NewTextHandler(stderr, nil))
|
||||||
|
s, err := server.New(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(stderr, "Start fehlgeschlagen:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if err := s.Run(ctx); err != nil {
|
||||||
|
fmt.Fprintln(stderr, "Server beendet mit Fehler:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseServerFlags liest die Flags von `sultan server`; Defaults wie in
|
||||||
|
// server.Config.
|
||||||
|
func parseServerFlags(args []string, output io.Writer) (server.Config, error) {
|
||||||
|
fs := flag.NewFlagSet("sultan server", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(output)
|
||||||
|
var cfg server.Config
|
||||||
|
fs.StringVar(&cfg.DataDir, "data-dir", "./suldata", "Datenverzeichnis (sultan.db, server.key/.crt, master.key)")
|
||||||
|
fs.StringVar(&cfg.AgentAddr, "agent-addr", ":8443", "Adresse des Agent-Ports (nur TLS)")
|
||||||
|
fs.StringVar(&cfg.ProviderAddr, "provider-addr", "127.0.0.1:8080",
|
||||||
|
"Adresse für Traefik (/v1/traefik-config, Klartext – im Container :8080 und nur auf 127.0.0.1 veröffentlichen)")
|
||||||
|
fs.StringVar(&cfg.InterceptorURL, "interceptor-url", "http://127.0.0.1:8081", "Adresse des Sultan-Interceptors/Landers")
|
||||||
|
fs.DurationVar(&cfg.RefreshInterval, "refresh", time.Minute, "Intervall der Neubewertung aller Knoten")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return server.Config{}, err
|
||||||
|
}
|
||||||
|
if fs.NArg() > 0 {
|
||||||
|
return server.Config{}, fmt.Errorf("unerwartetes Argument %q", fs.Arg(0))
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func run(ctx context.Context, args ...string) (code int, stdout, stderr string) {
|
||||||
|
var out, errb bytes.Buffer
|
||||||
|
code = Run(ctx, args, &out, &errb)
|
||||||
|
return code, out.String(), errb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_Usage(t *testing.T) {
|
||||||
|
code, _, stderr := run(context.Background())
|
||||||
|
if code != 2 || !strings.Contains(stderr, "sultan server") {
|
||||||
|
t.Errorf("ohne Befehl: code=%d stderr=%q", code, stderr)
|
||||||
|
}
|
||||||
|
code, stdout, _ := run(context.Background(), "help")
|
||||||
|
if code != 0 || !strings.Contains(stdout, "sultan server") || !strings.Contains(stdout, "sultan agent") {
|
||||||
|
t.Errorf("help: code=%d stdout=%q", code, stdout)
|
||||||
|
}
|
||||||
|
code, _, stderr = run(context.Background(), "zaubern")
|
||||||
|
if code != 2 || !strings.Contains(stderr, "zaubern") {
|
||||||
|
t.Errorf("unbekannter Befehl: code=%d stderr=%q", code, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_Version(t *testing.T) {
|
||||||
|
code, stdout, _ := run(context.Background(), "version")
|
||||||
|
if code != 0 || strings.TrimSpace(stdout) != "sultan "+Version {
|
||||||
|
t.Errorf("version: code=%d stdout=%q", code, stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_NotYetImplemented(t *testing.T) {
|
||||||
|
for _, cmd := range []string{"agent", "token"} {
|
||||||
|
code, _, stderr := run(context.Background(), cmd)
|
||||||
|
if code != 1 || !strings.Contains(stderr, "noch nicht implementiert") {
|
||||||
|
t.Errorf("%s: code=%d stderr=%q", cmd, code, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerFlags(t *testing.T) {
|
||||||
|
cfg, err := parseServerFlags(nil, &bytes.Buffer{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.DataDir != "./suldata" || cfg.AgentAddr != ":8443" || cfg.ProviderAddr != "127.0.0.1:8080" ||
|
||||||
|
cfg.InterceptorURL != "http://127.0.0.1:8081" || cfg.RefreshInterval != time.Minute {
|
||||||
|
t.Errorf("Defaults: %+v", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err = parseServerFlags([]string{
|
||||||
|
"--data-dir=/srv/sul", "--agent-addr", ":9443", "--provider-addr=:8080",
|
||||||
|
"--interceptor-url=http://lander:8081", "--refresh=30s",
|
||||||
|
}, &bytes.Buffer{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.DataDir != "/srv/sul" || cfg.AgentAddr != ":9443" || cfg.ProviderAddr != ":8080" ||
|
||||||
|
cfg.InterceptorURL != "http://lander:8081" || cfg.RefreshInterval != 30*time.Second {
|
||||||
|
t.Errorf("Overrides: %+v", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := parseServerFlags([]string{"--gibts-nicht"}, &bytes.Buffer{}); err == nil {
|
||||||
|
t.Error("unbekanntes Flag akzeptiert")
|
||||||
|
}
|
||||||
|
if _, err := parseServerFlags([]string{"überzählig"}, &bytes.Buffer{}); err == nil {
|
||||||
|
t.Error("überzähliges Argument akzeptiert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_ServerHelpAndBadFlags(t *testing.T) {
|
||||||
|
code, stdout, stderr := run(context.Background(), "server", "--help")
|
||||||
|
if code != 0 || !strings.Contains(stdout+stderr, "data-dir") {
|
||||||
|
t.Errorf("server --help: code=%d out=%q err=%q", code, stdout, stderr)
|
||||||
|
}
|
||||||
|
code, _, _ = run(context.Background(), "server", "--bogus")
|
||||||
|
if code != 2 {
|
||||||
|
t.Errorf("server --bogus: code=%d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Startet den echten Server über die CLI und beendet ihn per Kontext
|
||||||
|
// (wie SIGTERM in main).
|
||||||
|
func TestRun_ServerStartsAndStops(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "suldata")
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
type result struct {
|
||||||
|
code int
|
||||||
|
stderr string
|
||||||
|
}
|
||||||
|
done := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
code, _, stderr := run(ctx, "server", "--data-dir="+dir, "--agent-addr=127.0.0.1:0", "--provider-addr=127.0.0.1:0")
|
||||||
|
done <- result{code, stderr}
|
||||||
|
}()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "server.crt")); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("Server hat das Datenverzeichnis nicht angelegt")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case r := <-done:
|
||||||
|
if r.code != 0 {
|
||||||
|
t.Errorf("Exit-Code %d, stderr:\n%s", r.code, r.stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.stderr, "Fingerprint") {
|
||||||
|
t.Errorf("Start-Log ohne Fingerprint:\n%s", r.stderr)
|
||||||
|
}
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("Server endet nicht")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_ServerStartError(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "datei")
|
||||||
|
os.WriteFile(file, nil, 0o600)
|
||||||
|
code, _, stderr := run(context.Background(), "server", "--data-dir="+filepath.Join(file, "sub"))
|
||||||
|
if code != 1 || stderr == "" {
|
||||||
|
t.Errorf("unbrauchbares Datenverzeichnis: code=%d stderr=%q", code, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ein DNS-Label: 1–63 Zeichen, a–z, 0–9 und Bindestrich, nicht am Rand.
|
||||||
|
// Internationalisierte Domains werden in Punycode (xn--…) angegeben.
|
||||||
|
var labelRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||||
|
|
||||||
|
// splitHost normalisiert host (klein, ohne Punkt am Ende) und prüft, dass er
|
||||||
|
// aus mindestens zwei gültigen Labels besteht.
|
||||||
|
func splitHost(host string) (string, bool) {
|
||||||
|
h := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
|
||||||
|
if len(h) > 253 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
labels := strings.Split(h, ".")
|
||||||
|
if len(labels) < 2 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, l := range labels {
|
||||||
|
if !labelRe.MatchString(l) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizePattern prüft einen Eintrag aus allowed_domains: entweder eine
|
||||||
|
// Domain ("shop.de") oder eine Wildcard ganz vorne ("*.firma.de"). Eine
|
||||||
|
// Wildcard direkt auf einer TLD ("*.de") ist nicht erlaubt.
|
||||||
|
func normalizePattern(p string) (string, error) {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
rest, wildcard := strings.CutPrefix(p, "*.")
|
||||||
|
h, ok := splitHost(rest)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%w: ungültiges Domain-Muster %q", ErrInvalid, p)
|
||||||
|
}
|
||||||
|
if wildcard {
|
||||||
|
return "*." + h, nil
|
||||||
|
}
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowsDomain meldet, ob host durch allowed_domains gedeckt ist:
|
||||||
|
// - "shop.de" deckt genau shop.de (keine Subdomains),
|
||||||
|
// - "*.firma.de" deckt jede Subdomain beliebiger Tiefe, nicht firma.de selbst.
|
||||||
|
//
|
||||||
|
// Groß-/Kleinschreibung und ein Punkt am Ende werden ignoriert.
|
||||||
|
func (l License) AllowsDomain(host string) bool {
|
||||||
|
h, ok := splitHost(host)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, p := range l.AllowedDomains {
|
||||||
|
if base, wildcard := strings.CutPrefix(p, "*."); wildcard {
|
||||||
|
if strings.HasSuffix(h, "."+base) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else if h == p {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package license
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestAllowsDomain(t *testing.T) {
|
||||||
|
l := License{AllowedDomains: []string{"*.firma.de", "shop.de"}}
|
||||||
|
tests := map[string]bool{
|
||||||
|
"shop.de": true,
|
||||||
|
"SHOP.de": true,
|
||||||
|
"shop.de.": true,
|
||||||
|
"www.shop.de": false, // exakter Eintrag deckt keine Subdomains
|
||||||
|
"a.firma.de": true,
|
||||||
|
"a.b.firma.de": true, // Wildcard deckt alle Subdomain-Ebenen
|
||||||
|
"firma.de": false, // Wildcard deckt nicht die Domain selbst
|
||||||
|
"evilfirma.de": false,
|
||||||
|
"firma.de.evil.com": false,
|
||||||
|
"a.firma.de.evil.com": false,
|
||||||
|
"": false,
|
||||||
|
".firma.de": false,
|
||||||
|
"a..firma.de": false,
|
||||||
|
}
|
||||||
|
for host, want := range tests {
|
||||||
|
if got := l.AllowsDomain(host); got != want {
|
||||||
|
t.Errorf("AllowsDomain(%q) = %v, erwartet %v", host, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeDomainPattern(t *testing.T) {
|
||||||
|
valid := map[string]string{
|
||||||
|
"firma.de": "firma.de",
|
||||||
|
"Firma.DE.": "firma.de",
|
||||||
|
"*.firma.de": "*.firma.de",
|
||||||
|
"xn--mnchen-3ya.de": "xn--mnchen-3ya.de",
|
||||||
|
"a-b.c1.de": "a-b.c1.de",
|
||||||
|
}
|
||||||
|
for in, want := range valid {
|
||||||
|
got, err := normalizePattern(in)
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Errorf("normalizePattern(%q) = %q, %v; erwartet %q", in, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"", "*", "*.de", "de", "a.*.de", "**.de", "-a.de", "a-.de", "a_b.de", "münchen.de", "a..de", "foo bar.de", strings63() + "x.de"} {
|
||||||
|
if _, err := normalizePattern(bad); err == nil {
|
||||||
|
t.Errorf("normalizePattern(%q): erwartet Fehler", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// strings63 liefert ein Label mit 63 Zeichen (Maximum); mit "x" wird es zu lang.
|
||||||
|
func strings63() string {
|
||||||
|
b := make([]byte, 63)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = 'a'
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// Package license stellt Mandanten-Lizenzen (Capabilities, Spec Abschnitt
|
||||||
|
// 4.1) aus und prüft sie.
|
||||||
|
//
|
||||||
|
// Eine Lizenz legt fest, was ein Mandant darf: Tier, maximale Routenzahl,
|
||||||
|
// erlaubte Domains, Features und Laufzeit. Sie ist mit dem Ed25519-
|
||||||
|
// Master-Key des Servers signiert; jede Änderung an einem Feld macht die
|
||||||
|
// Signatur ungültig.
|
||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrInvalid: Die Lizenz verletzt eine Formatregel.
|
||||||
|
ErrInvalid = errors.New("license: ungültige Lizenz")
|
||||||
|
// ErrInvalidSignature: Signatur fehlt, ist beschädigt oder passt nicht.
|
||||||
|
ErrInvalidSignature = errors.New("license: Signatur ungültig")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tier ist die Lizenzstufe.
|
||||||
|
type Tier string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TierCommunity Tier = "community"
|
||||||
|
TierPro Tier = "pro"
|
||||||
|
TierEnterprise Tier = "enterprise"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Feature ist eine optional freigeschaltete Fähigkeit.
|
||||||
|
type Feature string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FeatureWebsockets Feature = "websockets"
|
||||||
|
FeatureTCPStream Feature = "tcp_stream"
|
||||||
|
FeatureCustomMiddleware Feature = "custom_middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// License entspricht license.json (Spec 4.1).
|
||||||
|
type License struct {
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
Tier Tier `json:"tier"`
|
||||||
|
MaxRoutes int `json:"max_routes"` // -1 = unbegrenzt
|
||||||
|
AllowedDomains []string `json:"allowed_domains"`
|
||||||
|
Features []Feature `json:"features"`
|
||||||
|
ValidUntil time.Time `json:"valid_until"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// signatureVersion trennt Lizenz-Signaturen von anderen Nutzungen desselben
|
||||||
|
// Master-Keys (z. B. künftig signierten Server-Events).
|
||||||
|
const signatureVersion = "SULTAN-LICENSE-V1\n"
|
||||||
|
|
||||||
|
// signedPayload legt Reihenfolge und Format der signierten Felder fest.
|
||||||
|
type signedPayload struct {
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
Tier Tier `json:"tier"`
|
||||||
|
MaxRoutes int `json:"max_routes"`
|
||||||
|
AllowedDomains []string `json:"allowed_domains"`
|
||||||
|
Features []Feature `json:"features"`
|
||||||
|
ValidUntil string `json:"valid_until"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l License) message() []byte {
|
||||||
|
b, _ := json.Marshal(signedPayload{
|
||||||
|
TenantID: l.TenantID,
|
||||||
|
Tier: l.Tier,
|
||||||
|
MaxRoutes: l.MaxRoutes,
|
||||||
|
AllowedDomains: l.AllowedDomains,
|
||||||
|
Features: l.Features,
|
||||||
|
ValidUntil: l.ValidUntil.UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
return append([]byte(signatureVersion), b...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issuer stellt Lizenzen mit dem Master-Key des Servers aus.
|
||||||
|
type Issuer struct {
|
||||||
|
Key ed25519.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue prüft und normalisiert l (Domains klein und ohne Punkt am Ende,
|
||||||
|
// ValidUntil in UTC auf ganze Sekunden) und signiert sie. Die Eingabe wird
|
||||||
|
// nicht verändert.
|
||||||
|
func (i *Issuer) Issue(l License) (License, error) {
|
||||||
|
if len(i.Key) != ed25519.PrivateKeySize {
|
||||||
|
return License{}, errors.New("license: Issuer ohne gültigen Master-Key")
|
||||||
|
}
|
||||||
|
out := l
|
||||||
|
out.Signature = ""
|
||||||
|
out.ValidUntil = l.ValidUntil.UTC().Truncate(time.Second)
|
||||||
|
out.AllowedDomains = make([]string, len(l.AllowedDomains))
|
||||||
|
for n, d := range l.AllowedDomains {
|
||||||
|
p, err := normalizePattern(d)
|
||||||
|
if err != nil {
|
||||||
|
return License{}, err
|
||||||
|
}
|
||||||
|
out.AllowedDomains[n] = p
|
||||||
|
}
|
||||||
|
out.Features = append([]Feature{}, l.Features...)
|
||||||
|
if err := out.validate(); err != nil {
|
||||||
|
return License{}, err
|
||||||
|
}
|
||||||
|
out.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(i.Key, out.message()))
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l License) validate() error {
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(l.TenantID) == "":
|
||||||
|
return fmt.Errorf("%w: tenant_id fehlt", ErrInvalid)
|
||||||
|
case l.Tier != TierCommunity && l.Tier != TierPro && l.Tier != TierEnterprise:
|
||||||
|
return fmt.Errorf("%w: unbekannter Tier %q", ErrInvalid, l.Tier)
|
||||||
|
case l.MaxRoutes < -1:
|
||||||
|
return fmt.Errorf("%w: max_routes %d (erlaubt: -1 oder ≥ 0)", ErrInvalid, l.MaxRoutes)
|
||||||
|
case len(l.AllowedDomains) == 0:
|
||||||
|
return fmt.Errorf("%w: allowed_domains leer", ErrInvalid)
|
||||||
|
case l.ValidUntil.IsZero():
|
||||||
|
return fmt.Errorf("%w: valid_until fehlt", ErrInvalid)
|
||||||
|
}
|
||||||
|
for _, d := range l.AllowedDomains {
|
||||||
|
if p, err := normalizePattern(d); err != nil || p != d {
|
||||||
|
return fmt.Errorf("%w: Domain %q nicht normalisiert", ErrInvalid, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, f := range l.Features {
|
||||||
|
if f != FeatureWebsockets && f != FeatureTCPStream && f != FeatureCustomMiddleware {
|
||||||
|
return fmt.Errorf("%w: unbekanntes Feature %q", ErrInvalid, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify prüft die Signatur mit dem Public Key des Master-Keys.
|
||||||
|
func (l License) Verify(pub ed25519.PublicKey) error {
|
||||||
|
if len(pub) != ed25519.PublicKeySize {
|
||||||
|
return fmt.Errorf("%w: kein gültiger Public Key", ErrInvalidSignature)
|
||||||
|
}
|
||||||
|
sig, err := base64.StdEncoding.DecodeString(l.Signature)
|
||||||
|
if err != nil || len(sig) != ed25519.SignatureSize {
|
||||||
|
return fmt.Errorf("%w: Signatur fehlt oder ist beschädigt", ErrInvalidSignature)
|
||||||
|
}
|
||||||
|
if !ed25519.Verify(pub, l.message(), sig) {
|
||||||
|
return ErrInvalidSignature
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse liest license.json, lehnt unbekannte Felder ab und prüft Format und
|
||||||
|
// Signatur.
|
||||||
|
func Parse(data []byte, pub ed25519.PublicKey) (License, error) {
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
var l License
|
||||||
|
if err := dec.Decode(&l); err != nil {
|
||||||
|
return License{}, fmt.Errorf("%w: %v", ErrInvalid, err)
|
||||||
|
}
|
||||||
|
l.ValidUntil = l.ValidUntil.UTC()
|
||||||
|
if l.Features == nil {
|
||||||
|
l.Features = []Feature{}
|
||||||
|
}
|
||||||
|
if err := l.validate(); err != nil {
|
||||||
|
return License{}, err
|
||||||
|
}
|
||||||
|
if err := l.Verify(pub); err != nil {
|
||||||
|
return License{}, err
|
||||||
|
}
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired meldet, ob die Lizenz zum Zeitpunkt now abgelaufen ist (der
|
||||||
|
// Ablaufzeitpunkt selbst zählt als abgelaufen).
|
||||||
|
func (l License) Expired(now time.Time) bool { return !now.Before(l.ValidUntil) }
|
||||||
|
|
||||||
|
// DaysLeft liefert die verbleibenden vollen Tage (abgerundet); nach Ablauf
|
||||||
|
// negativ. Grundlage für die Checkmk-Schwellen (30/14 Tage).
|
||||||
|
func (l License) DaysLeft(now time.Time) int {
|
||||||
|
return int(math.Floor(l.ValidUntil.Sub(now).Hours() / 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowsRoutes meldet, ob n Routen im Limit liegen.
|
||||||
|
func (l License) AllowsRoutes(n int) bool { return l.MaxRoutes == -1 || n <= l.MaxRoutes }
|
||||||
|
|
||||||
|
// HasFeature meldet, ob f freigeschaltet ist.
|
||||||
|
func (l License) HasFeature(f Feature) bool { return slices.Contains(l.Features, f) }
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newIssuer(t *testing.T) (*Issuer, ed25519.PublicKey) {
|
||||||
|
t.Helper()
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &Issuer{Key: priv}, pub
|
||||||
|
}
|
||||||
|
|
||||||
|
func sample() License {
|
||||||
|
return License{
|
||||||
|
TenantID: "kunde_schmidt",
|
||||||
|
Tier: TierPro,
|
||||||
|
MaxRoutes: 10,
|
||||||
|
AllowedDomains: []string{"*.firma.de", "shop.de"},
|
||||||
|
Features: []Feature{FeatureWebsockets, FeatureTCPStream},
|
||||||
|
ValidUntil: time.Date(2026, 12, 15, 0, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueAndVerify(t *testing.T) {
|
||||||
|
iss, pub := newIssuer(t)
|
||||||
|
l, err := iss.Issue(sample())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
if l.Signature == "" {
|
||||||
|
t.Fatal("keine Signatur gesetzt")
|
||||||
|
}
|
||||||
|
if err := l.Verify(pub); err != nil {
|
||||||
|
t.Errorf("Verify: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssue_NormalizesAndDoesNotMutateInput(t *testing.T) {
|
||||||
|
iss, _ := newIssuer(t)
|
||||||
|
in := sample()
|
||||||
|
in.ValidUntil = time.Date(2026, 12, 15, 1, 30, 0, 999_000_000, time.FixedZone("CET", 3600))
|
||||||
|
in.Signature = "alt"
|
||||||
|
l, err := iss.Issue(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := time.Date(2026, 12, 15, 0, 30, 0, 0, time.UTC)
|
||||||
|
if !l.ValidUntil.Equal(want) || l.ValidUntil.Location() != time.UTC {
|
||||||
|
t.Errorf("ValidUntil = %v, erwartet %v (UTC, ganze Sekunden)", l.ValidUntil, want)
|
||||||
|
}
|
||||||
|
if in.Signature != "alt" || in.ValidUntil.Location() == time.UTC {
|
||||||
|
t.Error("Eingabe wurde verändert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feldnamen gemäß Spec Abschnitt 4.1.
|
||||||
|
func TestJSONFormat(t *testing.T) {
|
||||||
|
iss, _ := newIssuer(t)
|
||||||
|
l, _ := iss.Issue(sample())
|
||||||
|
b, err := json.Marshal(l)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
json.Unmarshal(b, &m)
|
||||||
|
for _, key := range []string{"tenant_id", "tier", "max_routes", "allowed_domains", "features", "valid_until", "signature"} {
|
||||||
|
if _, ok := m[key]; !ok {
|
||||||
|
t.Errorf("Feld %q fehlt in %s", key, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(m) != 7 {
|
||||||
|
t.Errorf("unerwartete Felder: %s", b)
|
||||||
|
}
|
||||||
|
if m["valid_until"] != "2026-12-15T00:00:00Z" {
|
||||||
|
t.Errorf("valid_until = %v, erwartet RFC 3339", m["valid_until"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_RoundTrip(t *testing.T) {
|
||||||
|
iss, pub := newIssuer(t)
|
||||||
|
l, _ := iss.Issue(sample())
|
||||||
|
b, _ := json.MarshalIndent(l, "", " ")
|
||||||
|
|
||||||
|
got, err := Parse(b, pub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, l) {
|
||||||
|
t.Errorf("Round-Trip weicht ab\n got: %+v\nwant: %+v", got, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_Errors(t *testing.T) {
|
||||||
|
iss, pub := newIssuer(t)
|
||||||
|
l, _ := iss.Issue(sample())
|
||||||
|
good, _ := json.Marshal(l)
|
||||||
|
otherPub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
|
||||||
|
if _, err := Parse(good, otherPub); !errors.Is(err, ErrInvalidSignature) {
|
||||||
|
t.Errorf("fremder Schlüssel: erwartet ErrInvalidSignature, bekommen %v", err)
|
||||||
|
}
|
||||||
|
tests := map[string]string{
|
||||||
|
"kein JSON": "lizenz",
|
||||||
|
"unbekanntes Feld": strings.Replace(string(good), `"tier"`, `"admin":true,"tier"`, 1),
|
||||||
|
"Signatur fehlt": strings.Replace(string(good), `"signature":"`+l.Signature+`"`, `"signature":""`, 1),
|
||||||
|
"Pflichtfeld fehlt": strings.Replace(string(good), `"tenant_id":"kunde_schmidt",`, ``, 1),
|
||||||
|
}
|
||||||
|
for name, data := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, err := Parse([]byte(data), pub)
|
||||||
|
if !errors.Is(err, ErrInvalid) && !errors.Is(err, ErrInvalidSignature) {
|
||||||
|
t.Errorf("erwartet ErrInvalid oder ErrInvalidSignature, bekommen %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerify_Tampering(t *testing.T) {
|
||||||
|
tests := map[string]func(*License){
|
||||||
|
"Mandant": func(l *License) { l.TenantID = "fremd" },
|
||||||
|
"Tier": func(l *License) { l.Tier = TierEnterprise },
|
||||||
|
"MaxRoutes": func(l *License) { l.MaxRoutes = -1 },
|
||||||
|
"Domain ergänzt": func(l *License) { l.AllowedDomains = append(l.AllowedDomains, "bank.de") },
|
||||||
|
"Domains vertauscht": func(l *License) { l.AllowedDomains[0], l.AllowedDomains[1] = l.AllowedDomains[1], l.AllowedDomains[0] },
|
||||||
|
"Feature ergänzt": func(l *License) { l.Features = append(l.Features, FeatureCustomMiddleware) },
|
||||||
|
"Ablauf +1s": func(l *License) { l.ValidUntil = l.ValidUntil.Add(time.Second) },
|
||||||
|
"Signatur Müll": func(l *License) { l.Signature = "!!!" },
|
||||||
|
"Signatur leer": func(l *License) { l.Signature = "" },
|
||||||
|
}
|
||||||
|
for name, tamper := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
iss, pub := newIssuer(t)
|
||||||
|
l, _ := iss.Issue(sample())
|
||||||
|
l.AllowedDomains = append([]string(nil), l.AllowedDomains...)
|
||||||
|
tamper(&l)
|
||||||
|
if err := l.Verify(pub); !errors.Is(err, ErrInvalidSignature) {
|
||||||
|
t.Errorf("erwartet ErrInvalidSignature, bekommen %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssue_Validation(t *testing.T) {
|
||||||
|
tests := map[string]func(*License){
|
||||||
|
"Mandant leer": func(l *License) { l.TenantID = " " },
|
||||||
|
"unbekannter Tier": func(l *License) { l.Tier = "gold" },
|
||||||
|
"MaxRoutes < -1": func(l *License) { l.MaxRoutes = -2 },
|
||||||
|
"keine Domains": func(l *License) { l.AllowedDomains = nil },
|
||||||
|
"Domain ungültig": func(l *License) { l.AllowedDomains = []string{"foo bar.de"} },
|
||||||
|
"Wildcard allein": func(l *License) { l.AllowedDomains = []string{"*"} },
|
||||||
|
"Wildcard auf TLD": func(l *License) { l.AllowedDomains = []string{"*.de"} },
|
||||||
|
"Wildcard in der Mitte": func(l *License) { l.AllowedDomains = []string{"a.*.de"} },
|
||||||
|
"unbekanntes Feature": func(l *License) { l.Features = []Feature{"teleport"} },
|
||||||
|
"ValidUntil fehlt": func(l *License) { l.ValidUntil = time.Time{} },
|
||||||
|
}
|
||||||
|
for name, mutate := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
iss, _ := newIssuer(t)
|
||||||
|
in := sample()
|
||||||
|
mutate(&in)
|
||||||
|
if _, err := iss.Issue(in); !errors.Is(err, ErrInvalid) {
|
||||||
|
t.Errorf("erwartet ErrInvalid, bekommen %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := (&Issuer{}).Issue(sample()); err == nil {
|
||||||
|
t.Error("Issuer ohne Schlüssel: erwartet Fehler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssue_DomainsNormalized(t *testing.T) {
|
||||||
|
iss, _ := newIssuer(t)
|
||||||
|
in := sample()
|
||||||
|
in.AllowedDomains = []string{"Shop.DE.", " *.Firma.de "}
|
||||||
|
l, err := iss.Issue(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(l.AllowedDomains, []string{"shop.de", "*.firma.de"}) {
|
||||||
|
t.Errorf("AllowedDomains = %v", l.AllowedDomains)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiry(t *testing.T) {
|
||||||
|
l := sample() // gültig bis 2026-12-15T00:00:00Z
|
||||||
|
tests := []struct {
|
||||||
|
now time.Time
|
||||||
|
expired bool
|
||||||
|
daysLeft int
|
||||||
|
}{
|
||||||
|
{l.ValidUntil.AddDate(0, 0, -74), false, 74},
|
||||||
|
{l.ValidUntil.Add(-12 * time.Hour), false, 0},
|
||||||
|
{l.ValidUntil, true, 0},
|
||||||
|
{l.ValidUntil.Add(time.Hour), true, -1},
|
||||||
|
{l.ValidUntil.Add(25 * time.Hour), true, -2},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := l.Expired(tt.now); got != tt.expired {
|
||||||
|
t.Errorf("Expired(%v) = %v", tt.now, got)
|
||||||
|
}
|
||||||
|
if got := l.DaysLeft(tt.now); got != tt.daysLeft {
|
||||||
|
t.Errorf("DaysLeft(%v) = %d, erwartet %d", tt.now, got, tt.daysLeft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowsRoutes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
max, n int
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{-1, 0, true}, {-1, 10_000, true},
|
||||||
|
{0, 0, true}, {0, 1, false},
|
||||||
|
{5, 5, true}, {5, 6, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
l := License{MaxRoutes: tt.max}
|
||||||
|
if got := l.AllowsRoutes(tt.n); got != tt.want {
|
||||||
|
t.Errorf("max=%d AllowsRoutes(%d) = %v", tt.max, tt.n, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasFeature(t *testing.T) {
|
||||||
|
l := sample()
|
||||||
|
if !l.HasFeature(FeatureTCPStream) || l.HasFeature(FeatureCustomMiddleware) {
|
||||||
|
t.Errorf("HasFeature falsch für %v", l.Features)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
// Package policy ist die Policy- & Interception-Engine des Servers (Spec
|
||||||
|
// 3.3/5.2). Sie nimmt die Traefik-Konfiguration eines Knotens (aus dessen
|
||||||
|
// Labels) und macht daraus, was tatsächlich ausgeliefert werden darf:
|
||||||
|
//
|
||||||
|
// - Domain-Schutz: Jede Regel muss auf Hosts aus allowed_domains
|
||||||
|
// beschränkt sein (echter Regel-Parser, kein Textvergleich).
|
||||||
|
// - Routenlimit (max_routes), Feature-Gates für Middlewares, keine
|
||||||
|
// Referenzen auf fremde Provider (api@internal …) außer freigegebenen.
|
||||||
|
// - Namensraum: Alle Namen werden zu <tenant>__<node>__<name>, damit sich
|
||||||
|
// Mandanten nicht gegenseitig überschreiben oder referenzieren können.
|
||||||
|
// - Interception: Je nach Stufe (Mahnwesen, Ablauf, Wartung) wird der
|
||||||
|
// Router zum Sultan-Interceptor (Web-Lander) umgeleitet. Bei
|
||||||
|
// Mahnstufe 1/2 zeigt er einen Hinweis und leitet per
|
||||||
|
// <meta http-equiv="refresh"> zurück; ein Cookie lässt den Besucher
|
||||||
|
// danach über einen zusätzlichen Router direkt zum Original durch.
|
||||||
|
//
|
||||||
|
// Verstöße entfernen den betroffenen Router und werden als Violation
|
||||||
|
// gemeldet (für das Audit-Log); der Rest des Knotens bleibt aktiv.
|
||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sultan/pkg/license"
|
||||||
|
"sultan/pkg/store"
|
||||||
|
"sultan/pkg/traefik"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrInvalidInput: IDs ungültig oder Lizenz gehört zu einem anderen Mandanten.
|
||||||
|
ErrInvalidInput = errors.New("policy: ungültige Eingabe")
|
||||||
|
// ErrNoInterceptor: Eine Umleitung ist nötig, aber kein Interceptor konfiguriert.
|
||||||
|
ErrNoInterceptor = errors.New("policy: Interceptor-URL nicht konfiguriert")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stage ist die Durchsetzungsstufe für einen Mandanten bzw. Router.
|
||||||
|
type Stage string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StageNormal Stage = "normal"
|
||||||
|
StageWatermark Stage = "watermark" // Mahnstufe 1: Hinweisseite, kurze Weiterleitung zum Original
|
||||||
|
StageFiremark Stage = "firemark" // Mahnstufe 2: Hinweisseite, längere Weiterleitung zum Original
|
||||||
|
StagePaywall Stage = "paywall" // Mahnstufe 3 / Zahlung ausgesetzt: 402-Seite
|
||||||
|
StageLanding Stage = "landing" // abgelaufen: Informations-Landingpage
|
||||||
|
StageMaintenance Stage = "maintenance" // Dienst laut Healthcheck down: 503, Retry-After 60
|
||||||
|
StageBlocked Stage = "blocked" // Quarantäne: keine Routen
|
||||||
|
)
|
||||||
|
|
||||||
|
// InterceptorService ist der Name des gemeinsamen Interceptor-Services.
|
||||||
|
// Das Präfix "sultan__" ist reserviert (Mandanten-ID "sultan" ist verboten).
|
||||||
|
const InterceptorService = "sultan__interceptor"
|
||||||
|
|
||||||
|
// Header, mit denen der Interceptor seinen Auftrag erhält. Traefik setzt sie
|
||||||
|
// per customRequestHeaders und überschreibt damit Werte des Besuchers.
|
||||||
|
const (
|
||||||
|
HeaderStage = "X-Sultan-Stage"
|
||||||
|
HeaderTenant = "X-Sultan-Tenant"
|
||||||
|
HeaderTier = "X-Sultan-Tier" // z. B. für den Pro-Hinweis auf Community-Wartungsseiten
|
||||||
|
)
|
||||||
|
|
||||||
|
// AckCookie setzt der Lander, nachdem er den Mahnhinweis gezeigt hat; der
|
||||||
|
// Wert ist die Stufe. Ein bestätigter Hinweis gilt nicht für eine höhere
|
||||||
|
// Stufe. Das Cookie ist bewusst kein Sicherheitsmerkmal – wer es selbst
|
||||||
|
// setzt, überspringt nur den Hinweis, nicht die Paywall.
|
||||||
|
const AckCookie = "sultan_ack"
|
||||||
|
|
||||||
|
// ackStages sind die Stufen, die nach dem Hinweis zum Original durchlassen.
|
||||||
|
var ackStages = []Stage{StageWatermark, StageFiremark}
|
||||||
|
|
||||||
|
// DunningSchedule legt fest, ab wie vielen Tagen Zahlungsverzug welche
|
||||||
|
// Mahnstufe gilt. Der Nullwert entspricht den Standardwerten (8/25/32 Tage,
|
||||||
|
// angelehnt an Bank-Zahlungsfristen).
|
||||||
|
type DunningSchedule struct {
|
||||||
|
Level1, Level2, Level3 time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level liefert die Mahnstufe für eine seit overdueSince offene Zahlung
|
||||||
|
// (Nullwert: nichts offen).
|
||||||
|
func (d DunningSchedule) Level(overdueSince, now time.Time) int {
|
||||||
|
if overdueSince.IsZero() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
day := 24 * time.Hour
|
||||||
|
l1, l2, l3 := orDefault(d.Level1, 8*day), orDefault(d.Level2, 25*day), orDefault(d.Level3, 32*day)
|
||||||
|
switch overdue := now.Sub(overdueSince); {
|
||||||
|
case overdue >= l3:
|
||||||
|
return 3
|
||||||
|
case overdue >= l2:
|
||||||
|
return 2
|
||||||
|
case overdue >= l1:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func orDefault(d, def time.Duration) time.Duration {
|
||||||
|
if d > 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant ist der für die Policy relevante Mandantenzustand.
|
||||||
|
type Tenant struct {
|
||||||
|
ID string
|
||||||
|
Status store.TenantStatus
|
||||||
|
// DunningLevel ist die Mahnstufe (nur kommerzielle Tiers): 0 keine,
|
||||||
|
// 1 Wasserzeichen, 2 Firemark, ≥3 Paywall.
|
||||||
|
DunningLevel int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input beschreibt einen Knoten zum Zeitpunkt der Aggregation.
|
||||||
|
type Input struct {
|
||||||
|
Tenant Tenant
|
||||||
|
License license.License // bereits per license.Parse geprüft
|
||||||
|
NodeID string
|
||||||
|
NodeDrained bool
|
||||||
|
Config *traefik.Configuration // aus traefik.FromLabels
|
||||||
|
// DownServices: Services (Originalnamen), die der Healthcheck des
|
||||||
|
// Agenten als ausgefallen meldet.
|
||||||
|
DownServices map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViolationKind klassifiziert einen Verstoß.
|
||||||
|
type ViolationKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ViolationDomain ViolationKind = "domain"
|
||||||
|
ViolationCatchAll ViolationKind = "catch_all"
|
||||||
|
ViolationHostRegexp ViolationKind = "host_regexp"
|
||||||
|
ViolationRuleSyntax ViolationKind = "rule_syntax"
|
||||||
|
ViolationRouteLimit ViolationKind = "route_limit"
|
||||||
|
ViolationExternalRef ViolationKind = "external_ref"
|
||||||
|
ViolationUnknownRef ViolationKind = "unknown_ref"
|
||||||
|
ViolationFeature ViolationKind = "feature"
|
||||||
|
ViolationForbiddenMiddleware ViolationKind = "forbidden_middleware"
|
||||||
|
ViolationMiddlewareTarget ViolationKind = "middleware_target"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Violation ist ein Verstoß eines Routers (Originalname).
|
||||||
|
type Violation struct {
|
||||||
|
Kind ViolationKind
|
||||||
|
Router string
|
||||||
|
Detail string
|
||||||
|
// Observed: nur beobachtet (Audit-Log), der Router bleibt aktiv.
|
||||||
|
Observed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result ist die bereinigte Konfiguration eines Knotens.
|
||||||
|
type Result struct {
|
||||||
|
Config *traefik.Configuration
|
||||||
|
Stage Stage
|
||||||
|
Violations []Violation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine wendet die Policy an.
|
||||||
|
type Engine struct {
|
||||||
|
// InterceptorURL ist die Adresse des Sultan-Interceptors (z. B.
|
||||||
|
// http://127.0.0.1:8081); Pflicht, sobald umgeleitet werden muss.
|
||||||
|
InterceptorURL string
|
||||||
|
// AllowedExternalRefs sind Referenzen auf andere Provider, die Mandanten
|
||||||
|
// nutzen dürfen (z. B. "auth@file"). Alles andere mit "@" wird abgelehnt.
|
||||||
|
AllowedExternalRefs []string
|
||||||
|
|
||||||
|
// MiddlewareTargetCheck ist die Andockstelle für die geplante
|
||||||
|
// Begrenzung von Middleware-Zielen (z. B. forwardAuth nur auf das eigene
|
||||||
|
// Backend). Sie wird für jede lokale Middleware eines Routers mit der
|
||||||
|
// Backend-URL seines Services aufgerufen (leer bei externem Service).
|
||||||
|
// nil = alles dulden.
|
||||||
|
MiddlewareTargetCheck func(mwName string, def traefik.Middleware, backend string) error
|
||||||
|
// EnforceMiddlewareTargets: true entfernt den Router bei einem Fehler
|
||||||
|
// von MiddlewareTargetCheck; false meldet ihn nur (Beobachtungsmodus).
|
||||||
|
EnforceMiddlewareTargets bool
|
||||||
|
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware-Typen, die ohne Feature custom_middleware erlaubt sind.
|
||||||
|
var basicMiddlewares = []string{
|
||||||
|
"redirectscheme", "redirectregex", "stripprefix", "stripprefixregex",
|
||||||
|
"addprefix", "replacepath", "replacepathregex", "compress", "headers",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware-Typen, die per Namen auf andere Middlewares/Services verweisen
|
||||||
|
// und so den Namensraum umgehen könnten – nie erlaubt.
|
||||||
|
var forbiddenMiddlewares = []string{"chain", "errors"}
|
||||||
|
|
||||||
|
// IDs: alphanumerisch mit - und _, nicht am Rand; "__" ist als Trenner reserviert.
|
||||||
|
var idRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9_-]*[A-Za-z0-9])?$`)
|
||||||
|
|
||||||
|
// ValidID meldet, ob id als Mandanten- oder Knoten-ID taugt (Namensraum-sicher).
|
||||||
|
func ValidID(id string) bool { return idRe.MatchString(id) && !strings.Contains(id, "__") }
|
||||||
|
|
||||||
|
// StageFor bestimmt die Stufe eines Mandanten. Reihenfolge: Quarantäne,
|
||||||
|
// Ablauf, dann (nur kommerziell) Zahlungsstatus und Mahnstufe.
|
||||||
|
func StageFor(t Tenant, l license.License, now time.Time) Stage {
|
||||||
|
switch {
|
||||||
|
case t.Status == store.TenantQuarantined:
|
||||||
|
return StageBlocked
|
||||||
|
case t.Status == store.TenantExpired || l.Expired(now):
|
||||||
|
return StageLanding
|
||||||
|
case l.Tier == license.TierCommunity:
|
||||||
|
return StageNormal // kein Mahnwesen im Community-Betrieb
|
||||||
|
case t.Status == store.TenantSuspendedPayment || t.DunningLevel >= 3:
|
||||||
|
return StagePaywall
|
||||||
|
case t.DunningLevel == 2:
|
||||||
|
return StageFiremark
|
||||||
|
case t.DunningLevel == 1:
|
||||||
|
return StageWatermark
|
||||||
|
}
|
||||||
|
return StageNormal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply wendet die Policy auf einen Knoten an. Die Eingabe wird nicht
|
||||||
|
// verändert.
|
||||||
|
func (e *Engine) Apply(in Input) (Result, error) {
|
||||||
|
if err := validateInput(in); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
now := time.Now
|
||||||
|
if e.now != nil {
|
||||||
|
now = e.now
|
||||||
|
}
|
||||||
|
stage := StageFor(in.Tenant, in.License, now())
|
||||||
|
res := Result{Config: &traefik.Configuration{}, Stage: stage}
|
||||||
|
if stage == StageBlocked || in.NodeDrained || in.Config == nil || in.Config.HTTP == nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
src := in.Config.HTTP
|
||||||
|
|
||||||
|
// 1. Router einzeln prüfen (sortiert, damit Ergebnis und Routenlimit
|
||||||
|
// deterministisch sind).
|
||||||
|
var accepted []string
|
||||||
|
for _, name := range sortedKeys(src.Routers) {
|
||||||
|
blocked, observed := e.checkRouter(name, src.Routers[name], src, in.License)
|
||||||
|
res.Violations = append(res.Violations, observed...)
|
||||||
|
if blocked != nil {
|
||||||
|
res.Violations = append(res.Violations, *blocked)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
accepted = append(accepted, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Routenlimit: überzählige Router (nach Namen) verwerfen.
|
||||||
|
if !in.License.AllowsRoutes(len(accepted)) {
|
||||||
|
for _, name := range accepted[in.License.MaxRoutes:] {
|
||||||
|
res.Violations = append(res.Violations, Violation{Kind: ViolationRouteLimit, Router: name,
|
||||||
|
Detail: fmt.Sprintf("max_routes = %d", in.License.MaxRoutes)})
|
||||||
|
}
|
||||||
|
accepted = accepted[:in.License.MaxRoutes]
|
||||||
|
}
|
||||||
|
if len(accepted) == 0 {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Ausgabe mit Namensraum und ggf. Umleitung zum Interceptor.
|
||||||
|
prefix := in.Tenant.ID + "__" + in.NodeID + "__"
|
||||||
|
out := &traefik.HTTPConfiguration{
|
||||||
|
Routers: map[string]*traefik.Router{},
|
||||||
|
Services: map[string]*traefik.Service{},
|
||||||
|
Middlewares: map[string]traefik.Middleware{},
|
||||||
|
}
|
||||||
|
for _, name := range accepted {
|
||||||
|
r := src.Routers[name]
|
||||||
|
nr := *r
|
||||||
|
nr.EntryPoints = slices.Clone(r.EntryPoints)
|
||||||
|
nr.Middlewares = nil
|
||||||
|
if r.TLS != nil {
|
||||||
|
tlsCopy := *r.TLS
|
||||||
|
nr.TLS = &tlsCopy
|
||||||
|
}
|
||||||
|
for _, mw := range r.Middlewares {
|
||||||
|
if isExternal(mw) {
|
||||||
|
nr.Middlewares = append(nr.Middlewares, mw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nr.Middlewares = append(nr.Middlewares, prefix+mw)
|
||||||
|
out.Middlewares[prefix+mw] = copyMiddleware(src.Middlewares[mw])
|
||||||
|
}
|
||||||
|
if isExternal(r.Service) {
|
||||||
|
nr.Service = r.Service
|
||||||
|
} else {
|
||||||
|
nr.Service = prefix + r.Service
|
||||||
|
out.Services[nr.Service] = copyService(src.Services[r.Service])
|
||||||
|
}
|
||||||
|
|
||||||
|
routerStage := stage
|
||||||
|
if routerStage == StageNormal && in.DownServices[r.Service] {
|
||||||
|
routerStage = StageMaintenance
|
||||||
|
}
|
||||||
|
if routerStage != StageNormal {
|
||||||
|
if e.InterceptorURL == "" {
|
||||||
|
return Result{}, ErrNoInterceptor
|
||||||
|
}
|
||||||
|
if slices.Contains(ackStages, routerStage) {
|
||||||
|
// Durchlass nach bestätigtem Hinweis: gleiche Regel + Cookie,
|
||||||
|
// direkt zum Original. Ohne gesetzte Priorität gewinnt er
|
||||||
|
// über die längere Regel, sonst über Priorität+1.
|
||||||
|
ack := nr
|
||||||
|
ack.Middlewares = slices.Clone(nr.Middlewares)
|
||||||
|
ack.EntryPoints = slices.Clone(nr.EntryPoints)
|
||||||
|
if nr.TLS != nil {
|
||||||
|
tlsCopy := *nr.TLS
|
||||||
|
ack.TLS = &tlsCopy
|
||||||
|
}
|
||||||
|
ack.Rule = fmt.Sprintf("(%s) && HeaderRegexp(`Cookie`, `(^|;\\s*)%s=%s(;|$)`)", r.Rule, AckCookie, routerStage)
|
||||||
|
if nr.Priority > 0 {
|
||||||
|
ack.Priority = nr.Priority + 1
|
||||||
|
}
|
||||||
|
out.Routers["sultan__"+prefix+name] = &ack
|
||||||
|
}
|
||||||
|
headers := map[string]any{
|
||||||
|
HeaderStage: string(routerStage),
|
||||||
|
HeaderTenant: in.Tenant.ID,
|
||||||
|
HeaderTier: string(in.License.Tier),
|
||||||
|
}
|
||||||
|
mwName := "sultan__" + prefix + name
|
||||||
|
out.Middlewares[mwName] = traefik.Middleware{"headers": map[string]any{"customRequestHeaders": headers}}
|
||||||
|
nr.Middlewares = append(slices.Clone(nr.Middlewares), mwName)
|
||||||
|
nr.Service = InterceptorService
|
||||||
|
out.Services[InterceptorService] = &traefik.Service{LoadBalancer: &traefik.ServersLoadBalancer{
|
||||||
|
Servers: []traefik.Server{{URL: e.InterceptorURL}},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
out.Routers[prefix+name] = &nr
|
||||||
|
}
|
||||||
|
if len(out.Middlewares) == 0 {
|
||||||
|
out.Middlewares = nil
|
||||||
|
}
|
||||||
|
res.Config = &traefik.Configuration{HTTP: out}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateInput(in Input) error {
|
||||||
|
switch {
|
||||||
|
case !ValidID(in.Tenant.ID) || in.Tenant.ID == "sultan":
|
||||||
|
return fmt.Errorf("%w: Mandanten-ID %q", ErrInvalidInput, in.Tenant.ID)
|
||||||
|
case !ValidID(in.NodeID):
|
||||||
|
return fmt.Errorf("%w: Knoten-ID %q", ErrInvalidInput, in.NodeID)
|
||||||
|
case in.License.TenantID != in.Tenant.ID:
|
||||||
|
return fmt.Errorf("%w: Lizenz gehört zu %q, nicht %q", ErrInvalidInput, in.License.TenantID, in.Tenant.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRouter prüft einen Router. blocked != nil entfernt ihn; observed sind
|
||||||
|
// nur gemeldete Verstöße (Beobachtungsmodus).
|
||||||
|
func (e *Engine) checkRouter(name string, r *traefik.Router, src *traefik.HTTPConfiguration, l license.License) (blocked *Violation, observed []Violation) {
|
||||||
|
v, observed := e.checkRouterRules(name, r, src, l)
|
||||||
|
if v != nil {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
if e.MiddlewareTargetCheck == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var backend string
|
||||||
|
if svc := src.Services[r.Service]; svc != nil && svc.LoadBalancer != nil && len(svc.LoadBalancer.Servers) > 0 {
|
||||||
|
backend = svc.LoadBalancer.Servers[0].URL
|
||||||
|
}
|
||||||
|
local := slices.DeleteFunc(slices.Clone(r.Middlewares), isExternal)
|
||||||
|
slices.Sort(local)
|
||||||
|
for _, mw := range local {
|
||||||
|
if err := e.MiddlewareTargetCheck(mw, src.Middlewares[mw], backend); err != nil {
|
||||||
|
viol := Violation{Kind: ViolationMiddlewareTarget, Router: name, Detail: fmt.Sprintf("Middleware %q: %v", mw, err)}
|
||||||
|
if e.EnforceMiddlewareTargets {
|
||||||
|
return &viol, nil
|
||||||
|
}
|
||||||
|
viol.Observed = true
|
||||||
|
observed = append(observed, viol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, observed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) checkRouterRules(name string, r *traefik.Router, src *traefik.HTTPConfiguration, l license.License) (*Violation, []Violation) {
|
||||||
|
v := func(kind ViolationKind, format string, args ...any) *Violation {
|
||||||
|
return &Violation{Kind: kind, Router: name, Detail: fmt.Sprintf(format, args...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts, err := HostsFromRule(r.Rule)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrUnconstrained):
|
||||||
|
return v(ViolationCatchAll, "Regel %q nicht auf Hosts beschränkt", r.Rule), nil
|
||||||
|
case errors.Is(err, ErrHostRegexp):
|
||||||
|
return v(ViolationHostRegexp, "Regel %q", r.Rule), nil
|
||||||
|
case err != nil:
|
||||||
|
return v(ViolationRuleSyntax, "%v", err), nil
|
||||||
|
}
|
||||||
|
for _, h := range hosts {
|
||||||
|
if !l.AllowsDomain(h) {
|
||||||
|
return v(ViolationDomain, "Host %q nicht in allowed_domains", h), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isExternal(r.Service) {
|
||||||
|
if !slices.Contains(e.AllowedExternalRefs, r.Service) {
|
||||||
|
return v(ViolationExternalRef, "Service %q", r.Service), nil
|
||||||
|
}
|
||||||
|
} else if src.Services[r.Service] == nil {
|
||||||
|
return v(ViolationUnknownRef, "Service %q nicht auf diesem Knoten definiert", r.Service), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mw := range r.Middlewares {
|
||||||
|
if isExternal(mw) {
|
||||||
|
if !slices.Contains(e.AllowedExternalRefs, mw) {
|
||||||
|
return v(ViolationExternalRef, "Middleware %q", mw), nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
def, ok := src.Middlewares[mw]
|
||||||
|
if !ok {
|
||||||
|
return v(ViolationUnknownRef, "Middleware %q nicht auf diesem Knoten definiert", mw), nil
|
||||||
|
}
|
||||||
|
for typ := range def {
|
||||||
|
t := strings.ToLower(typ)
|
||||||
|
switch {
|
||||||
|
case slices.Contains(forbiddenMiddlewares, t):
|
||||||
|
return v(ViolationForbiddenMiddleware, "Middleware %q vom Typ %s", mw, typ), nil
|
||||||
|
case !slices.Contains(basicMiddlewares, t) && !l.HasFeature(license.FeatureCustomMiddleware):
|
||||||
|
return v(ViolationFeature, "Middleware %q vom Typ %s erfordert %s", mw, typ, license.FeatureCustomMiddleware), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExternal(ref string) bool { return strings.Contains(ref, "@") }
|
||||||
|
|
||||||
|
func sortedKeys[V any](m map[string]V) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
slices.Sort(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyService(s *traefik.Service) *traefik.Service {
|
||||||
|
c := &traefik.Service{}
|
||||||
|
if s.LoadBalancer != nil {
|
||||||
|
lb := *s.LoadBalancer
|
||||||
|
lb.Servers = slices.Clone(s.LoadBalancer.Servers)
|
||||||
|
if s.LoadBalancer.PassHostHeader != nil {
|
||||||
|
v := *s.LoadBalancer.PassHostHeader
|
||||||
|
lb.PassHostHeader = &v
|
||||||
|
}
|
||||||
|
c.LoadBalancer = &lb
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyMiddleware(m traefik.Middleware) traefik.Middleware {
|
||||||
|
return traefik.Middleware(copyMap(map[string]any(m)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyMap(m map[string]any) map[string]any {
|
||||||
|
out := make(map[string]any, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
if sub, ok := v.(map[string]any); ok {
|
||||||
|
out[k] = copyMap(sub)
|
||||||
|
} else {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sultan/pkg/license"
|
||||||
|
"sultan/pkg/store"
|
||||||
|
"sultan/pkg/traefik"
|
||||||
|
)
|
||||||
|
|
||||||
|
var now = time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
const interceptor = "http://127.0.0.1:8081"
|
||||||
|
|
||||||
|
func engine() *Engine {
|
||||||
|
return &Engine{InterceptorURL: interceptor, now: func() time.Time { return now }}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lic(tier license.Tier, maxRoutes int, features ...license.Feature) license.License {
|
||||||
|
return license.License{
|
||||||
|
TenantID: "kunde",
|
||||||
|
Tier: tier,
|
||||||
|
MaxRoutes: maxRoutes,
|
||||||
|
AllowedDomains: []string{"kunde.de", "*.kunde.de"},
|
||||||
|
Features: features,
|
||||||
|
ValidUntil: now.AddDate(0, 3, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cfg baut eine Knoten-Konfiguration aus Labels – wie sie der Server vom
|
||||||
|
// Agenten bekommt.
|
||||||
|
func cfg(t *testing.T, labels map[string]string) *traefik.Configuration {
|
||||||
|
t.Helper()
|
||||||
|
labels["traefik.enable"] = "true"
|
||||||
|
c, err := traefik.FromLabels("100.64.0.42", labels)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FromLabels: %v", err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func wpLabels() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"traefik.http.routers.wp.rule": "Host(`kunde.de`) || Host(`www.kunde.de`)",
|
||||||
|
"traefik.http.routers.wp.entrypoints": "websecure",
|
||||||
|
"traefik.http.routers.wp.tls.certresolver": "dns-resolver",
|
||||||
|
"traefik.http.routers.wp.middlewares": "wp-www",
|
||||||
|
"traefik.http.middlewares.wp-www.redirectregex.regex": `^https?://www\.kunde\.de/(.*)`,
|
||||||
|
"traefik.http.middlewares.wp-www.redirectregex.replacement": "https://kunde.de/${1}",
|
||||||
|
"traefik.http.services.wp.loadbalancer.server.port": "80",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func input(t *testing.T, labels map[string]string) Input {
|
||||||
|
return Input{
|
||||||
|
Tenant: Tenant{ID: "kunde", Status: store.TenantActive},
|
||||||
|
License: lic(license.TierPro, 10),
|
||||||
|
NodeID: "node-1",
|
||||||
|
Config: cfg(t, labels),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func kinds(vs []Violation) []ViolationKind {
|
||||||
|
var out []ViolationKind
|
||||||
|
for _, v := range vs {
|
||||||
|
out = append(out, v.Kind)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_NormalNamespacesEverything(t *testing.T) {
|
||||||
|
in := input(t, wpLabels())
|
||||||
|
res, err := engine().Apply(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Apply: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Violations) != 0 || res.Stage != StageNormal {
|
||||||
|
t.Errorf("Violations=%v Stage=%q", res.Violations, res.Stage)
|
||||||
|
}
|
||||||
|
want := &traefik.Configuration{HTTP: &traefik.HTTPConfiguration{
|
||||||
|
Routers: map[string]*traefik.Router{
|
||||||
|
"kunde__node-1__wp": {
|
||||||
|
EntryPoints: []string{"websecure"},
|
||||||
|
Middlewares: []string{"kunde__node-1__wp-www"},
|
||||||
|
Service: "kunde__node-1__wp",
|
||||||
|
Rule: "Host(`kunde.de`) || Host(`www.kunde.de`)",
|
||||||
|
TLS: &traefik.RouterTLS{CertResolver: "dns-resolver"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Services: map[string]*traefik.Service{
|
||||||
|
"kunde__node-1__wp": {LoadBalancer: &traefik.ServersLoadBalancer{Servers: []traefik.Server{{URL: "http://100.64.0.42:80"}}}},
|
||||||
|
},
|
||||||
|
Middlewares: map[string]traefik.Middleware{
|
||||||
|
"kunde__node-1__wp-www": in.Config.HTTP.Middlewares["wp-www"],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
if !reflect.DeepEqual(res.Config, want) {
|
||||||
|
t.Errorf("Konfiguration weicht ab\n got: %+v\nwant: %+v", res.Config.HTTP, want.HTTP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_DoesNotMutateInput(t *testing.T) {
|
||||||
|
in := input(t, wpLabels())
|
||||||
|
before := cfg(t, wpLabels())
|
||||||
|
if _, err := engine().Apply(in); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(in.Config, before) {
|
||||||
|
t.Error("Eingabe-Konfiguration wurde verändert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_TwoTenantsSameNamesMergeWithoutConflict(t *testing.T) {
|
||||||
|
a := input(t, wpLabels())
|
||||||
|
b := input(t, wpLabels())
|
||||||
|
b.Tenant.ID, b.License.TenantID, b.NodeID = "andere", "andere", "node-1"
|
||||||
|
b.License.AllowedDomains = []string{"kunde.de", "*.kunde.de"}
|
||||||
|
|
||||||
|
ra, _ := engine().Apply(a)
|
||||||
|
rb, _ := engine().Apply(b)
|
||||||
|
if _, err := traefik.Merge(ra.Config, rb.Config); err != nil {
|
||||||
|
t.Errorf("Merge nach Namensraum-Präfix: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_DomainViolation(t *testing.T) {
|
||||||
|
labels := wpLabels()
|
||||||
|
labels["traefik.http.routers.bank.rule"] = "Host(`bank.de`)"
|
||||||
|
labels["traefik.http.routers.bank.service"] = "wp"
|
||||||
|
res, err := engine().Apply(input(t, labels))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := res.Config.HTTP.Routers["kunde__node-1__bank"]; ok {
|
||||||
|
t.Error("Router mit fremder Domain wurde übernommen")
|
||||||
|
}
|
||||||
|
if _, ok := res.Config.HTTP.Routers["kunde__node-1__wp"]; !ok {
|
||||||
|
t.Error("erlaubter Router fehlt")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationDomain}) || res.Violations[0].Router != "bank" {
|
||||||
|
t.Errorf("Violations = %+v", res.Violations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_RuleViolations(t *testing.T) {
|
||||||
|
tests := map[string]ViolationKind{
|
||||||
|
"Host(`kunde.de`) || PathPrefix(`/`)": ViolationCatchAll,
|
||||||
|
"PathPrefix(`/`)": ViolationCatchAll,
|
||||||
|
"HostRegexp(`.+`)": ViolationHostRegexp,
|
||||||
|
"Host(`kunde.de`": ViolationRuleSyntax,
|
||||||
|
"Host(`sub.kunde.de`) || Host(`x.de`)": ViolationDomain,
|
||||||
|
}
|
||||||
|
for rule, want := range tests {
|
||||||
|
labels := wpLabels()
|
||||||
|
labels["traefik.http.routers.wp.rule"] = rule
|
||||||
|
res, err := engine().Apply(input(t, labels))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{want}) {
|
||||||
|
t.Errorf("%s: Violations = %+v, erwartet %s", rule, res.Violations, want)
|
||||||
|
}
|
||||||
|
if res.Config.HTTP != nil {
|
||||||
|
t.Errorf("%s: Router trotz Verstoß übernommen: %+v", rule, res.Config.HTTP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_RouteLimit(t *testing.T) {
|
||||||
|
labels := map[string]string{"traefik.http.services.s.loadbalancer.server.port": "80"}
|
||||||
|
for _, r := range []string{"c", "a", "b"} {
|
||||||
|
labels["traefik.http.routers."+r+".rule"] = "Host(`" + r + ".kunde.de`)"
|
||||||
|
}
|
||||||
|
in := input(t, labels)
|
||||||
|
in.License.MaxRoutes = 2
|
||||||
|
res, err := engine().Apply(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(res.Config.HTTP.Routers) != 2 || res.Config.HTTP.Routers["kunde__node-1__c"] != nil {
|
||||||
|
t.Errorf("erwartet a und b (sortiert), bekommen %v", keys(res.Config.HTTP.Routers))
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationRouteLimit}) || res.Violations[0].Router != "c" {
|
||||||
|
t.Errorf("Violations = %+v", res.Violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
in.License.MaxRoutes = -1
|
||||||
|
res, _ = engine().Apply(in)
|
||||||
|
if len(res.Config.HTTP.Routers) != 3 || len(res.Violations) != 0 {
|
||||||
|
t.Errorf("unbegrenzt: %d Router, %v", len(res.Config.HTTP.Routers), res.Violations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_ExternalReferences(t *testing.T) {
|
||||||
|
labels := wpLabels()
|
||||||
|
labels["traefik.http.routers.dash.rule"] = "Host(`dash.kunde.de`)"
|
||||||
|
labels["traefik.http.routers.dash.service"] = "api@internal"
|
||||||
|
labels["traefik.http.routers.wp.middlewares"] = "wp-www,auth@file"
|
||||||
|
|
||||||
|
res, err := engine().Apply(input(t, labels))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Config.HTTP != nil {
|
||||||
|
t.Errorf("Router mit externen Referenzen übernommen: %v", keys(res.Config.HTTP.Routers))
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationExternalRef, ViolationExternalRef}) {
|
||||||
|
t.Errorf("Violations = %+v", res.Violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
e := engine()
|
||||||
|
e.AllowedExternalRefs = []string{"auth@file"}
|
||||||
|
res, _ = e.Apply(input(t, labels))
|
||||||
|
r := res.Config.HTTP.Routers["kunde__node-1__wp"]
|
||||||
|
if r == nil || !reflect.DeepEqual(r.Middlewares, []string{"kunde__node-1__wp-www", "auth@file"}) {
|
||||||
|
t.Errorf("freigegebene Referenz: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_UnknownLocalReferences(t *testing.T) {
|
||||||
|
labels := wpLabels()
|
||||||
|
labels["traefik.http.routers.x.rule"] = "Host(`x.kunde.de`)"
|
||||||
|
labels["traefik.http.routers.x.service"] = "andere__node-9__shop" // fremder Service per vollem Namen
|
||||||
|
labels["traefik.http.routers.y.rule"] = "Host(`y.kunde.de`)"
|
||||||
|
labels["traefik.http.routers.y.service"] = "wp"
|
||||||
|
labels["traefik.http.routers.y.middlewares"] = "gibtsnicht"
|
||||||
|
|
||||||
|
res, err := engine().Apply(input(t, labels))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationUnknownRef, ViolationUnknownRef}) {
|
||||||
|
t.Errorf("Violations = %+v", res.Violations)
|
||||||
|
}
|
||||||
|
if len(res.Config.HTTP.Routers) != 1 {
|
||||||
|
t.Errorf("Router: %v", keys(res.Config.HTTP.Routers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_MiddlewareFeatureGate(t *testing.T) {
|
||||||
|
labels := wpLabels()
|
||||||
|
labels["traefik.http.middlewares.auth.basicauth.users"] = "u:$apr1$x"
|
||||||
|
labels["traefik.http.routers.wp.middlewares"] = "wp-www,auth"
|
||||||
|
|
||||||
|
res, _ := engine().Apply(input(t, labels))
|
||||||
|
if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationFeature}) || res.Config.HTTP != nil {
|
||||||
|
t.Errorf("ohne custom_middleware: Violations=%+v", res.Violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
in := input(t, labels)
|
||||||
|
in.License = lic(license.TierPro, 10, license.FeatureCustomMiddleware)
|
||||||
|
res, _ = engine().Apply(in)
|
||||||
|
if len(res.Violations) != 0 || res.Config.HTTP.Middlewares["kunde__node-1__auth"] == nil {
|
||||||
|
t.Errorf("mit custom_middleware: Violations=%+v", res.Violations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// chain und errors verweisen per Namen auf andere Middlewares/Services und
|
||||||
|
// würden den Namensraum umgehen – nie erlaubt.
|
||||||
|
func TestApply_ForbiddenMiddlewares(t *testing.T) {
|
||||||
|
for _, typ := range []string{"chain.middlewares", "errors.service"} {
|
||||||
|
labels := wpLabels()
|
||||||
|
labels["traefik.http.middlewares.m."+typ] = "andere__node-9__x"
|
||||||
|
labels["traefik.http.routers.wp.middlewares"] = "m"
|
||||||
|
in := input(t, labels)
|
||||||
|
in.License = lic(license.TierEnterprise, -1, license.FeatureCustomMiddleware)
|
||||||
|
res, _ := engine().Apply(in)
|
||||||
|
if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationForbiddenMiddleware}) || res.Config.HTTP != nil {
|
||||||
|
t.Errorf("%s: Violations=%+v", typ, res.Violations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_PrunesUnreferenced(t *testing.T) {
|
||||||
|
labels := wpLabels()
|
||||||
|
labels["traefik.http.middlewares.unused.compress"] = "true"
|
||||||
|
labels["traefik.http.services.unused.loadbalancer.server.port"] = "81"
|
||||||
|
labels["traefik.http.routers.wp.service"] = "wp"
|
||||||
|
res, _ := engine().Apply(input(t, labels))
|
||||||
|
if res.Config.HTTP.Middlewares["kunde__node-1__unused"] != nil || res.Config.HTTP.Services["kunde__node-1__unused"] != nil {
|
||||||
|
t.Error("nicht referenzierte Definitionen wurden übernommen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStageFor(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tier license.Tier
|
||||||
|
status store.TenantStatus
|
||||||
|
level int
|
||||||
|
expire bool
|
||||||
|
want Stage
|
||||||
|
}{
|
||||||
|
{"Community aktiv", license.TierCommunity, store.TenantActive, 0, false, StageNormal},
|
||||||
|
{"Community ohne Mahnwesen", license.TierCommunity, store.TenantActive, 2, false, StageNormal},
|
||||||
|
{"Community abgelaufen", license.TierCommunity, store.TenantActive, 0, true, StageLanding},
|
||||||
|
{"Pro aktiv", license.TierPro, store.TenantActive, 0, false, StageNormal},
|
||||||
|
{"Mahnstufe 1", license.TierPro, store.TenantActive, 1, false, StageWatermark},
|
||||||
|
{"Mahnstufe 2", license.TierPro, store.TenantActive, 2, false, StageFiremark},
|
||||||
|
{"Mahnstufe 3", license.TierEnterprise, store.TenantActive, 3, false, StagePaywall},
|
||||||
|
{"Mahnstufe 9", license.TierPro, store.TenantActive, 9, false, StagePaywall},
|
||||||
|
{"Zahlung ausgesetzt", license.TierPro, store.TenantSuspendedPayment, 0, false, StagePaywall},
|
||||||
|
{"Status abgelaufen", license.TierPro, store.TenantExpired, 0, false, StageLanding},
|
||||||
|
{"Lizenz abgelaufen", license.TierPro, store.TenantActive, 1, true, StageLanding},
|
||||||
|
{"Quarantäne", license.TierPro, store.TenantQuarantined, 0, false, StageBlocked},
|
||||||
|
{"Quarantäne Community", license.TierCommunity, store.TenantQuarantined, 0, false, StageBlocked},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
l := lic(tt.tier, 5)
|
||||||
|
if tt.expire {
|
||||||
|
l.ValidUntil = now
|
||||||
|
}
|
||||||
|
if got := StageFor(Tenant{ID: "kunde", Status: tt.status, DunningLevel: tt.level}, l, now); got != tt.want {
|
||||||
|
t.Errorf("%s: Stage = %q, erwartet %q", tt.name, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_Interception(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
stage Stage
|
||||||
|
level int
|
||||||
|
status store.TenantStatus
|
||||||
|
ack bool // Durchlass per Cookie nach Lander-Hinweis
|
||||||
|
}{
|
||||||
|
{StageWatermark, 1, store.TenantActive, true},
|
||||||
|
{StageFiremark, 2, store.TenantActive, true},
|
||||||
|
{StagePaywall, 0, store.TenantSuspendedPayment, false},
|
||||||
|
{StageLanding, 0, store.TenantExpired, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.stage), func(t *testing.T) {
|
||||||
|
in := input(t, wpLabels())
|
||||||
|
in.Tenant.Status, in.Tenant.DunningLevel = tt.status, tt.level
|
||||||
|
res, err := engine().Apply(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Stage != tt.stage {
|
||||||
|
t.Errorf("Stage = %q", res.Stage)
|
||||||
|
}
|
||||||
|
r := res.Config.HTTP.Routers["kunde__node-1__wp"]
|
||||||
|
if r == nil || r.Service != InterceptorService {
|
||||||
|
t.Fatalf("Router nicht zum Interceptor umgeleitet: %+v", r)
|
||||||
|
}
|
||||||
|
if r.Rule != "Host(`kunde.de`) || Host(`www.kunde.de`)" || r.TLS == nil {
|
||||||
|
t.Error("Regel/TLS dürfen sich nicht ändern")
|
||||||
|
}
|
||||||
|
mw := "sultan__kunde__node-1__wp"
|
||||||
|
if !reflect.DeepEqual(r.Middlewares, []string{"kunde__node-1__wp-www", mw}) {
|
||||||
|
t.Errorf("Middlewares = %v", r.Middlewares)
|
||||||
|
}
|
||||||
|
hdr := res.Config.HTTP.Middlewares[mw]["headers"].(map[string]any)["customRequestHeaders"].(map[string]any)
|
||||||
|
want := map[string]any{HeaderStage: string(tt.stage), HeaderTenant: "kunde", HeaderTier: "pro"}
|
||||||
|
if !reflect.DeepEqual(hdr, want) {
|
||||||
|
t.Errorf("Header = %v, erwartet %v", hdr, want)
|
||||||
|
}
|
||||||
|
svc := res.Config.HTTP.Services[InterceptorService]
|
||||||
|
if svc == nil || svc.LoadBalancer.Servers[0].URL != interceptor {
|
||||||
|
t.Errorf("Interceptor-Service fehlt: %+v", svc)
|
||||||
|
}
|
||||||
|
|
||||||
|
ack := res.Config.HTTP.Routers["sultan__kunde__node-1__wp"]
|
||||||
|
if !tt.ack {
|
||||||
|
if ack != nil {
|
||||||
|
t.Errorf("Stufe %s darf keinen Durchlass haben: %+v", tt.stage, ack)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ack == nil {
|
||||||
|
t.Fatal("Durchlass-Router fehlt")
|
||||||
|
}
|
||||||
|
wantRule := "(Host(`kunde.de`) || Host(`www.kunde.de`)) && HeaderRegexp(`Cookie`, `(^|;\\s*)sultan_ack=" + string(tt.stage) + "(;|$)`)"
|
||||||
|
if ack.Rule != wantRule {
|
||||||
|
t.Errorf("Durchlass-Regel = %s\nerwartet %s", ack.Rule, wantRule)
|
||||||
|
}
|
||||||
|
if ack.Service != "kunde__node-1__wp" || !reflect.DeepEqual(ack.Middlewares, []string{"kunde__node-1__wp-www"}) {
|
||||||
|
t.Errorf("Durchlass-Router: %+v", ack)
|
||||||
|
}
|
||||||
|
if ack.TLS == nil || !reflect.DeepEqual(ack.EntryPoints, r.EntryPoints) {
|
||||||
|
t.Error("Durchlass-Router muss TLS/EntryPoints übernehmen")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hat der Mandant eine Priorität gesetzt, muss der Durchlass darüber liegen;
|
||||||
|
// ohne Priorität gewinnt er in Traefik ohnehin über die längere Regel.
|
||||||
|
func TestApply_AckPriority(t *testing.T) {
|
||||||
|
for _, prio := range []int{0, 50} {
|
||||||
|
labels := wpLabels()
|
||||||
|
if prio > 0 {
|
||||||
|
labels["traefik.http.routers.wp.priority"] = "50"
|
||||||
|
}
|
||||||
|
in := input(t, labels)
|
||||||
|
in.Tenant.DunningLevel = 1
|
||||||
|
res, _ := engine().Apply(in)
|
||||||
|
r := res.Config.HTTP.Routers["kunde__node-1__wp"]
|
||||||
|
ack := res.Config.HTTP.Routers["sultan__kunde__node-1__wp"]
|
||||||
|
wantAck := 0
|
||||||
|
if prio > 0 {
|
||||||
|
wantAck = prio + 1
|
||||||
|
}
|
||||||
|
if r.Priority != prio || ack.Priority != wantAck {
|
||||||
|
t.Errorf("prio=%d: Router %d, Durchlass %d", prio, r.Priority, ack.Priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDunningLevel(t *testing.T) {
|
||||||
|
due := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
day := 24 * time.Hour
|
||||||
|
tests := []struct {
|
||||||
|
now time.Time
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{due.Add(-day), 0},
|
||||||
|
{due, 0},
|
||||||
|
{due.Add(8*day - time.Second), 0},
|
||||||
|
{due.Add(8 * day), 1},
|
||||||
|
{due.Add(25*day - time.Second), 1},
|
||||||
|
{due.Add(25 * day), 2},
|
||||||
|
{due.Add(32 * day), 3},
|
||||||
|
{due.Add(365 * day), 3},
|
||||||
|
}
|
||||||
|
var sched DunningSchedule // Nullwert = Standard 8/25/32 Tage
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := sched.Level(due, tt.now); got != tt.want {
|
||||||
|
t.Errorf("%v überfällig: Stufe %d, erwartet %d", tt.now.Sub(due), got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := sched.Level(time.Time{}, due); got != 0 {
|
||||||
|
t.Errorf("ohne Fälligkeit: Stufe %d", got)
|
||||||
|
}
|
||||||
|
custom := DunningSchedule{Level1: day, Level2: 2 * day, Level3: 3 * day}
|
||||||
|
if got := custom.Level(due, due.Add(2*day)); got != 2 {
|
||||||
|
t.Errorf("eigener Zeitplan: Stufe %d, erwartet 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_MaintenanceForDownService(t *testing.T) {
|
||||||
|
labels := wpLabels()
|
||||||
|
labels["traefik.http.routers.shop.rule"] = "Host(`shop.kunde.de`)"
|
||||||
|
labels["traefik.http.routers.shop.service"] = "shop"
|
||||||
|
labels["traefik.http.routers.wp.service"] = "wp"
|
||||||
|
labels["traefik.http.services.shop.loadbalancer.server.port"] = "8080"
|
||||||
|
in := input(t, labels)
|
||||||
|
in.DownServices = map[string]bool{"shop": true}
|
||||||
|
|
||||||
|
res, err := engine().Apply(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Stage != StageNormal {
|
||||||
|
t.Errorf("Mandanten-Stage = %q, Wartung betrifft nur den Router", res.Stage)
|
||||||
|
}
|
||||||
|
if res.Config.HTTP.Routers["kunde__node-1__shop"].Service != InterceptorService {
|
||||||
|
t.Error("ausgefallener Dienst nicht zum Interceptor umgeleitet")
|
||||||
|
}
|
||||||
|
if res.Config.HTTP.Routers["kunde__node-1__wp"].Service != "kunde__node-1__wp" {
|
||||||
|
t.Error("gesunder Dienst wurde umgeleitet")
|
||||||
|
}
|
||||||
|
hdr := res.Config.HTTP.Middlewares["sultan__kunde__node-1__shop"]["headers"].(map[string]any)["customRequestHeaders"].(map[string]any)
|
||||||
|
if hdr[HeaderStage] != string(StageMaintenance) || hdr[HeaderTier] != "pro" {
|
||||||
|
t.Errorf("Header = %v", hdr)
|
||||||
|
}
|
||||||
|
if res.Config.HTTP.Routers["sultan__kunde__node-1__shop"] != nil {
|
||||||
|
t.Error("Wartung darf keinen Cookie-Durchlass haben")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_BlockedAndDrainedProduceNothing(t *testing.T) {
|
||||||
|
blocked := input(t, wpLabels())
|
||||||
|
blocked.Tenant.Status = store.TenantQuarantined
|
||||||
|
drained := input(t, wpLabels())
|
||||||
|
drained.NodeDrained = true
|
||||||
|
for name, in := range map[string]Input{"Quarantäne": blocked, "gedraint": drained} {
|
||||||
|
res, err := engine().Apply(in)
|
||||||
|
if err != nil || res.Config == nil || res.Config.HTTP != nil {
|
||||||
|
t.Errorf("%s: res=%+v err=%v", name, res.Config, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_Errors(t *testing.T) {
|
||||||
|
needsInterceptor := input(t, wpLabels())
|
||||||
|
needsInterceptor.Tenant.DunningLevel = 1
|
||||||
|
if _, err := (&Engine{now: engine().now}).Apply(needsInterceptor); !errors.Is(err, ErrNoInterceptor) {
|
||||||
|
t.Errorf("ohne InterceptorURL: erwartet ErrNoInterceptor, bekommen %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := map[string]func(*Input){
|
||||||
|
"Lizenz eines anderen Mandanten": func(in *Input) { in.License.TenantID = "andere" },
|
||||||
|
"Mandanten-ID mit __": func(in *Input) { in.Tenant.ID, in.License.TenantID = "a__b", "a__b" },
|
||||||
|
"Mandanten-ID endet mit _": func(in *Input) { in.Tenant.ID, in.License.TenantID = "a_", "a_" },
|
||||||
|
"reservierte Mandanten-ID": func(in *Input) { in.Tenant.ID, in.License.TenantID = "sultan", "sultan" },
|
||||||
|
"Knoten-ID leer": func(in *Input) { in.NodeID = "" },
|
||||||
|
"Knoten-ID mit @": func(in *Input) { in.NodeID = "n@x" },
|
||||||
|
}
|
||||||
|
for name, mutate := range tests {
|
||||||
|
in := input(t, wpLabels())
|
||||||
|
mutate(&in)
|
||||||
|
if _, err := engine().Apply(in); !errors.Is(err, ErrInvalidInput) {
|
||||||
|
t.Errorf("%s: erwartet ErrInvalidInput, bekommen %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_NilConfig(t *testing.T) {
|
||||||
|
in := input(t, wpLabels())
|
||||||
|
in.Config = nil
|
||||||
|
res, err := engine().Apply(in)
|
||||||
|
if err != nil || res.Config == nil || res.Config.HTTP != nil {
|
||||||
|
t.Errorf("res=%+v err=%v", res.Config, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keys[V any](m map[string]V) []string {
|
||||||
|
var out []string
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Andockstelle für die geplante Middleware-Zielbegrenzung: ohne Prüffunktion
|
||||||
|
// wird alles geduldet; mit Prüffunktion beobachten oder durchsetzen.
|
||||||
|
func TestApply_MiddlewareTargetHook(t *testing.T) {
|
||||||
|
labels := wpLabels()
|
||||||
|
labels["traefik.http.middlewares.auth.forwardauth.address"] = "http://10.0.0.99:9000/auth"
|
||||||
|
labels["traefik.http.routers.wp.middlewares"] = "wp-www,auth"
|
||||||
|
in := input(t, labels)
|
||||||
|
in.License = lic(license.TierPro, 10, license.FeatureCustomMiddleware)
|
||||||
|
|
||||||
|
// Ohne Hook: dulden.
|
||||||
|
res, _ := engine().Apply(in)
|
||||||
|
if len(res.Violations) != 0 || res.Config.HTTP.Routers["kunde__node-1__wp"] == nil {
|
||||||
|
t.Fatalf("ohne Hook: %+v", res.Violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
var calls []string
|
||||||
|
hook := func(mwName string, def traefik.Middleware, backend string) error {
|
||||||
|
calls = append(calls, mwName+"→"+backend)
|
||||||
|
if _, ok := def["forwardauth"]; ok {
|
||||||
|
return errors.New("Ziel außerhalb des Backends")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
observe := engine()
|
||||||
|
observe.MiddlewareTargetCheck = hook
|
||||||
|
res, _ = observe.Apply(in)
|
||||||
|
if res.Config.HTTP.Routers["kunde__node-1__wp"] == nil {
|
||||||
|
t.Error("Beobachtungsmodus darf nicht sperren")
|
||||||
|
}
|
||||||
|
if len(res.Violations) != 1 || res.Violations[0].Kind != ViolationMiddlewareTarget || !res.Violations[0].Observed {
|
||||||
|
t.Errorf("Beobachtungsmodus: %+v", res.Violations)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(calls, []string{"auth→http://100.64.0.42:80", "wp-www→http://100.64.0.42:80"}) {
|
||||||
|
t.Errorf("Hook-Aufrufe = %v", calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
enforce := engine()
|
||||||
|
enforce.MiddlewareTargetCheck = hook
|
||||||
|
enforce.EnforceMiddlewareTargets = true
|
||||||
|
res, _ = enforce.Apply(in)
|
||||||
|
if res.Config.HTTP != nil {
|
||||||
|
t.Error("Durchsetzungsmodus: Router hätte entfernt werden müssen")
|
||||||
|
}
|
||||||
|
if len(res.Violations) != 1 || res.Violations[0].Kind != ViolationMiddlewareTarget || res.Violations[0].Observed {
|
||||||
|
t.Errorf("Durchsetzungsmodus: %+v", res.Violations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidID(t *testing.T) {
|
||||||
|
for id, want := range map[string]bool{
|
||||||
|
"kunde": true, "kunde_schmidt": true, "node-proxmox-vm-42": true, "A1": true,
|
||||||
|
"": false, "a__b": false, "_a": false, "a_": false, "-a": false, "a.b": false, "a@b": false, "a b": false,
|
||||||
|
} {
|
||||||
|
if got := ValidID(id); got != want {
|
||||||
|
t.Errorf("ValidID(%q) = %v, erwartet %v", id, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrSyntax: Die Traefik-Regel ist syntaktisch ungültig.
|
||||||
|
ErrSyntax = errors.New("policy: Regel-Syntax ungültig")
|
||||||
|
// ErrUnconstrained: Die Regel ist nicht in jedem Zweig auf eigene Hosts
|
||||||
|
// beschränkt und würde fremden Verkehr abgreifen (Catch-all).
|
||||||
|
ErrUnconstrained = errors.New("policy: Regel nicht auf Hosts beschränkt")
|
||||||
|
// ErrHostRegexp: HostRegexp lässt sich nicht gegen allowed_domains prüfen.
|
||||||
|
ErrHostRegexp = errors.New("policy: HostRegexp nicht erlaubt")
|
||||||
|
)
|
||||||
|
|
||||||
|
// HostsFromRule parst eine Traefik-Regel (Matcher, &&, ||, !, Klammern)
|
||||||
|
// und liefert alle positiv geforderten Hosts in Reihenfolge des Auftretens.
|
||||||
|
//
|
||||||
|
// Die Regel muss in jedem ||-Zweig durch Host(...) eingeschränkt sein:
|
||||||
|
// "Host(`a.de`) || PathPrefix(`/`)" passt sonst auf jeden Host. Negierte
|
||||||
|
// Hosts schränken nicht ein (sie schließen nur aus). Positives HostRegexp
|
||||||
|
// ist nicht erlaubt, weil sich ein Regex nicht gegen die Lizenz prüfen lässt.
|
||||||
|
func HostsFromRule(rule string) ([]string, error) {
|
||||||
|
toks, err := lex(rule)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p := &parser{toks: toks}
|
||||||
|
root, err := p.parseOr()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.peek().kind != tEOF {
|
||||||
|
return nil, p.errorf("unerwartetes %q", p.peek().text)
|
||||||
|
}
|
||||||
|
constrained, hosts, err := analyze(root, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !constrained {
|
||||||
|
return nil, ErrUnconstrained
|
||||||
|
}
|
||||||
|
return hosts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// analyze ermittelt, ob ein Ausdruck auf Hosts beschränkt ist, und sammelt
|
||||||
|
// die Hosts aus positiven (nicht negierten) Host-Matchern.
|
||||||
|
func analyze(n *node, positive bool) (constrained bool, hosts []string, err error) {
|
||||||
|
switch n.op {
|
||||||
|
case opCall:
|
||||||
|
switch strings.ToLower(n.name) {
|
||||||
|
case "host":
|
||||||
|
if positive {
|
||||||
|
return true, n.args, nil
|
||||||
|
}
|
||||||
|
case "hostregexp":
|
||||||
|
if positive {
|
||||||
|
return false, nil, ErrHostRegexp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil, nil
|
||||||
|
case opNot:
|
||||||
|
_, h, err := analyze(n.kids[0], !positive)
|
||||||
|
return false, h, err
|
||||||
|
default: // opAnd, opOr
|
||||||
|
c1, h1, err := analyze(n.kids[0], positive)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
c2, h2, err := analyze(n.kids[1], positive)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
if n.op == opAnd {
|
||||||
|
return c1 || c2, append(h1, h2...), nil
|
||||||
|
}
|
||||||
|
return c1 && c2, append(h1, h2...), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lexer ---
|
||||||
|
|
||||||
|
type tokenKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
tEOF tokenKind = iota
|
||||||
|
tIdent
|
||||||
|
tString
|
||||||
|
tLParen
|
||||||
|
tRParen
|
||||||
|
tComma
|
||||||
|
tAnd
|
||||||
|
tOr
|
||||||
|
tNot
|
||||||
|
)
|
||||||
|
|
||||||
|
type token struct {
|
||||||
|
kind tokenKind
|
||||||
|
text string
|
||||||
|
pos int
|
||||||
|
}
|
||||||
|
|
||||||
|
func lex(s string) ([]token, error) {
|
||||||
|
var toks []token
|
||||||
|
for i := 0; i < len(s); {
|
||||||
|
c := s[i]
|
||||||
|
switch {
|
||||||
|
case c == ' ' || c == '\t' || c == '\n' || c == '\r':
|
||||||
|
i++
|
||||||
|
case c == '(':
|
||||||
|
toks = append(toks, token{tLParen, "(", i})
|
||||||
|
i++
|
||||||
|
case c == ')':
|
||||||
|
toks = append(toks, token{tRParen, ")", i})
|
||||||
|
i++
|
||||||
|
case c == ',':
|
||||||
|
toks = append(toks, token{tComma, ",", i})
|
||||||
|
i++
|
||||||
|
case c == '!':
|
||||||
|
toks = append(toks, token{tNot, "!", i})
|
||||||
|
i++
|
||||||
|
case strings.HasPrefix(s[i:], "&&"):
|
||||||
|
toks = append(toks, token{tAnd, "&&", i})
|
||||||
|
i += 2
|
||||||
|
case strings.HasPrefix(s[i:], "||"):
|
||||||
|
toks = append(toks, token{tOr, "||", i})
|
||||||
|
i += 2
|
||||||
|
case c == '`':
|
||||||
|
end := strings.IndexByte(s[i+1:], '`')
|
||||||
|
if end < 0 {
|
||||||
|
return nil, fmt.Errorf("%w: nicht geschlossener String ab Position %d", ErrSyntax, i)
|
||||||
|
}
|
||||||
|
toks = append(toks, token{tString, s[i+1 : i+1+end], i})
|
||||||
|
i += end + 2
|
||||||
|
case c == '"':
|
||||||
|
var b strings.Builder
|
||||||
|
j := i + 1
|
||||||
|
for ; j < len(s) && s[j] != '"'; j++ {
|
||||||
|
if s[j] == '\\' && j+1 < len(s) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
b.WriteByte(s[j])
|
||||||
|
}
|
||||||
|
if j >= len(s) {
|
||||||
|
return nil, fmt.Errorf("%w: nicht geschlossener String ab Position %d", ErrSyntax, i)
|
||||||
|
}
|
||||||
|
toks = append(toks, token{tString, b.String(), i})
|
||||||
|
i = j + 1
|
||||||
|
case isIdentStart(c):
|
||||||
|
j := i
|
||||||
|
for j < len(s) && (isIdentStart(s[j]) || (s[j] >= '0' && s[j] <= '9')) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
toks = append(toks, token{tIdent, s[i:j], i})
|
||||||
|
i = j
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: unerwartetes Zeichen %q an Position %d", ErrSyntax, c, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(toks, token{tEOF, "Ende", len(s)}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isIdentStart(c byte) bool {
|
||||||
|
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Parser ---
|
||||||
|
|
||||||
|
type opKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
opCall opKind = iota
|
||||||
|
opNot
|
||||||
|
opAnd
|
||||||
|
opOr
|
||||||
|
)
|
||||||
|
|
||||||
|
type node struct {
|
||||||
|
op opKind
|
||||||
|
kids []*node
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type parser struct {
|
||||||
|
toks []token
|
||||||
|
pos int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) peek() token { return p.toks[p.pos] }
|
||||||
|
|
||||||
|
func (p *parser) next() token {
|
||||||
|
t := p.toks[p.pos]
|
||||||
|
if t.kind != tEOF {
|
||||||
|
p.pos++
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) errorf(format string, args ...any) error {
|
||||||
|
return fmt.Errorf("%w: %s an Position %d", ErrSyntax, fmt.Sprintf(format, args...), p.peek().pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) expect(k tokenKind, what string) error {
|
||||||
|
if p.peek().kind != k {
|
||||||
|
return p.errorf("erwartet %s, gefunden %q", what, p.peek().text)
|
||||||
|
}
|
||||||
|
p.next()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOr: and ("||" and)*
|
||||||
|
func (p *parser) parseOr() (*node, error) {
|
||||||
|
left, err := p.parseAnd()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for p.peek().kind == tOr {
|
||||||
|
p.next()
|
||||||
|
right, err := p.parseAnd()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
left = &node{op: opOr, kids: []*node{left, right}}
|
||||||
|
}
|
||||||
|
return left, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAnd: unary ("&&" unary)*
|
||||||
|
func (p *parser) parseAnd() (*node, error) {
|
||||||
|
left, err := p.parseUnary()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for p.peek().kind == tAnd {
|
||||||
|
p.next()
|
||||||
|
right, err := p.parseUnary()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
left = &node{op: opAnd, kids: []*node{left, right}}
|
||||||
|
}
|
||||||
|
return left, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUnary: "!" unary | "(" or ")" | ident "(" string ("," string)* ")"
|
||||||
|
func (p *parser) parseUnary() (*node, error) {
|
||||||
|
switch p.peek().kind {
|
||||||
|
case tNot:
|
||||||
|
p.next()
|
||||||
|
kid, err := p.parseUnary()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &node{op: opNot, kids: []*node{kid}}, nil
|
||||||
|
case tLParen:
|
||||||
|
p.next()
|
||||||
|
inner, err := p.parseOr()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := p.expect(tRParen, "')'"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return inner, nil
|
||||||
|
case tIdent:
|
||||||
|
name := p.next().text
|
||||||
|
if err := p.expect(tLParen, "'('"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var args []string
|
||||||
|
for {
|
||||||
|
if p.peek().kind != tString {
|
||||||
|
return nil, p.errorf("erwartet String-Argument, gefunden %q", p.peek().text)
|
||||||
|
}
|
||||||
|
args = append(args, p.next().text)
|
||||||
|
if p.peek().kind != tComma {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
p.next()
|
||||||
|
}
|
||||||
|
if err := p.expect(tRParen, "')'"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &node{op: opCall, name: name, args: args}, nil
|
||||||
|
default:
|
||||||
|
return nil, p.errorf("erwartet Matcher, '(' oder '!', gefunden %q", p.peek().text)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHostsFromRule(t *testing.T) {
|
||||||
|
tests := map[string][]string{
|
||||||
|
"Host(`a.de`)": {"a.de"},
|
||||||
|
"Host(\"a.de\")": {"a.de"},
|
||||||
|
"Host(`a.de`) || Host(`b.de`)": {"a.de", "b.de"},
|
||||||
|
"Host(`a.de`, `b.de`)": {"a.de", "b.de"}, // v2-Syntax
|
||||||
|
"Host(`a.de`) && PathPrefix(`/api`)": {"a.de"},
|
||||||
|
"(Host(`a.de`) || Host(`b.de`)) && Method(`GET`)": {"a.de", "b.de"},
|
||||||
|
"Host(`a.de`) && !Host(`b.de`)": {"a.de"}, // ausgeschlossene Hosts zählen nicht
|
||||||
|
"host(`a.de`)": {"a.de"},
|
||||||
|
" Host( `a.de` ) ": {"a.de"},
|
||||||
|
"Host(`a.de`) && (PathPrefix(`/x`) || Path(`/y`))": {"a.de"},
|
||||||
|
"PathPrefix(`/x`) && Host(`a.de`)": {"a.de"},
|
||||||
|
"Host(`a.de`) || (Host(`b.de`) && Header(`X`, `1`))": {"a.de", "b.de"},
|
||||||
|
}
|
||||||
|
for rule, want := range tests {
|
||||||
|
got, err := HostsFromRule(rule)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: unerwarteter Fehler %v", rule, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("%s: Hosts = %v, erwartet %v", rule, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regeln, die nicht auf eigene Hosts beschränkt sind, würden fremden
|
||||||
|
// Verkehr abgreifen (Catch-all).
|
||||||
|
func TestHostsFromRule_Unconstrained(t *testing.T) {
|
||||||
|
for _, rule := range []string{
|
||||||
|
"PathPrefix(`/`)",
|
||||||
|
"Host(`a.de`) || PathPrefix(`/`)",
|
||||||
|
"!Host(`a.de`)",
|
||||||
|
"Method(`GET`)",
|
||||||
|
"ClientIP(`10.0.0.0/8`)",
|
||||||
|
"!(Host(`a.de`) && Path(`/`))",
|
||||||
|
"(Host(`a.de`) && Path(`/`)) || Header(`X`, `1`)",
|
||||||
|
} {
|
||||||
|
if _, err := HostsFromRule(rule); !errors.Is(err, ErrUnconstrained) {
|
||||||
|
t.Errorf("%s: erwartet ErrUnconstrained, bekommen %v", rule, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostsFromRule_HostRegexp(t *testing.T) {
|
||||||
|
for _, rule := range []string{
|
||||||
|
"HostRegexp(`.+`)",
|
||||||
|
"Host(`a.de`) && HostRegexp(`^x`)",
|
||||||
|
"hostregexp(`.+`)",
|
||||||
|
} {
|
||||||
|
if _, err := HostsFromRule(rule); !errors.Is(err, ErrHostRegexp) {
|
||||||
|
t.Errorf("%s: erwartet ErrHostRegexp, bekommen %v", rule, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostsFromRule_SyntaxErrors(t *testing.T) {
|
||||||
|
for _, rule := range []string{
|
||||||
|
"",
|
||||||
|
"Host(",
|
||||||
|
"Host(`a.de`",
|
||||||
|
"Host(`a.de`) &&",
|
||||||
|
"Host(a.de)",
|
||||||
|
"Host(`a.de`) Host(`b.de`)",
|
||||||
|
")(",
|
||||||
|
"Host()",
|
||||||
|
"Host(`a.de)",
|
||||||
|
"&& Host(`a.de`)",
|
||||||
|
"Host(`a.de`) | Host(`b.de`)",
|
||||||
|
"Host(`a.de`,)",
|
||||||
|
"(Host(`a.de`)",
|
||||||
|
"Host(\"a.de)",
|
||||||
|
} {
|
||||||
|
if _, err := HostsFromRule(rule); !errors.Is(err, ErrSyntax) {
|
||||||
|
t.Errorf("%q: erwartet ErrSyntax, bekommen %v", rule, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
// Package registry ist der Dynamic Config Aggregator des Servers (Spec
|
||||||
|
// 5.2): Er hält den zuletzt gemeldeten Zustand (service_state) jedes
|
||||||
|
// Knotens, übersetzt ihn über traefik.FromLabels, wendet die Policy an,
|
||||||
|
// führt alle Knoten zusammen und liefert das Ergebnis an Traefik aus
|
||||||
|
// (GET /v1/traefik-config).
|
||||||
|
//
|
||||||
|
// Neu berechnet wird bei jedem Update/Remove und bei Refresh (z. B.
|
||||||
|
// periodisch oder nach Änderungen an Mandant/Lizenz, und weil Stufen wie
|
||||||
|
// Ablauf oder Mahnstufe zeitabhängig sind).
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"sultan/pkg/license"
|
||||||
|
"sultan/pkg/policy"
|
||||||
|
"sultan/pkg/traefik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrUnknownNode meldet das Directory für Knoten, die es nicht (mehr) gibt.
|
||||||
|
var ErrUnknownNode = errors.New("registry: unbekannter Knoten")
|
||||||
|
|
||||||
|
// Zusätzliche Verstoßarten, die vor der Policy entstehen.
|
||||||
|
const (
|
||||||
|
// ViolationLabels: Die Labels eines Workloads ließen sich nicht übersetzen.
|
||||||
|
ViolationLabels policy.ViolationKind = "labels"
|
||||||
|
// ViolationNameConflict: Zwei Workloads eines Knotens nutzen denselben
|
||||||
|
// Namen (Workload oder Router/Service/Middleware).
|
||||||
|
ViolationNameConflict policy.ViolationKind = "name_conflict"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Workload ist ein Container bzw. ein Dienst aus /etc/sultan.d mit seinen
|
||||||
|
// Traefik-Labels. Host ist die vom Agenten ermittelte Backend-Adresse
|
||||||
|
// (LAN- oder NetBird-IP).
|
||||||
|
type Workload struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Labels map[string]string `json:"labels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeState ist der vollständige Zustand eines Knotens (Payload von
|
||||||
|
// service_state). Er ersetzt den vorherigen komplett.
|
||||||
|
type NodeState struct {
|
||||||
|
Workloads []Workload `json:"workloads"`
|
||||||
|
// DownServices: Services (Originalnamen), deren Healthcheck fehlschlägt.
|
||||||
|
DownServices []string `json:"down_services,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeContext ist, was die Policy über einen Knoten wissen muss.
|
||||||
|
type NodeContext struct {
|
||||||
|
Tenant policy.Tenant
|
||||||
|
License license.License
|
||||||
|
Drained bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory liefert den NodeContext; für gelöschte/unbekannte Knoten
|
||||||
|
// ErrUnknownNode (ggf. verpackt).
|
||||||
|
type Directory interface {
|
||||||
|
NodeContext(ctx context.Context, nodeID string) (NodeContext, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Violation ist ein Verstoß mit Herkunft, für das Audit-Log.
|
||||||
|
type Violation struct {
|
||||||
|
NodeID string
|
||||||
|
Workload string // leer bei Verstößen aus der Policy
|
||||||
|
Kind policy.ViolationKind
|
||||||
|
Router string
|
||||||
|
Detail string
|
||||||
|
Observed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry hält den Zustand aller Knoten. Sie ist für parallele Nutzung
|
||||||
|
// geeignet; die Felder dürfen nach der ersten Verwendung nicht mehr
|
||||||
|
// geändert werden.
|
||||||
|
type Registry struct {
|
||||||
|
Directory Directory
|
||||||
|
Policy *policy.Engine
|
||||||
|
// OnViolation wird für jeden neu aufgetretenen Verstoß einmal aufgerufen
|
||||||
|
// (nicht bei jeder Neuberechnung erneut).
|
||||||
|
OnViolation func(Violation)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
nodes map[string]*nodeEntry
|
||||||
|
config *traefik.Configuration
|
||||||
|
json []byte
|
||||||
|
yaml []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type nodeEntry struct {
|
||||||
|
state NodeState
|
||||||
|
config *traefik.Configuration
|
||||||
|
violations map[Violation]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update übernimmt den gemeldeten Zustand eines Knotens. Schlägt die
|
||||||
|
// Auswertung fehl (Directory, Policy), bleibt der vorherige Stand aktiv.
|
||||||
|
func (r *Registry) Update(ctx context.Context, nodeID string, state NodeState) error {
|
||||||
|
if nodeID == "" {
|
||||||
|
return fmt.Errorf("registry: Knoten-ID fehlt")
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
entry, err := r.evaluate(ctx, nodeID, state)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.store(nodeID, entry)
|
||||||
|
return r.rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove entfernt einen Knoten (z. B. Stream getrennt, Knoten gelöscht).
|
||||||
|
func (r *Registry) Remove(nodeID string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if _, ok := r.nodes[nodeID]; !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(r.nodes, nodeID)
|
||||||
|
r.rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh wertet alle Knoten neu aus. Unbekannte Knoten werden entfernt;
|
||||||
|
// bei anderen Fehlern bleibt der letzte gültige Stand des Knotens erhalten
|
||||||
|
// (fail-static) und der Fehler wird zurückgegeben.
|
||||||
|
func (r *Registry) Refresh(ctx context.Context) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
var errs []error
|
||||||
|
for _, id := range r.nodeIDs() {
|
||||||
|
entry, err := r.evaluate(ctx, id, r.nodes[id].state)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrUnknownNode):
|
||||||
|
delete(r.nodes, id)
|
||||||
|
case err != nil:
|
||||||
|
errs = append(errs, fmt.Errorf("Knoten %s: %w", id, err))
|
||||||
|
default:
|
||||||
|
r.store(id, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := r.rebuild(); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config liefert die aktuell ausgelieferte Konfiguration (nie nil).
|
||||||
|
func (r *Registry) Config() *traefik.Configuration {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.config == nil {
|
||||||
|
return &traefik.Configuration{}
|
||||||
|
}
|
||||||
|
return r.config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nodes liefert die Knoten mit gespeichertem Zustand, sortiert.
|
||||||
|
func (r *Registry) Nodes() []string {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.nodeIDs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler liefert die Konfiguration für Traefiks HTTP-Provider aus
|
||||||
|
// (?format=json|yaml, Default JSON).
|
||||||
|
func (r *Registry) Handler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodGet && req.Method != http.MethodHead {
|
||||||
|
w.Header().Set("Allow", "GET, HEAD")
|
||||||
|
http.Error(w, "nur GET", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
format, err := traefik.ParseFormat(req.URL.Query().Get("format"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
body := r.json
|
||||||
|
if format == traefik.FormatYAML {
|
||||||
|
body = r.yaml
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
if body == nil { // noch nie berechnet
|
||||||
|
body, _ = (&traefik.Configuration{}).Marshal(format)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", format.ContentType())
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Write(body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluate berechnet das Ergebnis eines Knotens, ohne den Zustand zu ändern.
|
||||||
|
func (r *Registry) evaluate(ctx context.Context, nodeID string, state NodeState) (*nodeEntry, error) {
|
||||||
|
nc, err := r.Directory.NodeContext(ctx, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entry := &nodeEntry{state: state, violations: map[Violation]bool{}}
|
||||||
|
add := func(v Violation) { v.NodeID = nodeID; entry.violations[v] = true }
|
||||||
|
|
||||||
|
workloads := slices.Clone(state.Workloads)
|
||||||
|
slices.SortStableFunc(workloads, func(a, b Workload) int { return cmp.Compare(a.Name, b.Name) })
|
||||||
|
merged := &traefik.Configuration{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, w := range workloads {
|
||||||
|
if seen[w.Name] {
|
||||||
|
add(Violation{Workload: w.Name, Kind: ViolationNameConflict, Detail: "Workload-Name doppelt"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[w.Name] = true
|
||||||
|
c, err := traefik.FromLabels(w.Host, w.Labels)
|
||||||
|
if err != nil {
|
||||||
|
add(Violation{Workload: w.Name, Kind: ViolationLabels, Detail: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m, err := traefik.Merge(merged, c)
|
||||||
|
if err != nil {
|
||||||
|
add(Violation{Workload: w.Name, Kind: ViolationNameConflict, Detail: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged = m
|
||||||
|
}
|
||||||
|
|
||||||
|
down := map[string]bool{}
|
||||||
|
for _, s := range state.DownServices {
|
||||||
|
down[s] = true
|
||||||
|
}
|
||||||
|
res, err := r.Policy.Apply(policy.Input{
|
||||||
|
Tenant: nc.Tenant,
|
||||||
|
License: nc.License,
|
||||||
|
NodeID: nodeID,
|
||||||
|
NodeDrained: nc.Drained,
|
||||||
|
Config: merged,
|
||||||
|
DownServices: down,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, v := range res.Violations {
|
||||||
|
add(Violation{Kind: v.Kind, Router: v.Router, Detail: v.Detail, Observed: v.Observed})
|
||||||
|
}
|
||||||
|
entry.config = res.Config
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// store übernimmt entry und meldet nur Verstöße, die vorher nicht bestanden.
|
||||||
|
func (r *Registry) store(nodeID string, entry *nodeEntry) {
|
||||||
|
if r.nodes == nil {
|
||||||
|
r.nodes = map[string]*nodeEntry{}
|
||||||
|
}
|
||||||
|
var previous map[Violation]bool
|
||||||
|
if old := r.nodes[nodeID]; old != nil {
|
||||||
|
previous = old.violations
|
||||||
|
}
|
||||||
|
r.nodes[nodeID] = entry
|
||||||
|
if r.OnViolation == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var fresh []Violation
|
||||||
|
for v := range entry.violations {
|
||||||
|
if !previous[v] {
|
||||||
|
fresh = append(fresh, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(fresh, func(a, b Violation) int {
|
||||||
|
return cmp.Or(cmp.Compare(a.Workload, b.Workload), cmp.Compare(a.Router, b.Router), cmp.Compare(a.Kind, b.Kind))
|
||||||
|
})
|
||||||
|
for _, v := range fresh {
|
||||||
|
r.OnViolation(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebuild führt alle Knoten zusammen und rendert die Ausgabe vor.
|
||||||
|
func (r *Registry) rebuild() error {
|
||||||
|
ids := r.nodeIDs()
|
||||||
|
configs := make([]*traefik.Configuration, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
configs = append(configs, r.nodes[id].config)
|
||||||
|
}
|
||||||
|
merged, err := traefik.Merge(configs...)
|
||||||
|
if err != nil {
|
||||||
|
// Durch den Namensraum der Policy nicht zu erwarten; die bisherige
|
||||||
|
// Ausgabe bleibt dann stehen.
|
||||||
|
return fmt.Errorf("registry: Zusammenführen: %w", err)
|
||||||
|
}
|
||||||
|
j, err := merged.Marshal(traefik.FormatJSON)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
y, err := merged.Marshal(traefik.FormatYAML)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.config, r.json, r.yaml = merged, j, y
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) nodeIDs() []string {
|
||||||
|
ids := make([]string, 0, len(r.nodes))
|
||||||
|
for id := range r.nodes {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
slices.Sort(ids)
|
||||||
|
return ids
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sultan/pkg/license"
|
||||||
|
"sultan/pkg/policy"
|
||||||
|
"sultan/pkg/store"
|
||||||
|
"sultan/pkg/traefik"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ctx = context.Background()
|
||||||
|
|
||||||
|
// fakeDirectory liefert Mandant/Lizenz je Knoten; err simuliert Ausfälle.
|
||||||
|
type fakeDirectory struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
nodes map[string]NodeContext
|
||||||
|
err map[string]error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *fakeDirectory) NodeContext(_ context.Context, nodeID string) (NodeContext, error) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if err := d.err[nodeID]; err != nil {
|
||||||
|
return NodeContext{}, err
|
||||||
|
}
|
||||||
|
nc, ok := d.nodes[nodeID]
|
||||||
|
if !ok {
|
||||||
|
return NodeContext{}, ErrUnknownNode
|
||||||
|
}
|
||||||
|
return nc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *fakeDirectory) set(nodeID string, f func(*NodeContext)) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
nc := d.nodes[nodeID]
|
||||||
|
f(&nc)
|
||||||
|
d.nodes[nodeID] = nc
|
||||||
|
}
|
||||||
|
|
||||||
|
func nodeCtx(tenant string, domains ...string) NodeContext {
|
||||||
|
return NodeContext{
|
||||||
|
Tenant: policy.Tenant{ID: tenant, Status: store.TenantActive},
|
||||||
|
License: license.License{
|
||||||
|
TenantID: tenant, Tier: license.TierPro, MaxRoutes: 10,
|
||||||
|
AllowedDomains: domains, ValidUntil: time.Now().AddDate(1, 0, 0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type audit struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
log []Violation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *audit) record(v Violation) { a.mu.Lock(); a.log = append(a.log, v); a.mu.Unlock() }
|
||||||
|
func (a *audit) entries() []Violation {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
return append([]Violation(nil), a.log...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRegistry() (*Registry, *fakeDirectory, *audit) {
|
||||||
|
dir := &fakeDirectory{
|
||||||
|
nodes: map[string]NodeContext{
|
||||||
|
"n1": nodeCtx("kunde", "kunde.de", "*.kunde.de"),
|
||||||
|
"n2": nodeCtx("andere", "andere.de"),
|
||||||
|
},
|
||||||
|
err: map[string]error{},
|
||||||
|
}
|
||||||
|
a := &audit{}
|
||||||
|
r := &Registry{
|
||||||
|
Directory: dir,
|
||||||
|
Policy: &policy.Engine{InterceptorURL: "http://127.0.0.1:8081"},
|
||||||
|
OnViolation: a.record,
|
||||||
|
}
|
||||||
|
return r, dir, a
|
||||||
|
}
|
||||||
|
|
||||||
|
func wp(name, host, domain string) Workload {
|
||||||
|
return Workload{Name: name, Host: host, Labels: map[string]string{
|
||||||
|
"traefik.enable": "true",
|
||||||
|
"traefik.http.routers.wp.rule": "Host(`" + domain + "`)",
|
||||||
|
"traefik.http.services.wp.loadbalancer.server.port": "80",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func routers(r *Registry) []string {
|
||||||
|
c := r.Config()
|
||||||
|
if c.HTTP == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for name := range c.HTTP.Routers {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
slices.Sort(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_Empty(t *testing.T) {
|
||||||
|
r, _, _ := newRegistry()
|
||||||
|
if c := r.Config(); c == nil || c.HTTP != nil {
|
||||||
|
t.Errorf("leere Registry: %+v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_UpdateAndAggregate(t *testing.T) {
|
||||||
|
r, _, a := newRegistry()
|
||||||
|
if err := r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")}}); err != nil {
|
||||||
|
t.Fatalf("Update n1: %v", err)
|
||||||
|
}
|
||||||
|
// Gleicher Router-Name bei einem anderen Mandanten: kein Konflikt.
|
||||||
|
if err := r.Update(ctx, "n2", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.2", "andere.de")}}); err != nil {
|
||||||
|
t.Fatalf("Update n2: %v", err)
|
||||||
|
}
|
||||||
|
if got := routers(r); !reflect.DeepEqual(got, []string{"andere__n2__wp", "kunde__n1__wp"}) {
|
||||||
|
t.Errorf("Router = %v", got)
|
||||||
|
}
|
||||||
|
svc := r.Config().HTTP.Services["kunde__n1__wp"]
|
||||||
|
if svc.LoadBalancer.Servers[0].URL != "http://100.64.0.1:80" {
|
||||||
|
t.Errorf("Backend = %s, erwartet Host des Workloads", svc.LoadBalancer.Servers[0].URL)
|
||||||
|
}
|
||||||
|
if len(a.entries()) != 0 {
|
||||||
|
t.Errorf("unerwartete Violations: %+v", a.entries())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// service_state ist ein vollständiger Schnappschuss, kein Delta.
|
||||||
|
func TestRegistry_UpdateReplacesState(t *testing.T) {
|
||||||
|
r, _, _ := newRegistry()
|
||||||
|
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")}})
|
||||||
|
r.Update(ctx, "n1", NodeState{})
|
||||||
|
if got := routers(r); len(got) != 0 {
|
||||||
|
t.Errorf("Router nach leerem Schnappschuss: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_Remove(t *testing.T) {
|
||||||
|
r, _, _ := newRegistry()
|
||||||
|
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")}})
|
||||||
|
r.Update(ctx, "n2", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.2", "andere.de")}})
|
||||||
|
r.Remove("n1")
|
||||||
|
if got := routers(r); !reflect.DeepEqual(got, []string{"andere__n2__wp"}) {
|
||||||
|
t.Errorf("Router nach Remove: %v", got)
|
||||||
|
}
|
||||||
|
r.Remove("gibts-nicht") // darf nicht stören
|
||||||
|
if !reflect.DeepEqual(r.Nodes(), []string{"n2"}) {
|
||||||
|
t.Errorf("Nodes = %v", r.Nodes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_UpdateErrors(t *testing.T) {
|
||||||
|
r, dir, _ := newRegistry()
|
||||||
|
if err := r.Update(ctx, "", NodeState{}); err == nil {
|
||||||
|
t.Error("leere Knoten-ID akzeptiert")
|
||||||
|
}
|
||||||
|
if err := r.Update(ctx, "fremd", NodeState{}); !errors.Is(err, ErrUnknownNode) {
|
||||||
|
t.Errorf("unbekannter Knoten: erwartet ErrUnknownNode, bekommen %v", err)
|
||||||
|
}
|
||||||
|
dbDown := errors.New("DB weg")
|
||||||
|
dir.err["n1"] = dbDown
|
||||||
|
if err := r.Update(ctx, "n1", NodeState{}); !errors.Is(err, dbDown) {
|
||||||
|
t.Errorf("Directory-Fehler: %v", err)
|
||||||
|
}
|
||||||
|
if len(r.Nodes()) != 0 {
|
||||||
|
t.Errorf("fehlgeschlagene Updates wurden gespeichert: %v", r.Nodes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_PolicyViolationsAuditedOnce(t *testing.T) {
|
||||||
|
r, _, a := newRegistry()
|
||||||
|
bad := NodeState{Workloads: []Workload{
|
||||||
|
wp("wordpress", "100.64.0.1", "kunde.de"),
|
||||||
|
{Name: "phish", Host: "100.64.0.1", Labels: map[string]string{
|
||||||
|
"traefik.enable": "true",
|
||||||
|
"traefik.http.routers.bank.rule": "Host(`bank.de`)",
|
||||||
|
"traefik.http.services.bank.loadbalancer.server.port": "81",
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
r.Update(ctx, "n1", bad)
|
||||||
|
if got := routers(r); !reflect.DeepEqual(got, []string{"kunde__n1__wp"}) {
|
||||||
|
t.Errorf("Router = %v", got)
|
||||||
|
}
|
||||||
|
log := a.entries()
|
||||||
|
if len(log) != 1 || log[0].NodeID != "n1" || log[0].Kind != policy.ViolationDomain || log[0].Router != "bank" {
|
||||||
|
t.Fatalf("Audit = %+v", log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gleicher Zustand erneut (z. B. nächster Heartbeat): nicht erneut melden.
|
||||||
|
r.Update(ctx, "n1", bad)
|
||||||
|
r.Refresh(ctx)
|
||||||
|
if n := len(a.entries()); n != 1 {
|
||||||
|
t.Errorf("Violation %d-mal gemeldet, erwartet 1", n)
|
||||||
|
}
|
||||||
|
// Behoben und wieder kaputt: erneut melden.
|
||||||
|
r.Update(ctx, "n1", NodeState{Workloads: bad.Workloads[:1]})
|
||||||
|
r.Update(ctx, "n1", bad)
|
||||||
|
if n := len(a.entries()); n != 2 {
|
||||||
|
t.Errorf("erneuter Verstoß nicht gemeldet (%d Einträge)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_LabelErrorsSkipWorkload(t *testing.T) {
|
||||||
|
r, _, a := newRegistry()
|
||||||
|
broken := Workload{Name: "kaputt", Host: "100.64.0.1", Labels: map[string]string{
|
||||||
|
"traefik.enable": "true",
|
||||||
|
"traefik.tcp.routers.db.rule": "HostSNI(`*`)",
|
||||||
|
}}
|
||||||
|
r.Update(ctx, "n1", NodeState{Workloads: []Workload{broken, wp("wordpress", "100.64.0.1", "kunde.de")}})
|
||||||
|
if got := routers(r); !reflect.DeepEqual(got, []string{"kunde__n1__wp"}) {
|
||||||
|
t.Errorf("Router = %v", got)
|
||||||
|
}
|
||||||
|
log := a.entries()
|
||||||
|
if len(log) != 1 || log[0].Kind != ViolationLabels || log[0].Workload != "kaputt" {
|
||||||
|
t.Errorf("Audit = %+v", log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zwei Workloads eines Knotens mit gleichem Router-Namen: der zweite
|
||||||
|
// (nach Workload-Namen) wird übersprungen.
|
||||||
|
func TestRegistry_NameConflictWithinNode(t *testing.T) {
|
||||||
|
r, _, a := newRegistry()
|
||||||
|
r.Update(ctx, "n1", NodeState{Workloads: []Workload{
|
||||||
|
wp("b-zweiter", "100.64.0.2", "b.kunde.de"),
|
||||||
|
wp("a-erster", "100.64.0.1", "a.kunde.de"),
|
||||||
|
}})
|
||||||
|
cfg := r.Config()
|
||||||
|
if rule := cfg.HTTP.Routers["kunde__n1__wp"].Rule; rule != "Host(`a.kunde.de`)" {
|
||||||
|
t.Errorf("Regel = %s, erwartet den ersten Workload", rule)
|
||||||
|
}
|
||||||
|
log := a.entries()
|
||||||
|
if len(log) != 1 || log[0].Kind != ViolationNameConflict || log[0].Workload != "b-zweiter" {
|
||||||
|
t.Errorf("Audit = %+v", log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_DuplicateWorkloadName(t *testing.T) {
|
||||||
|
r, _, a := newRegistry()
|
||||||
|
r.Update(ctx, "n1", NodeState{Workloads: []Workload{
|
||||||
|
wp("gleich", "100.64.0.1", "kunde.de"),
|
||||||
|
wp("gleich", "100.64.0.2", "kunde.de"),
|
||||||
|
}})
|
||||||
|
log := a.entries()
|
||||||
|
if len(log) != 1 || log[0].Kind != ViolationNameConflict {
|
||||||
|
t.Errorf("Audit = %+v", log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_DownServicesGoToMaintenance(t *testing.T) {
|
||||||
|
r, _, _ := newRegistry()
|
||||||
|
r.Update(ctx, "n1", NodeState{
|
||||||
|
Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")},
|
||||||
|
DownServices: []string{"wp"},
|
||||||
|
})
|
||||||
|
if svc := r.Config().HTTP.Routers["kunde__n1__wp"].Service; svc != policy.InterceptorService {
|
||||||
|
t.Errorf("Service = %s, erwartet Interceptor", svc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_RefreshAppliesDirectoryChanges(t *testing.T) {
|
||||||
|
r, dir, _ := newRegistry()
|
||||||
|
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")}})
|
||||||
|
|
||||||
|
dir.set("n1", func(nc *NodeContext) { nc.Tenant.Status = store.TenantSuspendedPayment })
|
||||||
|
if err := r.Refresh(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if svc := r.Config().HTTP.Routers["kunde__n1__wp"].Service; svc != policy.InterceptorService {
|
||||||
|
t.Errorf("nach Sperre: Service = %s", svc)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir.set("n1", func(nc *NodeContext) { nc.Drained = true })
|
||||||
|
r.Refresh(ctx)
|
||||||
|
if got := routers(r); len(got) != 0 {
|
||||||
|
t.Errorf("gedraint: Router = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fällt das Directory vorübergehend aus, bleibt der letzte gültige Stand
|
||||||
|
// eines Knotens erhalten (fail-static); ist der Knoten gelöscht, verschwindet er.
|
||||||
|
func TestRegistry_RefreshDirectoryFailures(t *testing.T) {
|
||||||
|
r, dir, _ := newRegistry()
|
||||||
|
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")}})
|
||||||
|
r.Update(ctx, "n2", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.2", "andere.de")}})
|
||||||
|
|
||||||
|
dbDown := errors.New("DB weg")
|
||||||
|
dir.err["n1"] = dbDown
|
||||||
|
if err := r.Refresh(ctx); !errors.Is(err, dbDown) {
|
||||||
|
t.Errorf("Refresh: erwartet DB-Fehler, bekommen %v", err)
|
||||||
|
}
|
||||||
|
if got := routers(r); !reflect.DeepEqual(got, []string{"andere__n2__wp", "kunde__n1__wp"}) {
|
||||||
|
t.Errorf("fail-static verletzt: %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir.err["n1"] = ErrUnknownNode
|
||||||
|
r.Refresh(ctx)
|
||||||
|
if got := routers(r); !reflect.DeepEqual(got, []string{"andere__n2__wp"}) {
|
||||||
|
t.Errorf("gelöschter Knoten noch aktiv: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_PolicyErrorKeepsPreviousState(t *testing.T) {
|
||||||
|
r, dir, _ := newRegistry()
|
||||||
|
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")}})
|
||||||
|
dir.set("n1", func(nc *NodeContext) { nc.License.TenantID = "falsch" }) // Policy lehnt ab
|
||||||
|
if err := r.Update(ctx, "n1", NodeState{}); !errors.Is(err, policy.ErrInvalidInput) {
|
||||||
|
t.Errorf("erwartet policy.ErrInvalidInput, bekommen %v", err)
|
||||||
|
}
|
||||||
|
if got := routers(r); !reflect.DeepEqual(got, []string{"kunde__n1__wp"}) {
|
||||||
|
t.Errorf("vorheriger Stand verloren: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_Handler(t *testing.T) {
|
||||||
|
r, _, _ := newRegistry()
|
||||||
|
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")}})
|
||||||
|
srv := httptest.NewServer(r.Handler())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/v1/traefik-config")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Type") != "application/json" {
|
||||||
|
t.Fatalf("Status %d, Content-Type %q", resp.StatusCode, resp.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
var got traefik.Configuration
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.HTTP.Routers["kunde__n1__wp"] == nil {
|
||||||
|
t.Errorf("Router fehlt in der Antwort: %+v", got.HTTP)
|
||||||
|
}
|
||||||
|
|
||||||
|
yamlResp, _ := http.Get(srv.URL + "/v1/traefik-config?format=yaml")
|
||||||
|
yamlResp.Body.Close()
|
||||||
|
if yamlResp.Header.Get("Content-Type") != "application/yaml" {
|
||||||
|
t.Errorf("YAML: Content-Type %q", yamlResp.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
bad, _ := http.Get(srv.URL + "/v1/traefik-config?format=xml")
|
||||||
|
bad.Body.Close()
|
||||||
|
if bad.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("unbekanntes Format: Status %d", bad.StatusCode)
|
||||||
|
}
|
||||||
|
post, _ := http.Post(srv.URL+"/v1/traefik-config", "text/plain", nil)
|
||||||
|
post.Body.Close()
|
||||||
|
if post.StatusCode != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("POST: Status %d", post.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_HandlerEmpty(t *testing.T) {
|
||||||
|
r, _, _ := newRegistry()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/traefik-config", nil))
|
||||||
|
if rec.Body.String() != "{}" {
|
||||||
|
t.Errorf("leere Konfiguration = %q, erwartet {}", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_Concurrent(t *testing.T) {
|
||||||
|
r, dir, _ := newRegistry()
|
||||||
|
for i := range 20 {
|
||||||
|
dir.nodes[fmt.Sprintf("k%d", i)] = nodeCtx("kunde", "kunde.de", "*.kunde.de")
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range 20 {
|
||||||
|
node := fmt.Sprintf("k%d", i)
|
||||||
|
wg.Go(func() {
|
||||||
|
for j := range 10 {
|
||||||
|
r.Update(ctx, node, NodeState{Workloads: []Workload{wp("w", "100.64.0.1", fmt.Sprintf("s%d.kunde.de", j))}})
|
||||||
|
r.Config()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Go(func() {
|
||||||
|
for range 50 {
|
||||||
|
r.Refresh(ctx)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/traefik-config", nil))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
wg.Wait()
|
||||||
|
if n := len(routers(r)); n != 20 {
|
||||||
|
t.Errorf("%d Router, erwartet 20", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"sultan/pkg/license"
|
||||||
|
"sultan/pkg/policy"
|
||||||
|
"sultan/pkg/registry"
|
||||||
|
"sultan/pkg/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// directory liefert der Registry Mandant, Lizenz und Drain-Status eines
|
||||||
|
// Knotens aus dem Store.
|
||||||
|
//
|
||||||
|
// Solange Migration v2 fehlt (Spec Abschnitt 9), wird die Lizenz aus dem
|
||||||
|
// Mandanten-Datensatz abgeleitet: ohne Features und ohne Mahnstufe.
|
||||||
|
type directory struct {
|
||||||
|
store *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *directory) NodeContext(ctx context.Context, nodeID string) (registry.NodeContext, error) {
|
||||||
|
node, err := d.store.GetNode(ctx, nodeID)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
return registry.NodeContext{}, fmt.Errorf("%w: %s", registry.ErrUnknownNode, nodeID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return registry.NodeContext{}, err
|
||||||
|
}
|
||||||
|
t, err := d.store.GetTenant(ctx, node.TenantID)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
return registry.NodeContext{}, fmt.Errorf("%w: Mandant von %s fehlt", registry.ErrUnknownNode, nodeID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return registry.NodeContext{}, err
|
||||||
|
}
|
||||||
|
return registry.NodeContext{
|
||||||
|
Tenant: policy.Tenant{ID: t.ID, Status: t.Status},
|
||||||
|
License: license.License{
|
||||||
|
TenantID: t.ID,
|
||||||
|
Tier: license.Tier(t.Tier),
|
||||||
|
MaxRoutes: t.MaxRoutes,
|
||||||
|
AllowedDomains: t.AllowedDomains,
|
||||||
|
Features: []license.Feature{},
|
||||||
|
ValidUntil: t.ValidUntil,
|
||||||
|
},
|
||||||
|
Drained: node.Status == store.NodeDrained,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sultan/pkg/auth"
|
||||||
|
"sultan/pkg/policy"
|
||||||
|
"sultan/pkg/registry"
|
||||||
|
"sultan/pkg/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnrollRequest ist der Body von POST /api/v1/enroll. Der Request ist mit
|
||||||
|
// dem neuen Schlüssel des Agenten signiert (Besitznachweis).
|
||||||
|
type EnrollRequest struct {
|
||||||
|
Token string `json:"token"` // sltn_enroll_<secret>, ohne Fingerprint
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrollResponse ist die Antwort auf ein erfolgreiches Enrollment.
|
||||||
|
type EnrollResponse struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
MasterPublicKey string `json:"master_public_key"` // zum Prüfen von Lizenzen/Leases
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) agentRoutes() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("POST /api/v1/enroll", s.handleEnroll)
|
||||||
|
mux.HandleFunc("GET /api/v1/stream", s.handleStream)
|
||||||
|
mux.HandleFunc("POST /api/v1/state", s.handleState)
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) providerRoutes() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("GET /v1/traefik-config", s.registry.Handler())
|
||||||
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Write([]byte("ok\n"))
|
||||||
|
})
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pub, err := s.verifier.VerifyUnregistered(r)
|
||||||
|
if err != nil {
|
||||||
|
authError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req EnrollRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "ungültiger Body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !policy.ValidID(req.NodeID) || strings.TrimSpace(req.Hostname) == "" {
|
||||||
|
http.Error(w, "node_id ungültig oder hostname fehlt", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node, err := s.store.Enroll(r.Context(), req.Token, store.Node{
|
||||||
|
ID: req.NodeID,
|
||||||
|
Hostname: req.Hostname,
|
||||||
|
PublicKey: auth.EncodePublicKey(pub),
|
||||||
|
IPAddress: remoteIP(r),
|
||||||
|
Status: store.NodeOffline, // online erst mit offenem Stream
|
||||||
|
})
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrTokenUsed), errors.Is(err, store.ErrTokenExpired):
|
||||||
|
// Keine Details nach außen, ob Token unbekannt, verbraucht oder abgelaufen.
|
||||||
|
s.log.Warn("Enrollment abgelehnt", "node", req.NodeID, "ip", remoteIP(r), "err", err)
|
||||||
|
http.Error(w, "Einladung ungültig", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
case errors.Is(err, store.ErrExists):
|
||||||
|
http.Error(w, "Knoten-ID oder Schlüssel bereits registriert", http.StatusConflict)
|
||||||
|
return
|
||||||
|
case errors.Is(err, store.ErrInvalid):
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
s.log.Error("Enrollment", "err", err)
|
||||||
|
http.Error(w, "interner Fehler", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log.Info("Knoten registriert", "node", node.ID, "tenant", node.TenantID, "ip", node.IPAddress)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(EnrollResponse{
|
||||||
|
NodeID: node.ID,
|
||||||
|
TenantID: node.TenantID,
|
||||||
|
MasterPublicKey: auth.EncodePublicKey(s.MasterPublicKey()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := s.verifier.Verify(r)
|
||||||
|
if err != nil {
|
||||||
|
authError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.TouchNode(r.Context(), id.Node.ID, remoteIP(r)); err != nil {
|
||||||
|
s.log.Error("Lebenszeichen speichern", "node", id.Node.ID, "err", err)
|
||||||
|
}
|
||||||
|
err = s.hub.Serve(w, r, id.Node.ID)
|
||||||
|
s.log.Debug("Stream beendet", "node", id.Node.ID, "reason", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := s.verifier.Verify(r)
|
||||||
|
if err != nil {
|
||||||
|
authError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Lebendigkeit hängt am Stream: Ohne offenen Stream würden die Routen
|
||||||
|
// beim nächsten Trennen nie wieder entfernt.
|
||||||
|
if !s.hub.Connected(id.Node.ID) {
|
||||||
|
http.Error(w, "Stream nicht verbunden", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var state registry.NodeState
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&state); err != nil {
|
||||||
|
http.Error(w, "ungültiger Body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = s.registry.Update(r.Context(), id.Node.ID, state)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, registry.ErrUnknownNode):
|
||||||
|
http.Error(w, "Knoten unbekannt", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
case errors.Is(err, policy.ErrInvalidInput):
|
||||||
|
s.log.Error("Zustand abgelehnt", "node", id.Node.ID, "err", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
s.log.Error("Zustand übernehmen", "node", id.Node.ID, "err", err)
|
||||||
|
http.Error(w, "interner Fehler", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.TouchNode(r.Context(), id.Node.ID, remoteIP(r)); err != nil {
|
||||||
|
s.log.Error("Lebenszeichen speichern", "node", id.Node.ID, "err", err)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func authError(w http.ResponseWriter, err error) {
|
||||||
|
code := auth.StatusCode(err)
|
||||||
|
msg := http.StatusText(code)
|
||||||
|
if code == http.StatusUpgradeRequired {
|
||||||
|
msg = "nur über TLS"
|
||||||
|
}
|
||||||
|
http.Error(w, msg, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteIP(r *http.Request) string {
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
// Package server verdrahtet alle Bausteine zum Sultan-Server (`sultan
|
||||||
|
// server`):
|
||||||
|
//
|
||||||
|
// - Agent-Port (Default :8443, nur TLS 1.3): Enrollment, SSE-Stream und
|
||||||
|
// Zustandsmeldungen der Agents, jeder Request Ed25519-signiert.
|
||||||
|
// - Provider-Port (Default 127.0.0.1:8080, Klartext, nur lokal): die
|
||||||
|
// Traefik-Konfiguration für Traefiks HTTP-Provider und /healthz.
|
||||||
|
//
|
||||||
|
// Persistenter Zustand liegt im Datenverzeichnis (Default ./suldata):
|
||||||
|
// sultan.db, server.key/server.crt (TLS) und master.key (Lizenzen).
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sultan/pkg/auth"
|
||||||
|
"sultan/pkg/crypto"
|
||||||
|
"sultan/pkg/policy"
|
||||||
|
"sultan/pkg/registry"
|
||||||
|
"sultan/pkg/store"
|
||||||
|
"sultan/pkg/stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config ist die Konfiguration des Servers; Nullwerte bekommen Defaults.
|
||||||
|
type Config struct {
|
||||||
|
DataDir string // Default ./suldata
|
||||||
|
AgentAddr string // Default :8443
|
||||||
|
ProviderAddr string // Default 127.0.0.1:8080 – Klartext, nicht öffentlich binden
|
||||||
|
InterceptorURL string // Default http://127.0.0.1:8081
|
||||||
|
RefreshInterval time.Duration // Default 1m: Neubewertung aller Knoten
|
||||||
|
Logger *slog.Logger // Default: Text auf stderr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) withDefaults() Config {
|
||||||
|
if c.DataDir == "" {
|
||||||
|
c.DataDir = "./suldata"
|
||||||
|
}
|
||||||
|
if c.AgentAddr == "" {
|
||||||
|
c.AgentAddr = ":8443"
|
||||||
|
}
|
||||||
|
if c.ProviderAddr == "" {
|
||||||
|
c.ProviderAddr = "127.0.0.1:8080"
|
||||||
|
}
|
||||||
|
if c.InterceptorURL == "" {
|
||||||
|
c.InterceptorURL = "http://127.0.0.1:8081"
|
||||||
|
}
|
||||||
|
if c.RefreshInterval <= 0 {
|
||||||
|
c.RefreshInterval = time.Minute
|
||||||
|
}
|
||||||
|
if c.Logger == nil {
|
||||||
|
c.Logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server ist ein laufender bzw. startbereiter Sultan-Server.
|
||||||
|
type Server struct {
|
||||||
|
cfg Config
|
||||||
|
log *slog.Logger
|
||||||
|
store *store.Store
|
||||||
|
cert crypto.ServerCert
|
||||||
|
master ed25519.PrivateKey
|
||||||
|
hub *stream.Hub
|
||||||
|
verifier *auth.Verifier
|
||||||
|
registry *registry.Registry
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
agentAddr string
|
||||||
|
providerAddr string
|
||||||
|
agentSrv *http.Server
|
||||||
|
providerSrv *http.Server
|
||||||
|
serveErr chan error
|
||||||
|
stopRefresh chan struct{}
|
||||||
|
shutdownOnce sync.Once
|
||||||
|
shutdownErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// New legt das Datenverzeichnis an (0700) und lädt bzw. erzeugt Datenbank,
|
||||||
|
// TLS-Zertifikat und Master-Key. Lauschen beginnt erst mit Start.
|
||||||
|
func New(ctx context.Context, cfg Config) (*Server, error) {
|
||||||
|
cfg = cfg.withDefaults()
|
||||||
|
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("server: Datenverzeichnis: %w", err)
|
||||||
|
}
|
||||||
|
st, err := store.Open(ctx, filepath.Join(cfg.DataDir, "sultan.db"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cert, err := crypto.LoadOrCreateServerCert(cfg.DataDir)
|
||||||
|
if err != nil {
|
||||||
|
st.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
master, err := loadOrCreateMasterKey(filepath.Join(cfg.DataDir, "master.key"))
|
||||||
|
if err != nil {
|
||||||
|
st.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
cfg: cfg,
|
||||||
|
log: cfg.Logger,
|
||||||
|
store: st,
|
||||||
|
cert: cert,
|
||||||
|
master: master,
|
||||||
|
verifier: &auth.Verifier{Directory: st},
|
||||||
|
serveErr: make(chan error, 2),
|
||||||
|
stopRefresh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
s.hub = &stream.Hub{OnConnect: s.nodeOnline, OnDisconnect: s.nodeOffline}
|
||||||
|
s.registry = ®istry.Registry{
|
||||||
|
Directory: &directory{store: st},
|
||||||
|
Policy: &policy.Engine{InterceptorURL: cfg.InterceptorURL},
|
||||||
|
OnViolation: s.logViolation,
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrCreateMasterKey(path string) (ed25519.PrivateKey, error) {
|
||||||
|
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||||
|
key, err := auth.GenerateKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := auth.WritePrivateKey(path, key); err != nil {
|
||||||
|
return nil, fmt.Errorf("server: master.key anlegen: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key, err := auth.ReadPrivateKey(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("server: master.key laden: %w", err)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fingerprint ist der Fingerprint des TLS-Zertifikats (für Enrollment-Tokens).
|
||||||
|
func (s *Server) Fingerprint() crypto.Fingerprint { return s.cert.Fingerprint }
|
||||||
|
|
||||||
|
// MasterPublicKey ist der Schlüssel, mit dem Lizenzen geprüft werden.
|
||||||
|
func (s *Server) MasterPublicKey() ed25519.PublicKey {
|
||||||
|
return s.master.Public().(ed25519.PublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentAddr ist die tatsächlich gebundene Adresse des Agent-Ports ("" vor Start).
|
||||||
|
func (s *Server) AgentAddr() string { s.mu.Lock(); defer s.mu.Unlock(); return s.agentAddr }
|
||||||
|
|
||||||
|
// ProviderAddr ist die tatsächlich gebundene Adresse des Provider-Ports.
|
||||||
|
func (s *Server) ProviderAddr() string { s.mu.Lock(); defer s.mu.Unlock(); return s.providerAddr }
|
||||||
|
|
||||||
|
// Start bindet beide Ports und beginnt zu bedienen; er kehrt sofort zurück.
|
||||||
|
func (s *Server) Start() error {
|
||||||
|
agentLn, err := net.Listen("tcp", s.cfg.AgentAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("server: Agent-Port %s: %w", s.cfg.AgentAddr, err)
|
||||||
|
}
|
||||||
|
providerLn, err := net.Listen("tcp", s.cfg.ProviderAddr)
|
||||||
|
if err != nil {
|
||||||
|
agentLn.Close()
|
||||||
|
return fmt.Errorf("server: Provider-Port %s: %w", s.cfg.ProviderAddr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
agentSrv := &http.Server{
|
||||||
|
Handler: s.agentRoutes(),
|
||||||
|
TLSConfig: crypto.ServerTLSConfig(s.cert),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelDebug),
|
||||||
|
}
|
||||||
|
providerSrv := &http.Server{
|
||||||
|
Handler: s.providerRoutes(),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.agentAddr, s.providerAddr = agentLn.Addr().String(), providerLn.Addr().String()
|
||||||
|
s.agentSrv, s.providerSrv = agentSrv, providerSrv
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
go s.serve(func() error { return agentSrv.ServeTLS(agentLn, "", "") })
|
||||||
|
go s.serve(func() error { return providerSrv.Serve(providerLn) })
|
||||||
|
go s.refreshLoop()
|
||||||
|
|
||||||
|
s.log.Info("Sultan-Server gestartet",
|
||||||
|
"agent", s.AgentAddr(), "provider", s.ProviderAddr(),
|
||||||
|
"Fingerprint", string(s.cert.Fingerprint), "data_dir", s.cfg.DataDir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serve(fn func() error) {
|
||||||
|
if err := fn(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
s.serveErr <- err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) refreshLoop() {
|
||||||
|
t := time.NewTicker(s.cfg.RefreshInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stopRefresh:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := s.registry.Refresh(context.Background()); err != nil {
|
||||||
|
s.log.Error("Neubewertung fehlgeschlagen", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run startet den Server und fährt ihn herunter, sobald ctx endet oder ein
|
||||||
|
// Port ausfällt.
|
||||||
|
func (s *Server) Run(ctx context.Context) error {
|
||||||
|
if err := s.Start(); err != nil {
|
||||||
|
s.Shutdown(context.Background())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var runErr error
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case runErr = <-s.serveErr:
|
||||||
|
}
|
||||||
|
sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return errors.Join(runErr, s.Shutdown(sctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown beendet zuerst alle Streams (sonst warten offene SSE-Requests
|
||||||
|
// ewig), dann beide HTTP-Server und schließt die Datenbank. Mehrfacher
|
||||||
|
// Aufruf ist erlaubt.
|
||||||
|
func (s *Server) Shutdown(ctx context.Context) error {
|
||||||
|
s.shutdownOnce.Do(func() {
|
||||||
|
close(s.stopRefresh)
|
||||||
|
s.hub.Close()
|
||||||
|
s.mu.Lock()
|
||||||
|
agentSrv, providerSrv := s.agentSrv, s.providerSrv
|
||||||
|
s.mu.Unlock()
|
||||||
|
var errs []error
|
||||||
|
for _, srv := range []*http.Server{agentSrv, providerSrv} {
|
||||||
|
if srv != nil {
|
||||||
|
errs = append(errs, srv.Shutdown(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errs = append(errs, s.store.Close())
|
||||||
|
s.shutdownErr = errors.Join(errs...)
|
||||||
|
s.log.Info("Sultan-Server beendet")
|
||||||
|
})
|
||||||
|
return s.shutdownErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// nodeOnline/nodeOffline halten nodes.status aktuell; ein gedrainter
|
||||||
|
// Knoten bleibt gedraint. Beim Trennen verschwinden seine Routen.
|
||||||
|
func (s *Server) nodeOnline(nodeID string) {
|
||||||
|
s.setNodeStatus(nodeID, store.NodeOnline)
|
||||||
|
s.log.Info("Knoten verbunden", "node", nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) nodeOffline(nodeID string) {
|
||||||
|
s.registry.Remove(nodeID)
|
||||||
|
s.setNodeStatus(nodeID, store.NodeOffline)
|
||||||
|
s.log.Info("Knoten getrennt", "node", nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setNodeStatus(nodeID string, status store.NodeStatus) {
|
||||||
|
ctx := context.Background()
|
||||||
|
n, err := s.store.GetNode(ctx, nodeID)
|
||||||
|
if err != nil || n.Status == store.NodeDrained {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetNodeStatus(ctx, nodeID, status); err != nil {
|
||||||
|
s.log.Error("Knotenstatus setzen", "node", nodeID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) logViolation(v registry.Violation) {
|
||||||
|
s.log.Warn("Policy-Verstoß",
|
||||||
|
"node", v.NodeID, "kind", string(v.Kind), "router", v.Router,
|
||||||
|
"workload", v.Workload, "detail", v.Detail, "observed", v.Observed)
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sultan/pkg/auth"
|
||||||
|
"sultan/pkg/crypto"
|
||||||
|
"sultan/pkg/policy"
|
||||||
|
"sultan/pkg/registry"
|
||||||
|
"sultan/pkg/store"
|
||||||
|
"sultan/pkg/stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ctx = context.Background()
|
||||||
|
|
||||||
|
// syncBuffer sammelt Log-Ausgaben threadsicher.
|
||||||
|
type syncBuffer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
b bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *syncBuffer) Write(p []byte) (int, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.b.Write(p)
|
||||||
|
}
|
||||||
|
func (s *syncBuffer) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() }
|
||||||
|
|
||||||
|
func eventually(t *testing.T, msg string, cond func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for !cond() {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("Bedingung nicht erfüllt: %s", msg)
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startServer startet einen Server auf freien Ports in einem frischen
|
||||||
|
// Datenverzeichnis.
|
||||||
|
func startServer(t *testing.T, mutate ...func(*Config)) (*Server, *syncBuffer) {
|
||||||
|
t.Helper()
|
||||||
|
logs := &syncBuffer{}
|
||||||
|
cfg := Config{
|
||||||
|
DataDir: filepath.Join(t.TempDir(), "suldata"),
|
||||||
|
AgentAddr: "127.0.0.1:0",
|
||||||
|
ProviderAddr: "127.0.0.1:0",
|
||||||
|
RefreshInterval: time.Hour,
|
||||||
|
Logger: slog.New(slog.NewTextHandler(logs, nil)),
|
||||||
|
}
|
||||||
|
for _, m := range mutate {
|
||||||
|
m(&cfg)
|
||||||
|
}
|
||||||
|
s, err := New(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Start(); err != nil {
|
||||||
|
t.Fatalf("Start: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
sctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
s.Shutdown(sctx)
|
||||||
|
})
|
||||||
|
return s, logs
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTenant(t *testing.T, s *Server, id string, domains ...string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := s.store.CreateTenant(ctx, store.Tenant{
|
||||||
|
ID: id, Name: id, Email: id + "@example.com", Tier: store.TierPro, MaxRoutes: 10,
|
||||||
|
AllowedDomains: domains, ValidUntil: time.Now().AddDate(1, 0, 0),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueToken erzeugt einen Enrollment-Token wie `sultan token` es tun wird.
|
||||||
|
func issueToken(t *testing.T, s *Server, tenant string) string {
|
||||||
|
t.Helper()
|
||||||
|
tok, err := crypto.NewEnrollmentToken(s.Fingerprint())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.store.AddEnrollmentToken(ctx, tok.Secret, tenant, time.Now().Add(time.Hour)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tok.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// testAgent spielt die Agent-Seite mit Pinning und Signatur.
|
||||||
|
type testAgent struct {
|
||||||
|
base string
|
||||||
|
hc *http.Client
|
||||||
|
signer *auth.Signer
|
||||||
|
secret string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAgent(t *testing.T, s *Server, token string) *testAgent {
|
||||||
|
t.Helper()
|
||||||
|
tok, err := crypto.ParseEnrollmentToken(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ks := &crypto.KnownServers{Path: filepath.Join(t.TempDir(), "known_servers")}
|
||||||
|
priv, _ := auth.GenerateKey()
|
||||||
|
return &testAgent{
|
||||||
|
base: "https://" + s.AgentAddr(),
|
||||||
|
hc: &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.ClientTLSConfig(s.AgentAddr(), ks, tok.Fingerprint)}},
|
||||||
|
signer: &auth.Signer{Key: priv},
|
||||||
|
secret: tok.Secret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *testAgent) do(t *testing.T, method, path string, body any) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
var rd io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
rd = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(method, a.base+path, rd)
|
||||||
|
if err := a.signer.Sign(req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp, err := a.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s: %v", method, path, err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { resp.Body.Close() })
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *testAgent) enroll(t *testing.T, nodeID string) *http.Response {
|
||||||
|
return a.do(t, http.MethodPost, "/api/v1/enroll", EnrollRequest{Token: a.secret, NodeID: nodeID, Hostname: "host-" + nodeID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// connect hält den Stream im Hintergrund offen, bis cancel aufgerufen wird.
|
||||||
|
func (a *testAgent) connect(t *testing.T) (cancel func()) {
|
||||||
|
t.Helper()
|
||||||
|
sctx, cancel := context.WithCancel(ctx)
|
||||||
|
c := &stream.Client{URL: a.base + "/api/v1/stream", HTTPClient: a.hc, Prepare: a.signer.Sign, MinBackoff: 10 * time.Millisecond}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { c.Run(sctx, func(stream.Event) error { return nil }); close(done) }()
|
||||||
|
t.Cleanup(func() { cancel(); <-done })
|
||||||
|
return cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
func wpState(domain string) registry.NodeState {
|
||||||
|
return registry.NodeState{Workloads: []registry.Workload{{
|
||||||
|
Name: "wordpress", Host: "100.64.0.42",
|
||||||
|
Labels: map[string]string{
|
||||||
|
"traefik.enable": "true",
|
||||||
|
"traefik.http.routers.wp.rule": "Host(`" + domain + "`)",
|
||||||
|
"traefik.http.services.wp.loadbalancer.server.port": "80",
|
||||||
|
},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerConfig(t *testing.T, s *Server) string {
|
||||||
|
t.Helper()
|
||||||
|
resp, err := http.Get("http://" + s.ProviderAddr() + "/v1/traefik-config")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigDefaults(t *testing.T) {
|
||||||
|
c := Config{}.withDefaults()
|
||||||
|
if c.DataDir != "./suldata" || c.AgentAddr != ":8443" || c.ProviderAddr != "127.0.0.1:8080" {
|
||||||
|
t.Errorf("Defaults: %+v", c)
|
||||||
|
}
|
||||||
|
if c.InterceptorURL != "http://127.0.0.1:8081" || c.RefreshInterval != time.Minute || c.Logger == nil {
|
||||||
|
t.Errorf("Defaults: %+v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_CreatesDataDir(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "a", "suldata")
|
||||||
|
s, err := New(ctx, Config{DataDir: dir, Logger: slog.New(slog.DiscardHandler)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
defer s.Shutdown(ctx)
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil || info.Mode().Perm() != 0o700 {
|
||||||
|
t.Fatalf("Datenverzeichnis: %v, %v", info, err)
|
||||||
|
}
|
||||||
|
for _, f := range []string{"sultan.db", "server.key", "server.crt", "master.key"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
|
||||||
|
t.Errorf("%s fehlt: %v", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if info, _ := os.Stat(filepath.Join(dir, "master.key")); info.Mode().Perm() != 0o600 {
|
||||||
|
t.Errorf("master.key hat %o", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach einem Neustart müssen Zertifikat und Master-Key gleich bleiben,
|
||||||
|
// sonst sperren sich alle Agents aus bzw. alle Lizenzen werden ungültig.
|
||||||
|
func TestNew_StableIdentityAcrossRestarts(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "suldata")
|
||||||
|
cfg := Config{DataDir: dir, Logger: slog.New(slog.DiscardHandler)}
|
||||||
|
a, err := New(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fp, mk := a.Fingerprint(), a.MasterPublicKey()
|
||||||
|
a.Shutdown(ctx)
|
||||||
|
|
||||||
|
b, err := New(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Neustart: %v", err)
|
||||||
|
}
|
||||||
|
defer b.Shutdown(ctx)
|
||||||
|
if b.Fingerprint() != fp || !b.MasterPublicKey().Equal(mk) {
|
||||||
|
t.Error("Identität hat sich beim Neustart geändert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderPort(t *testing.T) {
|
||||||
|
s, _ := startServer(t)
|
||||||
|
if got := providerConfig(t, s); got != "{}" {
|
||||||
|
t.Errorf("leere Konfiguration = %q", got)
|
||||||
|
}
|
||||||
|
resp, err := http.Get("http://" + s.ProviderAddr() + "/healthz")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("/healthz: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// Agent-Routen gibt es auf dem Klartext-Port nicht.
|
||||||
|
resp, _ = http.Post("http://"+s.ProviderAddr()+"/api/v1/enroll", "application/json", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("Agent-Route auf Provider-Port: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentPort_NoPlainHTTP(t *testing.T) {
|
||||||
|
s, _ := startServer(t)
|
||||||
|
resp, err := http.Get("http://" + s.AgentAddr() + "/api/v1/stream")
|
||||||
|
if err == nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
t.Error("Agent-Port antwortet im Klartext mit 200")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ablauf wie im Betrieb: Enrollment, Stream, Zustand melden, Route bei
|
||||||
|
// Traefik, Agent weg → Route weg und Knoten offline.
|
||||||
|
func TestEndToEnd(t *testing.T) {
|
||||||
|
s, _ := startServer(t)
|
||||||
|
createTenant(t, s, "kunde", "kunde.de", "*.kunde.de")
|
||||||
|
agent := newAgent(t, s, issueToken(t, s, "kunde"))
|
||||||
|
|
||||||
|
resp := agent.enroll(t, "node-1")
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("Enrollment: %d %s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
var er EnrollResponse
|
||||||
|
json.NewDecoder(resp.Body).Decode(&er)
|
||||||
|
if er.NodeID != "node-1" || er.TenantID != "kunde" || er.MasterPublicKey != auth.EncodePublicKey(s.MasterPublicKey()) {
|
||||||
|
t.Errorf("Antwort: %+v", er)
|
||||||
|
}
|
||||||
|
|
||||||
|
stop := agent.connect(t)
|
||||||
|
eventually(t, "Stream verbunden", func() bool { return s.hub.Connected("node-1") })
|
||||||
|
eventually(t, "Knoten online", func() bool {
|
||||||
|
n, _ := s.store.GetNode(ctx, "node-1")
|
||||||
|
return n.Status == store.NodeOnline
|
||||||
|
})
|
||||||
|
|
||||||
|
if resp := agent.do(t, http.MethodPost, "/api/v1/state", wpState("kunde.de")); resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("State: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if cfg := providerConfig(t, s); !strings.Contains(cfg, "kunde__node-1__wp") || !strings.Contains(cfg, "http://100.64.0.42:80") {
|
||||||
|
t.Errorf("Traefik-Konfiguration: %s", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
stop()
|
||||||
|
eventually(t, "Route nach Trennung entfernt", func() bool { return providerConfig(t, s) == "{}" })
|
||||||
|
eventually(t, "Knoten offline", func() bool {
|
||||||
|
n, _ := s.store.GetNode(ctx, "node-1")
|
||||||
|
return n.Status == store.NodeOffline
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnroll_Errors(t *testing.T) {
|
||||||
|
s, _ := startServer(t)
|
||||||
|
createTenant(t, s, "kunde", "kunde.de")
|
||||||
|
|
||||||
|
used := newAgent(t, s, issueToken(t, s, "kunde"))
|
||||||
|
if r := used.enroll(t, "n1"); r.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("erstes Enrollment: %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
again := newAgent(t, s, issueToken(t, s, "kunde"))
|
||||||
|
again.secret = used.secret // bereits eingelöst
|
||||||
|
if r := again.enroll(t, "n2"); r.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("verbrauchter Token: %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
unknown := newAgent(t, s, issueToken(t, s, "kunde"))
|
||||||
|
unknown.secret = crypto.TokenPrefix + strings.Repeat("A", 43)
|
||||||
|
if r := unknown.enroll(t, "n3"); r.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("unbekannter Token: %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if r := newAgent(t, s, issueToken(t, s, "kunde")).enroll(t, "a__b"); r.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("ungültige Knoten-ID: %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
if r := newAgent(t, s, issueToken(t, s, "kunde")).enroll(t, "n1"); r.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("doppelte Knoten-ID: %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne Signatur gibt es nichts.
|
||||||
|
plain := newAgent(t, s, issueToken(t, s, "kunde"))
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, plain.base+"/api/v1/enroll", strings.NewReader(`{}`))
|
||||||
|
resp, err := plain.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("unsigniert: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestState_Errors(t *testing.T) {
|
||||||
|
s, _ := startServer(t)
|
||||||
|
createTenant(t, s, "kunde", "kunde.de")
|
||||||
|
agent := newAgent(t, s, issueToken(t, s, "kunde"))
|
||||||
|
agent.enroll(t, "n1")
|
||||||
|
|
||||||
|
// Zustand nur bei offenem Stream (Lebendigkeit hängt am Stream).
|
||||||
|
if r := agent.do(t, http.MethodPost, "/api/v1/state", wpState("kunde.de")); r.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("ohne Stream: %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
agent.connect(t)
|
||||||
|
eventually(t, "Stream", func() bool { return s.hub.Connected("n1") })
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, agent.base+"/api/v1/state", strings.NewReader("kein json"))
|
||||||
|
agent.signer.Sign(req)
|
||||||
|
resp, err := agent.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("kaputtes JSON: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
stranger := newAgent(t, s, issueToken(t, s, "kunde")) // nicht enrollt
|
||||||
|
if r := stranger.do(t, http.MethodPost, "/api/v1/state", wpState("kunde.de")); r.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("nicht registriert: %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestState_ViolationsAreLogged(t *testing.T) {
|
||||||
|
s, logs := startServer(t)
|
||||||
|
createTenant(t, s, "kunde", "kunde.de")
|
||||||
|
agent := newAgent(t, s, issueToken(t, s, "kunde"))
|
||||||
|
agent.enroll(t, "n1")
|
||||||
|
agent.connect(t)
|
||||||
|
eventually(t, "Stream", func() bool { return s.hub.Connected("n1") })
|
||||||
|
|
||||||
|
if r := agent.do(t, http.MethodPost, "/api/v1/state", wpState("bank.de")); r.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("State: %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
if cfg := providerConfig(t, s); cfg != "{}" {
|
||||||
|
t.Errorf("fremde Domain ausgeliefert: %s", cfg)
|
||||||
|
}
|
||||||
|
out := logs.String()
|
||||||
|
if !strings.Contains(out, "Policy-Verstoß") || !strings.Contains(out, "kind=domain") || !strings.Contains(out, "node=n1") {
|
||||||
|
t.Errorf("Audit-Log fehlt:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshLoopAppliesTenantChanges(t *testing.T) {
|
||||||
|
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond })
|
||||||
|
createTenant(t, s, "kunde", "kunde.de")
|
||||||
|
agent := newAgent(t, s, issueToken(t, s, "kunde"))
|
||||||
|
agent.enroll(t, "n1")
|
||||||
|
agent.connect(t)
|
||||||
|
eventually(t, "Stream", func() bool { return s.hub.Connected("n1") })
|
||||||
|
agent.do(t, http.MethodPost, "/api/v1/state", wpState("kunde.de"))
|
||||||
|
|
||||||
|
if err := s.store.SetTenantStatus(ctx, "kunde", store.TenantSuspendedPayment); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
eventually(t, "Umleitung zum Interceptor", func() bool {
|
||||||
|
return strings.Contains(providerConfig(t, s), policy.InterceptorService)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirectory(t *testing.T) {
|
||||||
|
s, _ := startServer(t)
|
||||||
|
createTenant(t, s, "kunde", "kunde.de")
|
||||||
|
if _, err := s.store.CreateNode(ctx, store.Node{ID: "n1", TenantID: "kunde", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
d := &directory{store: s.store}
|
||||||
|
|
||||||
|
nc, err := d.NodeContext(ctx, "n1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if nc.Tenant.ID != "kunde" || nc.License.TenantID != "kunde" || nc.License.MaxRoutes != 10 ||
|
||||||
|
!nc.License.AllowsDomain("kunde.de") || nc.Drained {
|
||||||
|
t.Errorf("NodeContext: %+v", nc)
|
||||||
|
}
|
||||||
|
s.store.SetNodeStatus(ctx, "n1", store.NodeDrained)
|
||||||
|
if nc, _ := d.NodeContext(ctx, "n1"); !nc.Drained {
|
||||||
|
t.Error("gedraint nicht erkannt")
|
||||||
|
}
|
||||||
|
if _, err := d.NodeContext(ctx, "fehlt"); !errors.Is(err, registry.ErrUnknownNode) {
|
||||||
|
t.Errorf("unbekannter Knoten: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Herunterfahren darf nicht an offenen SSE-Streams hängen bleiben.
|
||||||
|
func TestRun_ShutdownWithOpenStream(t *testing.T) {
|
||||||
|
cfg := Config{
|
||||||
|
DataDir: filepath.Join(t.TempDir(), "suldata"), AgentAddr: "127.0.0.1:0", ProviderAddr: "127.0.0.1:0",
|
||||||
|
Logger: slog.New(slog.DiscardHandler),
|
||||||
|
}
|
||||||
|
s, err := New(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runCtx, cancel := context.WithCancel(ctx)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- s.Run(runCtx) }()
|
||||||
|
eventually(t, "gestartet", func() bool { return s.AgentAddr() != "" && !strings.HasSuffix(s.AgentAddr(), ":0") })
|
||||||
|
|
||||||
|
createTenant(t, s, "kunde", "kunde.de")
|
||||||
|
agent := newAgent(t, s, issueToken(t, s, "kunde"))
|
||||||
|
agent.enroll(t, "n1")
|
||||||
|
agent.connect(t)
|
||||||
|
eventually(t, "Stream", func() bool { return s.hub.Connected("n1") })
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Run: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Run hängt beim Herunterfahren")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStart_PortInUse(t *testing.T) {
|
||||||
|
a, _ := startServer(t)
|
||||||
|
b, err := New(ctx, Config{
|
||||||
|
DataDir: filepath.Join(t.TempDir(), "suldata"), AgentAddr: a.AgentAddr(), ProviderAddr: "127.0.0.1:0",
|
||||||
|
Logger: slog.New(slog.DiscardHandler),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer b.Shutdown(ctx)
|
||||||
|
if err := b.Start(); err == nil {
|
||||||
|
t.Error("belegter Port: erwartet Fehler")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user