diff --git a/.ai/sultan-system-architecture.md b/.ai/sultan-system-architecture.md index b77785c..599cb47 100644 --- a/.ai/sultan-system-architecture.md +++ b/.ai/sultan-system-architecture.md @@ -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: * **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 `____`. + * 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 @@ -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 (``, 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) 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) -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). * **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 1. **Watcher Engine:** @@ -311,6 +418,37 @@ sultan 3. **Policy & Interception Engine:** * **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`). +* **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 container_name: sultan-server 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: - "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: - - ./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` volumes: - /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: 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 | **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 | 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. | + +--- + +## 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. diff --git a/.gitignore b/.gitignore index 5b90e79..899f360 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,8 @@ go.work.sum # env file .env + +# sultan: Datenverzeichnis (enthält server.key, master.key, node.key!) und Binary +/suldata/ +suldata/ +/sultan diff --git a/CLAUDE.md b/CLAUDE.md index 1fb1b4e..c23234e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,17 +9,26 @@ Maßgeblich ist aktuell [.ai/sultan-system-architecture.md](.ai/sultan-system-ar ## 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/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/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. - [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. ```bash +go build -o sultan . && ./sultan server # Daten in ./suldata (gitignored, enthält Schlüssel!) go vet ./... go test -race ./... # alle Tests 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`. -**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. diff --git a/README.md b/README.md index 2d389f8..9fd6a2a 100644 --- a/README.md +++ b/README.md @@ -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 cd sultan go build -o sultan . +./sultan version sudo install -m 0755 sultan /usr/local/bin/sultan ``` ### Server starten (Ingress-Knoten) ```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 --providers.http.endpoint=http://127.0.0.1:8080/v1/traefik-config diff --git a/main.go b/main.go new file mode 100644 index 0000000..eaa1042 --- /dev/null +++ b/main.go @@ -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) +} diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go new file mode 100644 index 0000000..ba3328c --- /dev/null +++ b/pkg/cli/cli.go @@ -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 +} diff --git a/pkg/cli/cli_test.go b/pkg/cli/cli_test.go new file mode 100644 index 0000000..f978513 --- /dev/null +++ b/pkg/cli/cli_test.go @@ -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) + } +} diff --git a/pkg/license/domain.go b/pkg/license/domain.go new file mode 100644 index 0000000..d866d94 --- /dev/null +++ b/pkg/license/domain.go @@ -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 +} diff --git a/pkg/license/domain_test.go b/pkg/license/domain_test.go new file mode 100644 index 0000000..0389dbd --- /dev/null +++ b/pkg/license/domain_test.go @@ -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) +} diff --git a/pkg/license/license.go b/pkg/license/license.go new file mode 100644 index 0000000..e333ff4 --- /dev/null +++ b/pkg/license/license.go @@ -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) } diff --git a/pkg/license/license_test.go b/pkg/license/license_test.go new file mode 100644 index 0000000..c7fa4e3 --- /dev/null +++ b/pkg/license/license_test.go @@ -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) + } +} diff --git a/pkg/policy/policy.go b/pkg/policy/policy.go new file mode 100644 index 0000000..5c753fb --- /dev/null +++ b/pkg/policy/policy.go @@ -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 +} diff --git a/pkg/policy/policy_test.go b/pkg/policy/policy_test.go new file mode 100644 index 0000000..9e96870 --- /dev/null +++ b/pkg/policy/policy_test.go @@ -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) + } + } +} diff --git a/pkg/policy/rule.go b/pkg/policy/rule.go new file mode 100644 index 0000000..e842273 --- /dev/null +++ b/pkg/policy/rule.go @@ -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) + } +} diff --git a/pkg/policy/rule_test.go b/pkg/policy/rule_test.go new file mode 100644 index 0000000..e605c77 --- /dev/null +++ b/pkg/policy/rule_test.go @@ -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) + } + } +} diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go new file mode 100644 index 0000000..ceb3e1d --- /dev/null +++ b/pkg/registry/registry.go @@ -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 +} diff --git a/pkg/registry/registry_test.go b/pkg/registry/registry_test.go new file mode 100644 index 0000000..7d7c82d --- /dev/null +++ b/pkg/registry/registry_test.go @@ -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) + } +} diff --git a/pkg/server/directory.go b/pkg/server/directory.go new file mode 100644 index 0000000..c73ab0d --- /dev/null +++ b/pkg/server/directory.go @@ -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 +} diff --git a/pkg/server/handlers.go b/pkg/server/handlers.go new file mode 100644 index 0000000..84111e9 --- /dev/null +++ b/pkg/server/handlers.go @@ -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 +} diff --git a/pkg/server/server.go b/pkg/server/server.go new file mode 100644 index 0000000..3ccf494 --- /dev/null +++ b/pkg/server/server.go @@ -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) +} diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go new file mode 100644 index 0000000..2eaa95f --- /dev/null +++ b/pkg/server/server_test.go @@ -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") + } +}