software steht so weit.

jetzt build + docker build und testen
This commit is contained in:
br committed 2026-10-02 13:25:34 +02:00
1 parent 42f0c0accb
commit ca6cdcca63
90 files changed
+10278 -460

No files matched your search

+308 -71
View File
@@ -101,22 +101,34 @@ Klassische Ingress-Lösungen wie Nginx Proxy Manager (NPM) erfordern eine manuel
* Der Server authentifiziert nur Requests, die über TLS angekommen sind (sonst `426 Upgrade Required`).
* **Server-Zertifikat:** Wird beim ersten Start selbst erzeugt (selbstsigniert, ECDSA P-256, **keine CA**) und liegt dauerhaft unter `<data-dir>/server.key` (0600) und `server.crt`. Gültigkeit 20 Jahre, weil Agents den Fingerprint pinnen. Fehlt nur eine der beiden Dateien, startet der Server nicht, statt still ein neues Zertifikat zu erzeugen (das würde alle Pins brechen).
* **Fingerprint:** SHA-256 über das DER-Zertifikat, Base64url ohne Padding (43 Zeichen).
* **Vertrauensmodell: Pinning mit geprüftem Erstkontakt** (statt reinem TOFU, siehe 3.4):
* Der Agent speichert den Fingerprint je Server (`host:port`) in `known_servers` (Format wie SSH `known_hosts`: `<server> <fingerprint>`, 0600; Einträge werden nur angehängt, nie still geändert).
* **Erstkontakt:** Der Fingerprint muss zu dem im Enrollment-Token eingebetteten Wert passen (3.2), erst dann wird gepinnt.
* **Folgekontakte:** Das Zertifikat muss zum Pin passen. Der Pin gewinnt immer, auch gegen einen später gelieferten Token-Fingerprint.
* **Ohne Pin und ohne Token-Fingerprint** wird nicht verbunden (kein blindes TOFU).
* Die Prüfung erfolgt im TLS-Handshake, also bevor Token, Signatur oder Body gesendet werden.
* **Vertrauensmodell: Pinning am Fingerprint, unabhängig von der Adresse** (statt reinem TOFU, siehe 3.4):
* **Erstkontakt:** Der Fingerprint kommt aus der Einladung (3.2). Passt das Zertifikat nicht, wird abgebrochen.
* **Danach:** Der Fingerprint steht als `server_fingerprint` in `node.json` und wird bei **jeder** Verbindung geprüft, über welche Adresse auch immer (DNS, interne IP, NetBird). Derselbe Server hat überall denselben Fingerprint, deshalb ist ein Adresswechsel gefahrlos. Ein **anderer** Fingerprint bedeutet einen anderen Server und erfordert ein neues Enrollment.
* **Ohne Fingerprint** wird nicht verbunden (kein blindes TOFU).
* Die Prüfung erfolgt im TLS-Handshake (`crypto.PinnedTLSConfig`), also bevor Token, Signatur oder Body gesendet werden. Hostname und CA werden bewusst nicht geprüft; das Pinning ist strenger.
* **Optional (geplant):** Unterstützung für explizit gemountete Root-CA-Zertifikate.
### 3.2 Authentifizierung & Enrollment (CSR-Handshake)
* Private Keys werden **ausschließlich lokal auf dem Ziel-Host** generiert (Ed25519) und verlassen diesen niemals. `node.key` liegt als PKCS#8-PEM mit Rechten 0600 vor, wird nie überschrieben und bei offeneren Rechten nicht geladen.
* **Authentifizierung ist in jedem Tier Pflicht** – auch im `community`-/Homelab-Betrieb gibt es keinen Zugang ohne Ed25519-Signatur und keinen Schalter, der das abstellt.
* **Enrollment-Token:** `sltn_enroll_<secret>.<fingerprint>`
* `secret`: 256 Bit Zufall (Base64url). Der Server speichert nur den SHA-256 von `sltn_enroll_<secret>`, einmalig verwendbar, mit Ablaufzeit.
* `fingerprint`: Fingerprint des Server-Zertifikats (3.1). Nicht geheim; er sichert den Erstkontakt des Agenten ab.
* Der Admin überträgt den Token auf einem separaten Weg (z. B. Copy-Paste aus dem Server-Terminal) an den Agenten.
* **Enrollment-Token:** `sltn_enroll_<secret>.<fingerprint>[.<server-url>]`. Alle Teile sind Base64url; der Token ist ohne Quoting shell-sicher (~130 Zeichen mit URL).
* `secret`: 256 Bit Zufall. Der Server speichert nur den SHA-256 von `sltn_enroll_<secret>`, einmalig verwendbar, mit Ablaufzeit.
* `fingerprint`: Fingerprint des Server-Zertifikats (3.1). Nicht geheim; er sichert den Erstkontakt ab.
* `server-url` (optional, nicht geheim): Adresse, unter der der Agent den Server erreicht.
* Nur `https://host[:port]`: kein Pfad, keine Query, keine Zugangsdaten, max. 255 Zeichen.
* Woher sie kommt: `sultan server --public-url` (wird im Store gemerkt) oder pro Einladung `sultan token invite --server-url`. So kann ein Knoten z. B. eine NetBird-Adresse bekommen.
* Ohne Adresse entsteht der zweiteilige Token; der Agent braucht dann `--server`.
* Eine manipulierte Adresse führt dank Pinning höchstens zu einem Verbindungsfehler, nie zum Versand des Secrets an einen fremden Server.
* Der Admin überträgt den Token auf einem vertrauenswürdigen Weg (z. B. Copy-Paste aus dem Server-Terminal). Wer den Token manipulieren kann, könnte auch den Fingerprint tauschen.
* **Server-Adresse beim Agent:** Beim Enrollment gilt `--server`/`SULTAN_SERVER`, sonst die URL aus der Einladung. Danach versucht der Agent in dieser Reihenfolge:
1. `--server`
2. die zuletzt funktionierende Adresse (`node.json` `server`)
3. die Adressliste des Servers (`server_urls`)
Nach jedem Verbindungsfehler wechselt er zur nächsten Adresse; eine erfolgreich genutzte wird als `server` gespeichert. Das ist gefahrlos, weil der Pin am Fingerprint hängt (Schritt 4).
* **Umgebungsvariablen** `SULTAN_TOKEN`/`SULTAN_SERVER` statt Argumenten, z. B. für `curl … | sh` oder Compose. Argumente wären über `ps` für andere Benutzer sichtbar.
* Eine in Compose stehen gebliebene, bereits eingelöste Einladung **desselben** Servers ist harmlos und wird ignoriert. Eine Einladung eines **anderen** Servers bei bestehender Registrierung wird abgelehnt.
* **Enrollment:**
1. Admin erzeugt den Einladungs-Token auf dem Server (`sultan token`).
2. Agent zerlegt den Token, verbindet sich per TLS und prüft dabei den Server-Fingerprint gegen den Token (3.1). Erst danach pinnt er den Server.
@@ -184,7 +196,7 @@ Umsetzung in `pkg/policy`. Jeder Verstoß entfernt nur den betroffenen Router un
* unsignierte Server-Events (`node_command`, `lease_renew`) einschleusen.
* **Gegenmaßnahme (umgesetzt):** Der Fingerprint ist im Enrollment-Token eingebettet (3.1/3.2). Damit gibt es keinen ungeprüften Erstkontakt mehr. Ein Angreifer scheitert im TLS-Handshake und sieht weder Token noch Signatur; der Token bleibt beim echten Server einlösbar.
* **Betriebsrisiko:** Ein Verlust von `<data-dir>/server.key` erzeugt ein neues Zertifikat und sperrt alle Agents aus. Deshalb muss `server.key` gesichert werden. *(Geplant:)* geordneter Zertifikatswechsel, bei dem der Server das neue Zertifikat vorab über den gepinnten Kanal ankündigt.
* **Zweite Schicht (geplant):** Server→Agent-Events werden mit einem Server-Signaturschlüssel signiert, den der Agent beim Enrollment lernt. Die Echtheit von `node_command` hängt dann nicht mehr allein am TLS-Pin.
* **Zweite Schicht (umgesetzt, Schritt 2):** Jedes Server→Agent-Event ist mit dem Master-Key signiert (`signature`, Präfix `SULTAN-EVENT-V1`, über alle übrigen Felder inkl. `event_id`, `timestamp`, `node_id`). Der Agent kennt den Public Key seit dem Enrollment (`node.json`) und verwirft Events ohne gültige Signatur. Die Echtheit von `node_command` hängt damit nicht mehr allein am TLS-Pin.
---
@@ -237,34 +249,62 @@ Umsetzung in `pkg/policy`. Jeder Verstoß entfernt nur den betroffenen Router un
* **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.
* **Umsetzung (Schritt 2):**
* Lease-Dokument: Lizenzfelder plus `node_id`, `issued_at`, `renew_after`, `valid_until`, signiert mit dem Master-Key unter dem Präfix `SULTAN-LEASE-V1` (gilt nie als Lizenz und umgekehrt).
* Community: 14 Tage, Erneuerung nach 7 Tagen ± 12 h Streuung. Kommerziell: 7 Tage (10 bei Wartung), Erneuerung nach 2/3, gekappt am Vertragsende; nach Vertragsende keine Lease.
* Der Server stellt beim Verbinden aus bzw. erneut zu und erneuert bei jedem Refresh ab `renew_after` (`lease_renew`).
* Der Agent prüft die Signatur, legt `lease.json` ab und protokolliert. Weitere Folgen hat das beim Agent vorerst nicht; ein Checkmk-Check auf Kundenseite ist vorgesehen.
* **Wirkung serverseitig:** Ein getrennter Knoten behält seine Routen als Wartungsseite, bis die Lease abläuft.
* `sultan server --lease-duration` (Konfigurationsfeld `LeaseDuration`) erzwingt eine feste Laufzeit, für Tests und Sonderfälle.
* **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)
### 4.1b Agent-Konfiguration `/etc/sultan.d/*.yaml` (umgesetzt 2026-10-02)
* **Format: YAML.** Eine Datei beschreibt einen oder mehrere Dienste mit allem, was Traefik-Labels hergeben würden, plus Komfortschaltern. Beispiel (Schema noch auszuarbeiten):
* **Format: YAML**, eine oder mehrere Dateien `*.yaml`/`*.yml` (Verzeichnis per `--config-dir`, Default `/etc/sultan.d`, `none` = aus). Unbekannte Felder sind Fehler.
```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
- name: kunde-a # a–z, 0–9, - und _ (Router-/Service-Name)
url: https://127.0.0.1:8443/ # lokaler Dienst; sein Port gilt auch für den Ingress
hosts: [kunde-a.de] # keine Wildcards
https: true # Router auf websecure mit TLS
certresolver: dns-resolver # optional
entrypoints: [websecure] # optional, Default websecure (https) bzw. web
redirect_http_to_https: true # zweiter Router auf web mit redirectScheme (nur mit https)
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"
maintenance: false # true = Dienst in Wartung (Wartungsseite)
maintenance_public_reason: "" # optional, ersetzt den Standardtext
healthcheck: # optional
url: https://127.0.0.1:8443/ # Default: url des Dienstes
interval: 30s # Default 30s
timeout: 5s # Default 5s
failures: 2 # Fehlschläge in Folge bis "down", Default 2
expect_status: 200 # Default 200
expect_title: "KundeA - Willkommen" # optional, exakter <title> (Leerraum normalisiert)
expect_contains: { tag: h2, text: Impressum } # optional, Element enthält Text
labels: # optional: rohe traefik.*-Labels, überschreiben die erzeugten
traefik.http.middlewares.kunde-a-www.redirectregex.permanent: "false"
```
* **Healthcheck → `service_down`:** Der Agent ruft den lokalen Dienst ab und erwartet `200 OK`, optional auch einfache Inhaltsprüfungen (`<title>`, Tag mit Text). Ein Fehlschlag meldet `service_down`, die Policy setzt `maintenance`.
* **Backend-Adresse/NetBird:** Interface (z. B. `wt0`) und Port werden **konfiguriert**; die IP wird über das Interface automatisch ermittelt. Ports lassen sich nicht zuverlässig erraten. Als Vorschlag dienen typische Ports: tcp/8080, tcp/80, tcp/443, tcp/3000, tcp/8000, tcp/8443.
* **Übersetzung in Traefik-Labels** (danach gelten dieselben Regeln wie für Docker: `FromLabels`, Policy, Namensraum):
* Workload-Name `sultan.d/<name>`; Router und Service heißen `<name>`.
* Backend: `<backend-host>:<port der url>`; `https`-URL → `loadbalancer.server.scheme=https`. Ohne Port 80/443.
* `redirect_www`:
* Middleware `<name>-www` (redirectRegex, permanent), z. B. `^https?://www\.(kunde-a\.de)(.*)` → `https://${1}${2}`.
* Die jeweils fehlende Variante (`www.`/ohne) wird **automatisch** in die Hosts aufgenommen, sonst käme die Anfrage nie beim Router an.
* Die Policy prüft beide Varianten gegen `allowed_domains`.
* `redirect_http_to_https`: Router `<name>-http` auf `web` mit Middleware `<name>-https` (redirectScheme `https`, permanent).
* **Healthcheck → Wartungsseite:**
* Der Agent prüft regelmäßig Status, optional `<title>` und ein Element mit Text. Das ist ein einfacher Textabgleich ohne HTML-Bibliothek.
* Nach `failures` Fehlschlägen in Folge gilt der Dienst als `down`, nach einem Erfolg wieder als `up` (gegen Flattern).
* Ausgefallene Dienste stehen in `down_services`; die Policy leitet ihre Router auf `maintenance` um (503 über den Interceptor).
* **Änderungen:** Das Verzeichnis wird per **Polling** geprüft (Default alle 5 s, Inhaltsvergleich), nicht per inotify. Polling ist zuverlässig auch bei Docker-Bind-Mounts und atomar ersetzten Dateien und braucht keine Abhängigkeit.
* Bei Änderungen werden die Dienste neu geladen, Healthchecks neu gestartet und der Zustand gesendet.
* Eine fehlerhafte Datei oder ein doppelter Dienstname wird mit Warnung übersprungen; die übrigen Dateien bleiben aktiv.
* **Backend-Adresse/NetBird:** wie bei Docker (`--backend-host`, `--backend-interface`, sonst automatisch).
### 4.2 Stream Event Protocol (Agent <-> Server)
@@ -283,8 +323,13 @@ Beide Richtungen nutzen dasselbe JSON-Event-Format. Server→Agent läuft über
* **Typ `service_state` (Agent -> Server):**
Enthält die vollständige Liste der aktuell aktiven Traefik-Labels des Knotens.
* **Typ `node_command` (Server -> Agent):**
Enthält Aktionen wie `re_enroll`, `drain`, `maintenance`.
* **Typ `node_command` (Server -> Agent):** Payload `{command, reason}`, feste Liste:
* `resync`: Agent sendet sofort seinen Zustand.
* `rotate_key`: Agent legt `node.key.new` an und meldet ihn (`POST /api/v1/rotate-key`). Erst nach Erfolg wird umbenannt. Ein Abbruch wird beim nächsten Start sicher abgeschlossen: Lehnt der Server den alten Schlüssel ab, wird mit dem neuen wiederholt.
* `drain`, `maintenance`, `resume`: Hinweise, der Agent protokolliert nur.
* `config`: Adressliste des Servers (`server_urls`), wird beim Verbinden direkt gesendet (nicht über die Warteschlange). Der Agent übernimmt nur gültige `https`-Adressen nach `node.json`.
Die CLI reiht Befehle in `node_commands` ein, der Server stellt sie beim Verbinden und bei jedem Refresh zu.
* **Typ `lease_renew` (Server -> Agent):**
Liefert ein verlängertes Krypto-Lease an den Agenten zurück.
@@ -297,6 +342,14 @@ Der Server persistiert Mandanten, Tokens und Richtlinien lokal in SQLite (`<data
* **Migrationen:** Versioniert über `PRAGMA user_version`, nur anhängen. Eine Datenbank mit neuerer Version als das Programm wird abgelehnt.
* **Zeitstempel:** Die Anwendung setzt sie selbst (keine `CURRENT_TIMESTAMP`-Defaults), als UTC-Text fester Breite (`2006-01-02T15:04:05.000000000Z`). Dadurch funktionieren Vergleiche direkt in SQL.
* **Tokens:** Gespeichert wird nur der SHA-256-Hash.
* **Migration v2:** Tabelle `settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)` für Server-Einstellungen, die auch `sultan token` braucht (derzeit `public_url`).
* **Migration v3:** Wartung pro Knoten – `nodes.maintenance_since`, `maintenance_until` (NULL = unbefristet), `maintenance_reason`, `maintenance_public_reason`.
* **Migration v5:** Tabelle `notifications (key, sent_at)` für „jedes Ereignis genau einmal“.
* **Migration v4:**
* Tabelle `leases (node_id, issued_at, renew_after, valid_until, document)`
* Tabelle `node_commands (id, node_id, command, reason, created_at, sent_at)`
* `tenants.features` (JSON) und `tenants.payment_overdue_since`; daraus Lizenz-Features bzw. Mahnstufe
* `enrollment_tokens.used_at`
Schema (Stand Migration v1):
@@ -362,8 +415,21 @@ sultan
|---|---|---|
| `--data-dir` | `./suldata` | `sultan.db`, `server.key`/`server.crt`, `master.key` |
| `--agent-addr` | `:8443` | Agent-Port, nur TLS 1.3 |
| `--public-url` (mehrfach) | – | Adressen, unter denen Agents den Server erreichen (z. B. öffentlich und NetBird); werden im Store gemerkt (`settings.public_urls`). Die **erste** steckt in Einladungen; die ganze Liste bekommen Agents beim Verbinden per `config`-Befehl |
| `--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 |
| `--lander-addr` | `127.0.0.1:8081` | eingebauter Lander; `none` = aus (externer Lander) |
| `--interceptor-url` | Adresse des Landers | URL, unter der Traefik den Lander erreicht |
| `--hoster-name`, `--hoster-url`, `--pro-url` | – | Texte und Links der Lander-Seiten |
| `--notify-slack`, `--notify-ntfy` (mehrfach), `--notify-ntfy-token` | – | Benachrichtigungsziele |
| `--offline-alert-after` | `15m` | Alarm/CRIT für getrennte Knoten |
| `--lease-duration` | `0` (je Tier) | feste Lease-Laufzeit (Tests, Sonderfälle) |
| `--enroll-limit` | `10` | Enrollment-Versuche pro Minute und Client-IP, gleitendes Fenster; darüber `429` mit `Retry-After`; `-1` = unbegrenzt. Geprüft **vor** der Signaturprüfung |
**Umgebungsvariablen:** Jedes Flag von `server`, `agent` und `token` ist auch als `SULTAN_<FLAG>` setzbar (`--public-url` → `SULTAN_PUBLIC_URL`, `--notify-ntfy-token` → `SULTAN_NOTIFY_NTFY_TOKEN`, `--data-dir` → `SULTAN_DATA_DIR`).
* Listen sind kommagetrennt.
* **Flags gewinnen**; bei Listen ersetzt ein Flag den Wert aus der Umgebung, statt ihn zu ergänzen.
* Die Hilfe (`--help`) nennt zu jedem Flag die Variable; fehlerhafte Werte werden mit dem Variablennamen gemeldet.
* Geheimnisse wie `SULTAN_NOTIFY_NTFY_TOKEN` oder `SULTAN_TOKEN` gehören in die Umgebung statt auf die Befehlszeile (dort per `ps` sichtbar).
| `--refresh` | `1m` | Neubewertung aller Knoten (zeitabhängige Stufen, Mandantenänderungen) |
**HTTP-Routen des Servers:**
@@ -372,23 +438,78 @@ sultan
|---|---|---|
| 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/maintenance` | Body `{enabled, duration ("" = 4h, "0" = unbefristet), reason, public_reason}` → 204; wirkt sofort |
| Agent (TLS) | `POST /api/v1/rotate-key` | Body `{public_key, proof}`, signiert mit dem **bisherigen** Schlüssel; `proof` = Signatur des neuen Schlüssels über `SULTAN-ROTATE-V1\n<node_id>\n<public_key>` (an die Knoten-ID gebunden). Idempotent: ist der neue Schlüssel schon aktiv, ebenfalls 204 |
| 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 |
| Provider | `GET /metrics/checkmk` | Checkmk Local Checks (6.1) |
**`sultan token` – Verwaltung (Community-Weg; kommerziell zusätzlich Admin-API, geplant):** arbeitet direkt auf dem Datenverzeichnis des Servers, auch während er läuft (SQLite/WAL).
| Befehl | Wirkung |
|---|---|
| `tenant add --id --name --email --domains [--tier community] [--max-routes 5] [--valid-until +1 Jahr] [--features …]` | Mandant anlegen; Domains werden wie in der Lizenz normalisiert und geprüft; Features fließen in Lizenz, Lease und Policy |
| `tenant list` / `tenant set-status --id --status` / `tenant delete --id` | Anzeigen, Status setzen (wirkt beim nächsten Refresh), Löschen inkl. Knoten und Einladungen |
| `invite --tenant [--ttl 24h\|7d] [--server-url]` | Einladung `sltn_enroll_<secret>.<fingerprint>[.<server-url>]`; Adresse aus `--server-url` oder der gemerkten `--public-url`; stdout enthält **nur** den Token, Hinweise auf stderr |
| `node list [--tenant]` | Knoten mit Status und Wartung (`bis …`, `unbefristet`, `abgelaufen`) |
| `node maintenance --id [--for 4h\|0] [--reason] [--public-reason]` | Wartung anmelden (wirkt beim nächsten Refresh, ≤ 1 min) |
| `node drain --id` / `node resume --id` / `node delete --id` | drain; `resume` beendet Wartung **und** drain (`online` setzt erst der Stream). drain/maintenance/resume schicken dem Agenten zusätzlich einen Hinweis |
| `node command --id --cmd resync\|rotate_key [--reason]` | Befehl in die Warteschlange; Zustellung beim nächsten Refresh bzw. Verbinden |
| `license issue --tenant` | signierte `license.json` (Master-Key) auf stdout |
| `purge` | verbrauchte und abgelaufene Einladungen löschen |
* Ein **nicht initialisiertes Datenverzeichnis wird nie angelegt**. Ein Tippfehler bei `--data-dir` würde sonst eine neue Server-Identität erzeugen, und alle damit ausgestellten Einladungen hätten einen falschen Fingerprint.
* Exit-Codes: 0 ok, 1 Fehler, 2 Bedienfehler.
**Wartung vom Knoten aus:** `sultan agent maintenance on|off [--for 4h|0] [--reason] [--public-reason]` meldet die Wartung signiert beim Server an (z. B. in Patch- oder Reboot-Skripten). Der laufende Agent wird dafür nicht benötigt.
**Lebenszyklus eines Knotens:**
* Enrollment legt ihn `offline` an.
* Stream verbunden → `online`.
* Stream getrennt → Routen sofort entfernt, `offline`.
* Stream verbunden → Lease ausstellen bzw. zustellen, ausstehende Befehle zustellen.
* Stream getrennt → `offline`. Die Routen **bleiben als Wartungsseite** stehen, solange eine Wartung angemeldet ist **oder die Lease gilt**; danach werden sie entfernt (Prüfung bei jedem Refresh). `drain` entfernt sie sofort.
* `drained` bleibt bei beidem unverändert.
Der Agent muss deshalb nach jedem (Wieder-)Verbinden seinen Zustand erneut senden.
### 5.1 Agenten-Subsysteme
**Umsetzung (`pkg/agent`, `pkg/docker`, `sultan agent`), Teil 1:**
| Flag | Default | Bedeutung |
|---|---|---|
| `--server` / `SULTAN_SERVER` | – | Override der Server-Adresse; sonst aus der Einladung bzw. `node.json` |
| `--token` / `SULTAN_TOKEN` | – | Einladung, nur beim ersten Start |
| `--data-dir` | `./suldata` | `node.key` (0600), `node.json` (inkl. `server_fingerprint`) |
| `--node-id` | aus dem Hostnamen | z. B. `Proxmox-VM.42` → `proxmox-vm-42` |
| `--docker-socket` | `/var/run/docker.sock` | `none` = ohne Docker |
| `--backend-host` / `--backend-interface` | automatisch | siehe Backend-Adresse |
* **Enrollment:**
1. Der Agent erzeugt `node.key` und prüft den Server gegen den Fingerprint aus der Einladung.
2. Er löst die Einladung ein und speichert `node.json` (Adresse, `server_fingerprint`, Knoten-ID, Mandant, Master-Public-Key).
3. Danach ist kein Token mehr nötig.
4. Adresswechsel desselben Servers sind erlaubt und werden gespeichert. Ein anderer Server erfordert ein neues Enrollment (Datenverzeichnis leeren).
* **Zustand senden:** vollständiger Schnappschuss an `POST /api/v1/state`.
* Ausgelöst wird er **nach jedem (Wieder-)Verbinden** des Streams (der Server hat die Routen beim Trennen entfernt) und bei jedem Docker-Event.
* Mehrere Auslöser werden über ein kurzes Sammelintervall gebündelt (Default 500 ms).
* Schlägt die Docker-API fehl, wird **nichts** gesendet, sonst würden alle Routen entfernt. Ohne Docker-Socket (Bare-Metal) ist der Zustand leer.
* **Port-Übersetzung:** Der Ingress sitzt auf einem anderen Host. Deshalb wird `loadbalancer.server.port` (Port im Container) auf den **veröffentlichten Host-Port** umgerechnet.
* Fehlt das Port-Label, gilt wie bei Traefiks Docker-Provider der einzige veröffentlichte Port.
* Ohne Service-Labels entsteht ein Service mit dem Containernamen.
* Container, deren Port von außen nicht erreichbar ist (nicht veröffentlicht, nur auf `127.0.0.1`/`::1`, nur UDP), werden mit Warnung weggelassen.
* Es werden nur Container mit `traefik.enable=true` gemeldet.
* **Backend-Adresse**, in dieser Reihenfolge:
1. `--backend-host`
2. IP von `--backend-interface` (z. B. `wt0`)
3. automatisch die lokale IP, über die der Server erreicht wird
* **Docker:** minimaler Client für die Engine API über den Unix-Socket, **ohne Docker-SDK** (nur `GET /containers/json` und `GET /events`). Events werden mit Backoff neu abonniert; nach jedem Neuaufbau wird der Zustand neu gesendet.
* **Server-Events** (`node_command`, `lease_renew`) werden bisher nur protokolliert.
1. **Watcher Engine:**
* **Docker Engine:** Lauscht am `/var/run/docker.sock` via Docker Events API. Filtert nach Labels mit Präfix `traefik.`.
* **File Provider:** Überwacht Verzeichnis `/etc/sultan.d/*.conf` via Inotify auf Konfigurationsdateien im INI/YAML-Format.
* **File Provider:** liest `/etc/sultan.d/*.yaml` (Format 4.1b) per Polling, inkl. Healthchecks – umgesetzt (Teil 2).
2. **State Serializer:**
@@ -430,7 +551,7 @@ Der Agent muss deshalb nach jedem (Wieder-)Verbinden seinen Zustand erneut sende
| 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.
* **Sultan-Interceptor / Web-Lander** (`pkg/lander`, läuft im Server-Prozess auf `--lander-addr`, Default `127.0.0.1:8081`): Bei jeder Stufe außer `normal` leitet die Policy den Router auf den Service `sultan__interceptor` um. Die Interceptor-URL folgt automatisch der gebundenen Lander-Adresse; mit `--lander-addr=none` und `--interceptor-url` lässt sich ein externer Lander nutzen.
* 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.
@@ -442,12 +563,41 @@ Der Agent muss deshalb nach jedem (Wieder-)Verbinden seinen Zustand erneut sende
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.
* **Lander-Seiten (umgesetzt):**
| Stufe | Status | Inhalt |
|---|---|---|
| `watermark` / `firemark` | 200 | Hinweis, `meta refresh` nach 5 / 10 s, Cookie `sultan_ack=<stufe>` (60 min, HttpOnly, SameSite=Lax, Secure hinter HTTPS) |
| `paywall` | 402 | Kunde offline, freundliche Einladung des Hosters (`--hoster-name`, `--hoster-url`) |
| `landing` | 402 | Hosting abgelaufen, Einladung des Hosters |
| `maintenance` | 503, `Retry-After: 60` | „Gleich wieder da“; Community zusätzlich mit Pro-Hinweis (`--pro-url`) |
* **Eingebaut:** je Stufe eine minimale HTML-Seite ohne CSS, Text auf **Deutsch und Englisch** auf derselben Seite, `noindex`, `Cache-Control: no-store`. Alle Methoden (auch POST) bekommen die Seite.
* **Weiterleitungsziel nur lokal:** Pfad+Query des Requests. Ziele wie `//fremd.example` oder mit `\` werden zu `/`; es gibt keine Hosts und keine Schemata. Grund: `html/template` lässt im `content` von `meta refresh` auch `javascript:` durch, und `//host` wäre eine offene Weiterleitung.
* **Eigene Vorlagen:** `<data-dir>/landing/<stufe>.html` (Go `html/template`), optional `<stufe>.css` und `<stufe>.svg`/`.webp` (je ≤ 512 KiB).
* CSS und Grafik werden **eingebettet** (`{{.CSS}}`, `{{.Image}}` als `data:`-URI). Bei Mahnstufe 1/2 ist das Cookie schon gesetzt, ein separater Asset-Request ginge über den Durchlass-Router ans Kunden-Backend.
* Änderungen wirken sofort (Neuladen bei geänderter Größe/Zeitstempel).
* Eine fehlerhafte Vorlage führt zurück zur eingebauten Seite und wird protokolliert.
* **`sultan dump-landing [--out ./suldata/landing] [--force]`** schreibt die eingebauten Seiten plus `README.txt` (alle Template-Werte) als Ausgangspunkt.
* **Begriffe – `maintenance` vs. `drain` (Festlegung 2026-10-02):**
| | **maintenance** – „bin gleich wieder da“ | **drain** – „gebe meine Arbeit ab“ |
|---|---|---|
| Routen bei Traefik | **bleiben**, zeigen 503 (Lander) | **werden entfernt** |
| Besucher sehen | Wartungsseite mit `Retry-After` | anderen Knoten, falls einer die Domain bedient, sonst 404 |
| Dauer | befristet (Default 4 h, `--for`) | bis `resume` |
| Anlass | Patchen, Reboot, Migration, Sicherheitsupdate | Umzug, Abbau, Lastverteilung |
| Knoten | darf sich trennen (Reboot) – Routen bleiben als Wartungsseite stehen | bleibt verbunden, bekommt weiter Events und Leases |
| Auslöser | Admin (`sultan token node maintenance`) **oder** Knoten selbst (`sultan agent maintenance on`), pro Dienst per YAML/Label | Admin |
* Wie in der Cloud (`kubectl drain` vor der Wartung) ist die Kombination möglich: erst drain (Last auf andere Knoten), dann Wartung am leeren Knoten.
* **Einschränkung:** Echte Lastverteilung *einer* Domain über mehrere Knoten gibt es noch nicht (Roadmap). Bis dahin ist bei Seiten mit nur einem Backend **maintenance** das Mittel der Wahl; drain bedeutet dort „Seite offline (404)“.
* **Wartung (umgesetzt, Schritt 1):**
* Quellen: Knoten (`node maintenance` bzw. `agent maintenance on`), Dienst (Label `sultan.maintenance=true` + optional `sultan.maintenance.public_reason`, in YAML `maintenance`/`maintenance_public_reason`), Healthcheck-Ausfall.
* Vorrang: Sperre/drain (keine Routen) > Ablauf/Paywall > **Wartung** > Mahnhinweis > normal. Wartung unterdrückt also auch den Cookie-Durchlass der Mahnstufen.
* Header an den Lander: `X-Sultan-Until` (RFC 3339, nur befristet) und `X-Sultan-Public-Reason` (URL-kodiert).
* Lander: `public_reason` (max. 300 Zeichen, einzeilig) ersetzt den Standardtext; das Ende wird in der Zeitzone des Servers angezeigt; `Retry-After` = Sekunden bis zum Ende (60 s … 1 Tag).
* `reason` (max. 500 Zeichen) erscheint nur im Log und in `node list`, nie öffentlich.
* **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.
@@ -474,10 +624,31 @@ Das Ausgabeformat entspricht der nativen Checkmk Local-Check-Syntax:
```
**Umsetzung (Schritt 3, `pkg/checkmk`):** `GET /metrics/checkmk` auf dem Provider-Port.
| Service | Metrik | Zustand |
|---|---|---|
| `Sultan_Tenant_<id>` (nur kommerziell) | `days_left=N;30;14` | OK; WARN ≤ 30 Tage; CRIT ≤ 14 Tage, abgelaufen oder Quarantäne |
| `Sultan_Payment_<id>` (nur kommerziell) | `dunning_level=N;1;2` | OK ohne offene Zahlung; Mahnstufe 1 WARN; 2 und 3 CRIT (3 = „Kunde offline“) |
| `Sultan_Node_<id>` | `lease_hours_left=N` | online/drained OK; Wartung WARN (mit internem Grund, auch offline); offline WARN, ab `--offline-alert-after` (Default 15 min) CRIT |
| `Sultan_Policy_Violations` | `violations_24h=N;1` | OK ohne Verstöße; ab 1 WARN (gezählt im Speicher, nach Neustart neu) |
* Texte in ASCII (Umlaute umschrieben), einzeilig, sortiert. Kurze Ausfälle in Sekunden („offline seit 45 s“), sonst in Minuten. Das Wartungsende steht in der Zeitzone des Servers mit Kürzel (z. B. `CEST`), wie auf der Lander-Seite.
* Checkmk-Agent-Plugin auf dem Ingress-Host: `/usr/lib/check_mk_agent/local/sultan` mit dem Inhalt `#!/bin/sh` und `curl -fsS http://127.0.0.1:8080/metrics/checkmk`.
### 6.2 Schwellenwerte & Alarmierungskaskade
* **30 Tage vor Ablauf:** Warnung im Checkmk-Dashboard (Monitoring-Status: `WARN`).
* **14 Tage vor Ablauf:** Kritischer Alert (`CRIT`). Automatischer E-Mail-Dispatch / Ticket-Erstellung an Kunden und Admin: *„Abo-Verlängerung erforderlich – bitte Rechnung begleichen“*.
* **Umsetzung (Schritt 3):** Der Server-Job läuft bei jedem Refresh.
* **Vertragsablauf:** Kommerzielle Mandanten werden ab `valid_until` dauerhaft auf `expired` gesetzt. **Community läuft nie über `valid_until` ab** (5c), nur über den expliziten Status.
* **Benachrichtigungen** genau einmal pro Ereignis. Die Schlüssel enthalten das Bezugsdatum, damit nach einer Verlängerung bzw. bei einer neuen offenen Zahlung ein neuer Zyklus beginnt (Tabelle `notifications`).
* Ereignisse: Vertrag 30 / 14 / 0 Tage, Mahnstufe 1 / 2 / 3, Knoten länger als `--offline-alert-after` getrennt (nicht bei angemeldeter Wartung oder drain), neue Policy-Verstöße (sofort, asynchron).
* **„Wieder online“** (Info) beim erneuten Verbinden, nur wenn zuvor ein Offline-Alarm verschickt wurde, je Ausfall einmal, mit Ausfalldauer.
* Schlägt der Versand fehl, wird beim nächsten Durchlauf erneut versucht. Bei mehreren Zielen können dann einzelne Ziele eine Nachricht doppelt bekommen.
* Ziele: `--notify-slack` (Slack-kompatibel: `{"text": …}`, Slack-Markdown, Emoji je Schweregrad) und `--notify-ntfy` (+ `--notify-ntfy-token`; Titel, Priorität default/high/urgent, Tags, Markdown), jeweils mehrfach angebbar. Ohne Ziel wird nichts vermerkt.
* **Aufräumen** nach 10 Tagen: eingelöste/abgelaufene Einladungen, abgelaufene Leases, zugestellte Befehle.
* **Zahlungsstatus** bis zur Admin-API per CLI: `sultan token tenant payment --id … --overdue-since JJJJ-MM-TT | --paid`; `tenant list` zeigt die Spalte `PAYMENT`.
* **Tag 0 (Ablaufzeitpunkt):**
* Status wechselt in SQLite auf `expired`.
* Traefik leitet Domain auf Informations-Landingpage um.
@@ -489,6 +660,8 @@ Das Ausgabeformat entspricht der nativen Checkmk Local-Check-Syntax:
## 7. Referenzkonfigurationen
Die lauffähigen Vorlagen liegen unter `deploy/compose/{server,agent}` (mit `.env.example`); die Ausschnitte hier zeigen das Prinzip. Image: ein distroless-Image für Server und Agent (`Dockerfile`, nonroot-User `65532`, kein `ENTRYPOINT`, Arbeitsverzeichnis `/`, Default-Datenverzeichnis also `/suldata`). Ein Bind-Mount für `/suldata` muss vorher `65532:65532` gehören.
### 7.1 Compose-Setup: Ingress-Knoten
```yaml
@@ -515,10 +688,12 @@ services:
container_name: sultan-server
restart: unless-stopped
# 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"]
command: ["sultan", "server", "--data-dir=/suldata", "--provider-addr=:8080", "--lander-addr=:8081",
"--hoster-name=Mein Hosting", "--hoster-url=https://hosting.example"]
ports:
- "8443:8443" # Agent Inbound (TLS Stream)
- "127.0.0.1:8080:8080" # Traefik Config Provider & Checkmk Metrics – Klartext, nie öffentlich
- "127.0.0.1:8081:8081" # Lander (Hinweis-/Wartungsseiten) – Traefik ruft ihn über 127.0.0.1 auf
volumes:
- ./suldata:/suldata # sultan.db, server.key/.crt, master.key – server.key und master.key sichern!
@@ -532,14 +707,19 @@ services:
image: sultan:latest
container_name: sultan-agent
restart: unless-stopped
network_mode: host # Host-IP als Backend-Adresse, Hostname als Knoten-ID (Bridge: Container-IP/-ID)
group_add: ["${DOCKER_GID}"] # Lesezugriff auf den Docker-Socket für den nonroot-User
command:
- "sultan"
- "agent"
- "--server=https://ingress.dein-netz.de:8443"
- "--token=sltn_enroll_<secret>.<server-fingerprint>" # Ausgabe von `sultan token`
- "--data-dir=/suldata"
environment:
# Ausgabe von `sultan token invite` – enthält Fingerprint und (mit --public-url) die Server-Adresse.
# Nach dem ersten Start nicht mehr nötig; stehen lassen ist harmlos.
SULTAN_TOKEN: "sltn_enroll_<secret>.<fingerprint>.<server-url>"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./suldata:/suldata # node.key (0600) und known_servers – persistent halten! (Agent mit --data-dir=/suldata)
- ./suldata:/suldata # node.key (0600) und node.json – persistent halten!
kunde-wordpress:
image: wordpress:latest
@@ -591,7 +771,25 @@ services:
| 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. |
| 2026-10-01 | **`sultan token`** arbeitet direkt auf dem Datenverzeichnis; legt es nie selbst an; Einladungs-Token allein auf stdout. | Kein versehentlicher Identitätswechsel durch Tippfehler; skriptfreundlich (`TOKEN=$(sultan token invite …)`). |
| 2026-10-02 | **Agent Teil 1:** Docker-Anbindung ohne SDK; Port-Übersetzung Container-Port → veröffentlichter Host-Port; Zustand nach jedem Verbinden neu senden; bei Docker-Fehler nichts senden. | Ingress erreicht nur veröffentlichte Ports; leichte Abhängigkeiten; keine versehentlich gelöschten Routen. |
| 2026-10-02 | Hub registriert einen Knoten **vor** dem Senden der Antwort-Header. | Sonst Wettlauf: Agent sendet seinen Zustand direkt nach den Headern und bekäme 409. |
| 2026-10-02 | **Agent Teil 2:** `/etc/sultan.d`-YAML wird in Traefik-Labels übersetzt (gleicher Weg wie Docker); Polling statt inotify; Healthcheck mit Schwelle (Default 2 Fehlschläge) → `down_services`. | Ein Prüfweg für alle Quellen; robust bei Bind-Mounts; kein Flattern der Wartungsseite. |
| 2026-10-02 | **Lander im Server-Prozess** (`--lander-addr`); `landing` wie `paywall` mit **402**; Weiterleitungsziel nur lokaler Pfad; Assets eigener Vorlagen eingebettet; Vorlagen in `<data-dir>/landing`. | Ein Dienst weniger im Betrieb; keine offene Weiterleitung; Assets funktionieren auch mit gesetztem Durchlass-Cookie. |
| 2026-10-02 | Polling in `/etc/sultan.d` bestätigt: lokal, nur Inhaltsvergleich weniger KB alle 5 s. | inotify ist bei Bind-Mounts und atomar ersetzten Dateien unzuverlässig; immer das Verzeichnis mounten, nie einzelne Dateien. |
| 2026-10-02 | **Server-Adresse in der Einladung** (optionaler dritter Teil, Base64url); Quelle `--public-url` (gemerkt) bzw. `invite --server-url`; beim Agent `--server`/`SULTAN_SERVER` als Override, `SULTAN_TOKEN` als Alternative zum Argument. | Onboarding mit einem einzigen kopierbaren Token (`curl \| sh`); der Server kennt seine erreichbare Adresse nicht selbst. |
| 2026-10-02 | **Pin am Fingerprint statt an der Adresse** (`server_fingerprint` in `node.json`, `known_servers` entfällt); Adresswechsel desselben Servers erlaubt und gespeichert. | Adressen ändern sich (DNS, NetBird, intern/extern); die Identität des Servers nicht. Sicherheitsniveau unverändert. |
| 2026-10-02 | **Reihenfolge der Roadmap:** Wartung → Leases/`node_command`/signierte Events (Migration v3) → Checkmk/Fristen/Benachrichtigungen → mehrere Adressen → Betrieb. | Jeder Schritt baut auf den Daten des vorherigen auf. |
| 2026-10-02 | **maintenance vs. drain** trennscharf: Wartung = Routen bleiben mit 503, befristet (Default 4 h), Admin **und** Knoten können sie auslösen; drain = Routen weg, bis `resume`, nur Admin. Grund zweigeteilt (`reason` intern, `public_reason` ersetzt den Standardtext der Wartungsseite). | Beides ist aus Cloud-Umgebungen bekannt; unterschiedliche Wirkung für Besucher. |
| 2026-10-02 | **Lease** bestimmt, wie lange Routen eines getrennten Knotens als Wartungsseite stehen bleiben; Agent loggt nur. `node_command` als feste Liste ohne Fernsteuerung; lokale Hooks geparkt. Server-Events werden signiert. | Klarer Zweck der Lease; keine Remote-Code-Ausführung. |
| 2026-10-02 | **Fristen & Benachrichtigung:** `expired` wird gespeichert; Aufräumen nach 10 Tagen; Offline-Alarm nach 15 min; Webhooks zuerst Slack-kompatibel und ntfy, Matrix geplant. | Nachvollziehbar, wenig Aufwand, gängige Ziele. |
| 2026-10-02 | **Wartung umgesetzt:** eigene Dimension je Knoten (nicht Teil von `status`); Vorrang Ablauf/Paywall > Wartung > Mahnhinweis; getrennter Knoten in Wartung behält seine Routen bis Wartungsende; `undrain` durch `resume` ersetzt (beendet Wartung und drain). | Ein Knoten in Wartung kann verbunden sein oder nicht; Reboot ohne 404 für Besucher. |
| 2026-10-02 | **Leases und Befehle umgesetzt:** Lease mit eigenem Signaturpräfix; ungeplant getrennter Knoten zeigt die Wartungsseite bis Lease-Ende; Befehle über eine DB-Warteschlange (CLI ist eigener Prozess); alle Server-Events signiert; `rotate_key` mit Besitznachweis und abbruchsicherer Wiederaufnahme. | Kein 404 bei kurzen Ausfällen; keine Fernsteuerung ohne Server-Signatur; Schlüsselwechsel ohne Aussperren. |
| 2026-10-02 | **Schritt 3 umgesetzt:** Checkmk-Ausgabe als reine Funktion (`pkg/checkmk`); Job bei jedem Refresh (Ablauf, Benachrichtigung, Aufräumen); Benachrichtigungen Slack-kompatibel + ntfy mit Wiederholung bis zur erfolgreichen Zustellung; Community läuft nicht über `valid_until` ab. | Testbar, ohne zusätzlichen Prozess; keine verlorenen Alarme. |
| 2026-10-02 | **Mehrere Server-Adressen:** `--public-url` mehrfach; die Einladung trägt nur die erste; die Liste kommt per signiertem `config`-Befehl bei jedem Verbinden; der Agent weicht der Reihe nach aus und merkt sich die funktionierende Adresse. | Kurze Einladung; Ausfall einer Adresse (DNS, Weiterleitung) ohne Eingriff überbrückt. |
| 2026-10-02 | **Build & Auslieferung:** ein distroless-Image (nonroot) für Server und Agent, Multi-Arch amd64+arm64 per buildx mit Go-Cross-Compile (kein QEMU); Registry ist die Container-Registry von Gitea (`git.everdc.eu/chmln-app-dev/sultan`), ein Vanity-Name (`pods.chmln.de/<image>`) entsteht später per Traefik-Proxy mit `replacePathRegex` auf eine feste Gitea-Organisation (kein Redirect: Docker verwirft den `Authorization`-Header bei Host-Wechsel). Binaries + `SHA256SUMS` statisch unter `sultan/releases/<version>/`, `releases/stable` als Zeiger. Agent-Container mit `network_mode: host`. Vorerst manuell über `deploy/*.sh`, später Gitea Actions bei Tags. | Kein Go-Stack beim Nutzer; ein Image für alle Architekturen; Agent meldet sonst die Container-IP als Backend; Registry ohne zusätzlichen Dienst. |
| 2026-10-02 | **Kleinere Punkte:** Rate-Limit `/enroll` 10/min/IP vor der Signaturprüfung (`-1` = aus); `SULTAN_<FLAG>` für alle Flags, Flags gewinnen; „wieder online“ nur nach Offline-Alarm; Checkmk-Zeiten in Server-Zeitzone. Ein Agent, dessen Enrollment mit 429 scheitert, beendet sich; der Neustart erfolgt über systemd bzw. Docker `restart`. | Schutz vor Durchprobieren; Container-/systemd-freundliche Konfiguration; vollständige Alarmkette. |
| geplant | Geordneter Zertifikatswechsel; optionale Root-CA. (Signierte Server-Events: umgesetzt.) | Zweite Sicherheitsschicht neben dem TLS-Pin; Betrieb ohne Aussperren. |
---
@@ -610,36 +808,75 @@ services:
| `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) |
| `pkg/cli`, `main.go` | Befehle `server`, `agent`, `token` (Mandanten, Einladungen, Knoten, Lizenzen), `version`, `help` |
| `pkg/docker` | minimaler Docker-Engine-Client (Container, Events) über Unix-Socket |
| `pkg/lander` | Hinweis-, Wartungs- und Einladungsseiten je Stufe; eigene Vorlagen; `Dump` |
| `pkg/agent` | Agent: Enrollment, Stream, Zustand senden, Docker-Watcher, Port-Übersetzung, Backend-Adresse; `/etc/sultan.d`-YAML mit Komfortschaltern und Healthchecks |
**Offen – Bausteine:**
**Offen – Bausteine in dieser Reihenfolge (festgelegt 2026-10-02):**
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.
**Schritt 1 – Wartung pro Knoten und Dienst** (3a–3e) – ✅ umgesetzt 2026-10-02 (Migration v3)
* Knotenstatus `maintenance` mit Ende: `sultan token node maintenance --id … [--for 4h] [--reason …] [--public-reason …]`, Ende per `node resume`. Standard-Dauer **4 h**; `--for 0` = unbefristet.
* Auch der Knoten selbst kann Wartung an- und abmelden: `sultan agent maintenance on|off [--for] [--reason] [--public-reason]` (signierter Request, z. B. aus Patch-/Reboot-Skripten).
* Pro Dienst: YAML-Feld `maintenance: true` bzw. Container-Label `sultan.maintenance=true`.
* **Grund zweigeteilt:**
* `reason`: intern (Log, Monitoring), nie öffentlich.
* `public_reason`: optional. Wenn angegeben, **ersetzt** er auf der Wartungsseite den Standardtext („bitte versuchen Sie es bald wieder“), z. B. „Wir spielen ein Sicherheitsupdate ein (CVE-1234) – Sicherheit nehmen wir ernst.“
* Die Wartungsseite zeigt das voraussichtliche Ende; `Retry-After` wird daraus abgeleitet.
* Trennt sich ein Knoten **in Wartung**, bleiben seine Routen als Wartungsseite stehen, bis die Wartung endet. Für ungeplantes Trennen gilt künftig die Lease (Schritt 2).
**Schritt 2 – Leases, `node_command`, signierte Server-Events** (4a–4e) – ✅ umgesetzt 2026-10-02 (Migration v4)
* **Lease:** pro Knoten, signiert mit dem Master-Key. Der **Server** verlängert per `lease_renew`, solange der Knoten verbunden und der Mandant in Ordnung ist. Laufzeiten wie 4.1a.
* **Wirkung serverseitig:** Ein getrennter Knoten behält seine letzten Routen als **Wartungsseite, bis seine Lease abläuft**; erst dann werden sie entfernt. Sofort entfernt werden sie nur durch `drain`.
* **Beim Agent:** nur Logging, vorerst ohne Konsequenz. Später auch für einen Checkmk-Check auf Kundenseite („Lease läuft bald ab“).
* **`node_command`**, feste Liste ohne beliebige Befehle:
| Befehl | Agent tut |
|---|---|
| `resync` | sendet sofort seinen Zustand |
| `rotate_key` | neuer `node.key`, mit dem alten signiert gemeldet |
| `drain`, `maintenance`, `resume` | nur Logging (Hinweis) |
| `config` | übernimmt Server-Adressen (Schritt 4) |
Lokale Hooks (`/etc/sultan.d/hooks/<befehl>`) sind **geparkt**.
* **Server-Events signiert** mit dem Master-Key (Agent kennt den Public Key seit dem Enrollment). Das ist die bisher „geplante zweite Schicht“ aus 3.4.
* Migration v4 wie in 4.3 beschrieben. (Die Wartungsfelder kamen bereits mit v3.)
**Schritt 3 – Checkmk, Fristen-Job, Benachrichtigungen** (5a–5e) – ✅ umgesetzt 2026-10-02 (Migration v5)
* `GET /metrics/checkmk` (Provider-Port, lokal), Local-Check-Format:
* pro **kommerziellem** Mandanten: Vertragsende (WARN 30 / CRIT 14 Tage) und Mahnstufe
* pro Knoten: online / offline / Wartung, Lease-Restlaufzeit
* Server: Policy-Verstöße der letzten 24 h
* Community-Mandanten bekommen **keine** Vertragsfristen (automatisch neu lizenziert).
* **Zwei getrennte Uhren:** Vertragsende (`valid_until`) für Checkmk-Fristen; überfällige Zahlung (`payment_overdue_since`) für Mahnstufen 8/25/32. Bis zur Admin-API setzt die CLI den Zahlungsstatus: `sultan token tenant payment --id … --overdue-since DATUM | --paid`.
* **Job:**
* setzt `expired` dauerhaft als Status (Nachvollziehbarkeit, Anzeige)
* räumt auf: Einladungen **10 Tage** nach Ablauf bzw. Einlösung, abgelaufene Leases
* verschickt Benachrichtigungen
* **Benachrichtigungen** einmal pro Ereignis, Versand in der DB vermerkt:
* Ereignisse: Fristen 30/14/0 Tage, Mahnstufen 1/2/3, Knoten länger als **15 min** offline (entspricht 900 s DNS-TTL volatiler Subdomains), neue Policy-Verstöße
* Formate zuerst **Slack-kompatibel** (`{"text": …}`, Slack-Markdown; funktioniert auch mit Mattermost/Rocket.Chat/Discord) und **ntfy**. Danach **Matrix** (geplant), SMTP und Zammad (kommerziell).
**Schritt 4 – Mehrere Server-Adressen** – ✅ umgesetzt 2026-10-02
* Die Einladung behält **eine** Adresse.
* Der Server verteilt die vollständige Liste per `config`-Event (`--public-url` mehrfach angebbar).
* Der Agent probiert sie der Reihe nach und merkt sich die zuletzt funktionierende. Gefahrlos, weil das Pinning am Fingerprint hängt.
**Schritt 5 – Betrieb und Installationsskript** (teilweise umgesetzt: `Dockerfile`, `deploy/build.sh`, `deploy/push-image.sh`, `deploy/publish-static.sh`, `deploy/compose/`)
* Strukturiertes Logging (`log/slog`, umgesetzt), Graceful Shutdown (umgesetzt), Versionsinfo
* ✅ Dockerfile (statisch, distroless, Multi-Arch), Build-/Deploy-Skripte, Compose-Vorlagen
* offen: systemd-Unit, `install.sh`, Signatur von `SHA256SUMS` (Ed25519, Schlüssel nur im Runner), Gitea Actions (Tests bei Push, Release bei Tag `v*`), Image-Digest in der veröffentlichten Compose-Vorlage, Vanity-Proxy `pods.chmln.de`, optional Spiegel auf ghcr.io
* Installationsskript für `curl | sh`: Herkunft mit öffentlichem Zertifikat und/oder Prüfsumme bzw. Signatur. Die eigentliche Vertrauensfrage ist das Skript, nicht der Token.
**Weitere offene Bausteine (ohne feste Reihenfolge):**
* **Lastverteilung einer Domain über mehrere Knoten** (gemeinsamer Load-Balancer statt getrennter Router) – Voraussetzung dafür, dass `drain` seine Stärke ausspielt.
* Admin-API (nur kommerziell): Mandanten, Lizenzen, Zahlungsstatus für Rechnungssystem und Ansible; Authentifizierung per API-Token, TLS über ACME.
* *(umgesetzt 2026-10-02: Rate-Limit für `/enroll`, Umgebungsvariablen für alle Flags, „wieder online“-Meldung, Checkmk mit Sekunden und Zeitzone)*
* Agent: Healthchecks auch für Docker-Container (Docker-Health-Status oder Labels `sultan.healthcheck.*`).
* CLI: `tenant update` (Domains/Tier/Laufzeit ändern), JSON-Ausgabe für Skripte.
* Sicherheit (3.4): geordneter Zertifikatswechsel, optionale Root-CA.
* **Ausblick Monitoring:** Die Zahl der Policy-Verstöße der letzten 24 h (`Sultan_Policy_Violations`) zählt der Server derzeit im Speicher; nach einem Neustart beginnt sie bei 0. Für Checkmk reicht das. Wird sie dauerhaft gebraucht (Auswertung, Audit), ist das eine kleine Erweiterung: Verstöße mit Zeitpunkt in einer Tabelle ablegen und im Aufräum-Job nach der Frist löschen.
**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.
+12
View File
@@ -0,0 +1,12 @@
# Nie ins Image bzw. in den Build-Kontext: Schlüssel, private Doku, Build-Ausgaben
suldata/
**/suldata/
dist/
.git/
.ai/
Doku/
CLAUDE.md
.env
*.env
/sultan
deploy/
+3
View File
@@ -30,3 +30,6 @@ go.work.sum
/suldata/
suldata/
/sultan
# Build-Ausgaben (deploy/build.sh)
/dist/
+22 -7
View File
@@ -9,21 +9,36 @@ Maßgeblich ist aktuell [.ai/sultan-system-architecture.md](.ai/sultan-system-ar
## Projektstatus
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:
Go-Modul `sultan` (Go 1.26). `main.go` ist nur eine Hülle um `pkg/cli`; umgesetzt sind `sultan server|agent|token|dump-landing|version|help`. 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/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), `PinnedTLSConfig(fingerprint)` – Pinning im Handshake am Fingerprint, **unabhängig von der Adresse**; ohne Fingerprint `ErrUnknownServer` (kein blindes TOFU). Enrollment-Token `sltn_enroll_<secret>.<fingerprint>[.<base64url(server-url)>]` (`NewEnrollmentToken(fp, url)`/`ParseEnrollmentToken`, `ValidateServerURL`); 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/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. Wartung: `Input.Maintenance` (Knoten) und `Input.ServiceMaintenance` (Label `sultan.maintenance`), Vorrang Ablauf/Paywall > Wartung > Mahnhinweis, Header `X-Sultan-Until`/`X-Sultan-Public-Reason` (URL-kodiert). 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` (der Server ruft es beim Trennen nur ohne aktive Wartung auf; `pruneDisconnected` räumt nach Wartungsende), `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=…"`.
- [pkg/cli](pkg/cli) – `Run(ctx, args, stdout, stderr) int`; Flags/Defaults von `sultan server`; `sultan token` in `token.go` (Befehlstabelle `tokenCmds`, `usageError` → Exit 2, `openDataDir` verweigert nicht initialisierte Verzeichnisse). Version per `-ldflags "-X sultan/pkg/cli.Version=…"`.
Was noch fehlt (Lander, Agent, `sultan token`, Leases, Checkmk, Betrieb) und offene Fachfragen: Spec Abschnitt 9.
- [pkg/docker](pkg/docker) – minimaler Docker-Engine-Client über Unix-Socket (`List`, `Events`), bewusst ohne SDK.
- [pkg/agent](pkg/agent) – `sultan agent`: `Enroll` (node.key, Pinning über Einladungs-Fingerprint, node.json mit `server_fingerprint`; Adresse: `--server`/`SULTAN_SERVER` > URL in der Einladung > node.json, neue Adresse wird nach erfolgreichem Verbinden übernommen), `Run` (Stream mit `OnConnect` → Zustand neu senden, Docker-Watcher, Sammelintervall, kein Senden bei Docker-API-Fehler), `Workloads` (Container-Port → veröffentlichter Host-Port, Default-Service, unerreichbare Ports weglassen), `backendHost` (explizit → Interface → Route zum Server). Dazu `/etc/sultan.d`-YAML (`sultand.go`: `ParseServices`, `LoadServiceDir`, `FileWorkloads` → Traefik-Labels; Polling über `dirFingerprint`) und Healthchecks (`health.go`: `checkHealth`, `healthTracker` mit Fehlerschwelle) → `DownServices`. Tests laufen gegen echten `pkg/server` mit gefälschtem Docker-Socket.
- [pkg/lander](pkg/lander) – Interceptor-Seiten je `X-Sultan-Stage` (eingebettete `templates/*.html`, zweisprachig), Cookie `sultan_ack` bei watermark/firemark, `localTarget` erlaubt nur lokale Pfade (html/template filtert `meta refresh` **nicht**!), eigene Vorlagen aus `<data-dir>/landing` mit eingebettetem CSS/Bild, `Dump`. Läuft im Server auf `--lander-addr`; die Interceptor-URL der Policy folgt der gebundenen Adresse. Test-Server immer mit `LanderAddr: "127.0.0.1:0"` starten (sonst Portkonflikt auf 8081).
**Leases & Befehle (Schritt 2):** `license.TermsFor`/`IssueLease`/`ParseLease` (Präfix `SULTAN-LEASE-V1`); der Server stellt Leases beim Verbinden und im Refresh aus (`pkg/server/leases.go`: `ensureLease`, `dispatchCommands`, `leaseValid`), getrennte Knoten behalten Routen als Wartungsseite bis Lease-Ende (`Disconnected` → Policy-Stufe maintenance), drain entfernt sofort. Hub signiert alle Events (`Hub.SigningKey`, `stream.SignEvent`/`VerifyEvent`); der Agent verwirft unsignierte (`pkg/agent/events.go`). Befehle: Warteschlange `node_commands` (CLI reiht ein), feste Liste `stream.Cmd*`; `rotate_key` über `node.key.new` + `POST /api/v1/rotate-key` (abbruchsicher). Signer im Agent nur über `currentSigner()` lesen (wird im Betrieb getauscht). Tests mit kurzen Leases über `Config.LeaseDuration`.
**Monitoring & Jobs (Schritt 3):** [pkg/checkmk](pkg/checkmk) (`Render(State, now)`, reine Funktion, ASCII) hinter `GET /metrics/checkmk`; [pkg/notify](pkg/notify) (`Slack`, `Ntfy`, `Multi`); `pkg/server/jobs.go`: `runJobs` bei jedem Refresh (kommerzielle Verträge → `expired`, Benachrichtigungen über `sendOnce` mit Schlüssel inkl. Bezugsdatum in Tabelle `notifications`, Aufräumen nach 10 Tagen). Policy-Verstöße werden asynchron gemeldet (OnViolation läuft unter der Registry-Sperre). Tests nutzen `Config.Notify` mit einem aufzeichnenden Ziel.
**Mehrere Adressen (Schritt 4):** `Config.PublicURLs` (gemerkt als `settings.public_urls`, `server.LoadPublicURLs`), Versand per `config`-Befehl in `nodeOnline`; Agent: `Identity.ServerURLs`, `serverCandidates`/`nextServerURL` (Wechsel in `OnError` des Stream-Clients), `a.serverURL` nur über `currentServerURL()` lesen.
- [deploy/](deploy) – Auslieferung: `Dockerfile` im Root (distroless, nonroot 65532, Multi-Arch, kein `ENTRYPOINT`, `WORKDIR /`), `build.sh` (Binaries amd64/arm64 + `SHA256SUMS` nach `dist/<version>/`), `push-image.sh` (buildx → Gitea-Registry `git.everdc.eu/chmln-app-dev/sultan`), `publish-static.sh` (rsync nach `sultan/releases/<version>/`), `compose/{server,agent}` (Agent mit `network_mode: host`). Version überall aus `git describe`; `-dirty` wird nicht veröffentlicht. `.dockerignore` hält `suldata/`, `.ai/`, `CLAUDE.md` aus dem Build-Kontext.
**CLI-Umgebung:** `pkg/cli/env.go` – `annotateEnv`/`applyEnv` machen jedes Flag als `SULTAN_<FLAG>` setzbar (nach `fs.Parse`, nur für nicht gesetzte Flags; Listen-Flags `stringList` kommagetrennt). Neue Flags bekommen das automatisch. Rate-Limit: `pkg/server/ratelimit.go` (`EnrollLimit`, `-1` = aus).
Was noch fehlt: Betrieb (Spec Abschnitt 9, Schritt 5) 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.
@@ -51,7 +66,7 @@ Sultan ersetzt manuell gepflegtes Ingress-Routing (z. B. Nginx Proxy Manager): B
- `127.0.0.1:8080` – `GET /v1/traefik-config` (Traefik pollt alle 2–5s; flache Labels aller aktiven Nodes → verschachteltes Traefik-JSON) und `GET /metrics/checkmk` (Checkmk Local-Check-Format).
**Sicherheitsmodell (Kerninvarianten):**
- Server-TLS-Zertifikat self-signed (ECDSA P-256, TLS 1.3), Agent pinnt den SHA256-Fingerprint in `known_servers`; der Erstkontakt wird gegen den Fingerprint im Enrollment-Token geprüft (kein blindes TOFU). Kein Agent-Verkehr im Klartext.
- Server-TLS-Zertifikat self-signed (ECDSA P-256, TLS 1.3), Agent pinnt den SHA256-Fingerprint (aus der Einladung, danach `server_fingerprint` in `node.json`) – adressunabhängig, kein blindes TOFU. Kein Agent-Verkehr im Klartext.
- Agent-Keys (Ed25519) werden nur lokal erzeugt und verlassen den Host nie; Enrollment tauscht Public Key + einmaligen Token gegen Mandanten-Zuordnung und signierten Capability-Lease. Danach ist jeder Heartbeat mit `node.key` signiert.
- Jeder Agent-Payload wird serverseitig gegen die Mandanten-Policy geprüft: Hosts in `Host(...)`-Regeln müssen in `allowed_domains` stehen (sonst ablehnen + Audit-Log), Routen über `max_routes` werden verworfen.
- Lizenzen (`license.json`) sind mit dem Ed25519-Server-Master-Key signiert.
+33
View File
@@ -0,0 +1,33 @@
# Ein Image für sultan server und sultan agent, Multi-Arch (amd64 + arm64):
#
# docker buildx build --platform linux/amd64,linux/arm64 -t <name> --push .
# docker build -t sultan:latest . # nur die eigene Architektur, lokal
#
# Go kompiliert auf der Build-Plattform für die Zielarchitektur (kein QEMU
# nötig); das Laufzeit-Image enthält nur das statische Binary.
FROM --platform=$BUILDPLATFORM golang:1.26 AS build
ARG TARGETOS TARGETARCH
ARG VERSION=dev
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags "-s -w -X sultan/pkg/cli.Version=${VERSION}" -o /out/sultan . \
&& mkdir -p /out/suldata
FROM gcr.io/distroless/static-debian12:nonroot
ARG VERSION=dev
LABEL org.opencontainers.image.title="sultan" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.source="https://git.everdc.eu/chmln-app-dev/sultan"
COPY --from=build /out/sultan /usr/local/bin/sultan
# Datenverzeichnis gehört dem nonroot-User (65532); ein Named Volume übernimmt
# das, ein Bind-Mount muss vorher per chown 65532:65532 vorbereitet werden.
COPY --from=build --chown=65532:65532 /out/suldata /suldata
# WORKDIR / → der Default ./suldata ist /suldata; `sultan token …` braucht kein --data-dir.
WORKDIR /
# Kein ENTRYPOINT: Compose ruft "sultan server …" bzw. "sultan agent …" auf
# (Spec 7), `docker compose exec sultan-server sultan token …` funktioniert so ebenfalls.
CMD ["sultan", "help"]
+114 -10
View File
@@ -36,28 +36,79 @@ Die ausführliche Architektur steht in [.ai/sultan-system-architecture.md](.ai/s
### Voraussetzungen
- Go (aktuelle stabile Version)
- Zum Bauen: Go (Version laut `go.mod`) bzw. Docker mit buildx
- Für den Ingress-Knoten: Traefik v3
- Für Docker-Hosts: Zugriff auf `/var/run/docker.sock`
### Binary bauen
### 1. Quellen holen
```bash
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
```
### 2. Binary bauen (amd64 + arm64)
```bash
go build -o sultan . && ./sultan version # schnell, nur für die eigene Architektur
deploy/build.sh # Release-Binaries für linux/amd64 + linux/arm64
# → dist/<version>/sultan_linux_amd64, sultan_linux_arm64, SHA256SUMS
sudo install -m 0755 dist/<version>/sultan_linux_amd64 /usr/local/bin/sultan
```
Die Version kommt aus `git describe` (Tag, sonst Commit-Hash; `-dirty` bei uncommitteten Änderungen) und steht in `sultan version`.
### 3. Docker-Image bauen (ein Image für Server *und* Agent, amd64 + arm64)
```bash
docker build -t sultan:latest . # lokal, eigene Architektur
docker run --rm sultan:latest sultan version
```
Das Image ist distroless (nur das statische Binary, ~17 MB) und läuft als nonroot-User `65532`. Die Rolle wählt der Befehl: `sultan server …` bzw. `sultan agent …`.
### 4. Image in die Gitea-Registry schieben
```bash
docker login git.everdc.eu # einmalig: Gitea-User + Token mit Scope write:package
deploy/push-image.sh # → git.everdc.eu/chmln-app-dev/sultan:<version>
LATEST=1 deploy/push-image.sh # zusätzlich :latest (nur für Releases)
```
Baut per `docker buildx` gleichzeitig für `linux/amd64` und `linux/arm64` (Go kompiliert quer, kein QEMU nötig) und verweigert Versionen mit `-dirty`. Anpassbar über `REGISTRY`, `OWNER`, `PLATFORMS`.
### 5. Binaries auf den statischen Webhost legen
```bash
TARGET=deploy@webdocker11:/srv/inst/htdocs deploy/publish-static.sh
STABLE=1 TARGET=… deploy/publish-static.sh # zusätzlich sultan/releases/stable setzen
```
Ablage per `rsync` unter `sultan/releases/<version>/` (Binaries + `SHA256SUMS`); ältere Versionen bleiben herunterladbar. Grundlage für das spätere `curl … | bash`.
### 6. Mit Docker Compose betreiben
Vorlagen: [deploy/compose/server](deploy/compose/server) (Ingress-Knoten neben Traefik) und [deploy/compose/agent](deploy/compose/agent) (Docker-Host).
```bash
cp -r deploy/compose/server ~/sultan-server && cd ~/sultan-server
cp .env.example .env && $EDITOR .env # SULTAN_IMAGE, SULTAN_PUBLIC_URL
sudo install -d -m 0700 -o 65532 -g 65532 suldata # Datenverzeichnis für den nonroot-User
docker compose up -d
docker compose exec sultan-server sultan token tenant add --id=… --domains=…
docker compose exec sultan-server sultan token invite --tenant=…
```
Der Agent läuft mit `network_mode: host`. Nur so meldet er die Host-IP als Backend-Adresse (statt der Container-IP) und den Hostnamen als Knoten-ID. Für den Docker-Socket braucht er `DOCKER_GID` in der `.env` (`getent group docker | cut -d: -f3`).
### Server starten (Ingress-Knoten)
```bash
sultan server # Daten landen in ./suldata
sultan server --public-url=https://ingress.example.com:8443 # 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 (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:
Alle Optionen gehen auch als Umgebungsvariable, z. B. `SULTAN_PUBLIC_URL=… sultan server` (praktisch für Docker und systemd). 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
@@ -66,13 +117,66 @@ Der Server lauscht auf `:8443` für Agents und auf `127.0.0.1:8080` für Traefik
### Agent verbinden (Backend-Host)
1. Auf dem Server mit `sultan token` einen Einladungs-Token erzeugen 🎟️
1. Auf dem Server einen Mandanten anlegen und eine Einladung erzeugen 🎟️
```bash
sultan token tenant add --id=homelab --name="Mein Homelab" --email=ich@example.com --domains="example.com,*.example.com"
sultan token invite --tenant=homelab # gibt die Einladung aus, 24 h gültig
sultan token --help # alle Verwaltungsbefehle
```
2. Auf dem Backend-Host den Agent starten:
```bash
sultan agent --server=https://ingress.example.com:8443 --token=<einladungs-token>
SULTAN_TOKEN=<einladung> sultan agent # Adresse steckt in der Einladung (sultan server --public-url=…)
sultan agent --server=https://ingress.example.com:8443 --token=<einladung> # sonst Adresse angeben
# danach genügt:
sultan agent
```
Der Agent liest die Container mit `traefik.enable=true` aus Docker und meldet sie an den Server. Wichtig: Der Ingress erreicht nur **veröffentlichte** Ports (`ports: "8081:80"`), nicht solche, die nur auf `127.0.0.1` veröffentlicht sind. Ohne Docker: `--docker-socket=none`. Über welche Adresse der Ingress den Host erreicht, ermittelt der Agent automatisch; für NetBird z. B. `--backend-interface=wt0`.
**Ohne Docker (Bare-Metal, LAMP …)** 🖥️ beschreibst du deine Dienste in `/etc/sultan.d/*.yaml`:
```yaml
services:
- name: kunde-a
url: http://127.0.0.1:8080
hosts: [kunde-a.de]
https: true
redirect_http_to_https: true
redirect_www: to_non_www
healthcheck: { expect_title: "KundeA - Willkommen" } # bei Ausfall: Wartungsseite statt Fehler
```
Beim ersten Verbinden merkt sich der Agent den Fingerprint des Servers – ab dann läuft alles automatisch. 🎉
Fertige Docker-Compose-Beispiele für Ingress und Backend-Host findest du in der [Architektur-Doku](.ai/sultan-system-architecture.md#7-referenzkonfigurationen).
### Wartung 🔧
```bash
sultan agent maintenance on --for 2h --public-reason "Wir spielen ein Sicherheitsupdate ein." # auf dem Knoten
sultan token node maintenance --id=pi --for 2h # oder auf dem Server
sultan token node resume --id=pi # beenden
```
Besucher sehen währenddessen die Wartungsseite mit Grund und voraussichtlichem Ende – auch, wenn der Knoten gerade neu startet.
### Monitoring & Benachrichtigungen 📊
```bash
sultan server --notify-slack=https://hooks.slack.com/… --notify-ntfy=https://ntfy.sh/mein-sultan
curl -s http://127.0.0.1:8080/metrics/checkmk # Checkmk Local Checks: Verträge, Zahlungen, Knoten
```
Sultan meldet ablaufende Verträge, Mahnstufen, ausgefallene Knoten und Policy-Verstöße – jedes Ereignis genau einmal.
### Hinweis- und Wartungsseiten 🚧
Fällt ein Dienst aus oder läuft ein Abo ab, zeigt Sultan statt eines Fehlers eine freundliche Seite (Deutsch und Englisch). Eigene Gestaltung:
```bash
sultan dump-landing # Vorlagen nach ./suldata/landing – anpassen, wirkt sofort
sultan server --hoster-name="Mein Hosting" --hoster-url=https://hosting.example
```
Fertige Docker-Compose-Vorlagen für Ingress und Backend-Host liegen unter [deploy/compose](deploy/compose).
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Baut die Release-Binaries für Linux (amd64 + arm64) samt SHA256SUMS.
#
# deploy/build.sh # Version aus `git describe`
# VERSION=v0.1.0 deploy/build.sh
#
# Ausgabe: dist/<version>/sultan_linux_{amd64,arm64} und dist/<version>/SHA256SUMS
set -euo pipefail
cd "$(dirname "$0")/.."
VERSION="${VERSION:-$(git describe --tags --always --dirty)}"
ARCHS="${ARCHS:-amd64 arm64}"
OUT="dist/${VERSION}"
rm -rf "$OUT"
mkdir -p "$OUT"
for arch in $ARCHS; do
echo "==> linux/${arch} (${VERSION})"
CGO_ENABLED=0 GOOS=linux GOARCH="$arch" \
go build -trimpath -ldflags "-s -w -X sultan/pkg/cli.Version=${VERSION}" \
-o "${OUT}/sultan_linux_${arch}" .
done
(cd "$OUT" && sha256sum sultan_linux_* > SHA256SUMS)
echo "==> fertig: ${OUT}"
cat "${OUT}/SHA256SUMS"
+9
View File
@@ -0,0 +1,9 @@
# Image: lokal gebaut (docker build -t sultan:latest .) oder aus der Registry
SULTAN_IMAGE=sultan:latest
# SULTAN_IMAGE=git.everdc.eu/chmln-app-dev/sultan:v0.1.0
# Einladung vom Server: docker compose exec sultan-server sultan token invite --tenant=…
SULTAN_TOKEN=
# Gruppe des Docker-Sockets: getent group docker | cut -d: -f3
DOCKER_GID=
+30
View File
@@ -0,0 +1,30 @@
# sultan agent auf einem Docker-Host (Backend).
#
# cp .env.example .env && $EDITOR .env # SULTAN_TOKEN, DOCKER_GID
# install -d -m 0700 -o 65532 -g 65532 suldata
# docker compose up -d
#
# network_mode: host ist Absicht:
# - Backend-Adresse: der Agent meldet die Host-IP (Route zum Server bzw.
# --backend-interface=wt0), nicht die Container-IP aus dem Bridge-Netz.
# - Knoten-ID: Default ist der Hostname – mit Host-Netz der des Hosts statt
# der zufälligen Container-ID.
services:
sultan-agent:
image: ${SULTAN_IMAGE:-sultan:latest}
container_name: sultan-agent
restart: unless-stopped
network_mode: host
command: ["sultan", "agent", "--data-dir=/suldata", "--config-dir=none"]
environment:
# Ausgabe von `sultan token invite`; nach dem ersten Start nicht mehr nötig,
# stehen lassen ist harmlos.
SULTAN_TOKEN: ${SULTAN_TOKEN:-}
# Lesezugriff auf den Docker-Socket für den nonroot-User (UID 65532).
group_add:
- "${DOCKER_GID:?DOCKER_GID setzen: getent group docker | cut -d: -f3}"
volumes:
# :ro schützt nur die Socket-Datei, nicht die API – der Agent nutzt
# ausschließlich lesende Aufrufe (Container-Liste, Events).
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./suldata:/suldata # node.key (0600) und node.json – persistent halten!
+6
View File
@@ -0,0 +1,6 @@
# Image: lokal gebaut (docker build -t sultan:latest .) oder aus der Registry
SULTAN_IMAGE=sultan:latest
# SULTAN_IMAGE=git.everdc.eu/chmln-app-dev/sultan:v0.1.0
# Adresse, unter der Agents den Server erreichen – steckt danach in jeder Einladung
SULTAN_PUBLIC_URL=https://webproxy11.example.de:8443
+27
View File
@@ -0,0 +1,27 @@
# sultan server auf dem Ingress-Knoten (neben Traefik).
#
# cp .env.example .env && $EDITOR .env
# install -d -m 0700 -o 65532 -g 65532 suldata # Datenverzeichnis für den nonroot-User
# docker compose up -d
# docker compose exec sultan-server sultan token tenant add …
#
# Traefik (Host-Netz oder Binary) pollt http://127.0.0.1:8080/v1/traefik-config.
services:
sultan-server:
image: ${SULTAN_IMAGE:-sultan:latest}
container_name: sultan-server
restart: unless-stopped
# 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"
- "--lander-addr=:8081"
- "--public-url=${SULTAN_PUBLIC_URL}"
ports:
- "8443:8443" # Agents (TLS 1.3)
- "127.0.0.1:8080:8080" # Traefik-Provider & Checkmk – Klartext, nie öffentlich
- "127.0.0.1:8081:8081" # Lander (Hinweis-/Wartungsseiten) für Traefik
volumes:
- ./suldata:/suldata # sultan.db, server.key/.crt, master.key – sichern!
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Baut die Binaries (deploy/build.sh) und lädt sie per rsync auf den statischen
# Webhost (z. B. inst.chmln.de):
#
# TARGET=deploy@webdocker11:/srv/inst/htdocs deploy/publish-static.sh
# STABLE=1 TARGET=… deploy/publish-static.sh # zusätzlich als "stable" markieren
#
# Ablage unter ${TARGET}/sultan/:
# releases/<version>/sultan_linux_{amd64,arm64}
# releases/<version>/SHA256SUMS
# releases/stable → enthält die stabile Version (nur mit STABLE=1)
#
# Ältere Versionen bleiben liegen und damit herunterladbar.
set -euo pipefail
cd "$(dirname "$0")/.."
: "${TARGET:?TARGET setzen, z. B. TARGET=deploy@webdocker11:/srv/inst/htdocs}"
VERSION="${VERSION:-$(git describe --tags --always --dirty)}"
export VERSION
case "$VERSION" in
*-dirty)
echo "Arbeitsverzeichnis hat uncommittete Änderungen (${VERSION}) – erst committen." >&2
exit 1
;;
esac
deploy/build.sh
stage="$(mktemp -d)"
trap 'rm -rf "$stage"' EXIT
mkdir -p "${stage}/sultan/releases/${VERSION}"
cp "dist/${VERSION}"/* "${stage}/sultan/releases/${VERSION}/"
if [ "${STABLE:-0}" = 1 ]; then
echo "$VERSION" > "${stage}/sultan/releases/stable"
fi
chmod -R a+rX "$stage"
# Ohne --delete: bereits veröffentlichte Versionen bleiben erhalten.
rsync -rlv --chmod=Da+rx,Fa+r "${stage}/" "${TARGET}/"
echo "==> veröffentlicht: sultan/releases/${VERSION}"
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Baut das Multi-Arch-Image (amd64 + arm64) und schiebt es in die
# Container-Registry von Gitea.
#
# docker login git.everdc.eu # einmalig, Gitea-User + Token mit write:package
# deploy/push-image.sh # Tag aus `git describe`
# LATEST=1 deploy/push-image.sh # zusätzlich :latest setzen (nur für Releases)
#
# Ergebnis: ${REGISTRY}/${OWNER}/sultan:<version> (und ggf. :latest)
set -euo pipefail
cd "$(dirname "$0")/.."
REGISTRY="${REGISTRY:-git.everdc.eu}"
OWNER="${OWNER:-chmln-app-dev}"
IMAGE="${REGISTRY}/${OWNER}/sultan"
VERSION="${VERSION:-$(git describe --tags --always --dirty)}"
PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}"
case "$VERSION" in
*-dirty)
echo "Arbeitsverzeichnis hat uncommittete Änderungen (${VERSION}) – erst committen." >&2
exit 1
;;
esac
tags=(-t "${IMAGE}:${VERSION}")
if [ "${LATEST:-0}" = 1 ]; then
tags+=(-t "${IMAGE}:latest")
fi
# Multi-Arch-Builds brauchen einen buildx-Builder mit docker-container-Treiber.
if ! docker buildx inspect sultan-builder >/dev/null 2>&1; then
docker buildx create --name sultan-builder --driver docker-container >/dev/null
fi
docker buildx build --builder sultan-builder \
--platform "$PLATFORMS" \
--build-arg VERSION="$VERSION" \
"${tags[@]}" \
--push .
echo "==> gepusht: ${IMAGE}:${VERSION}"
docker buildx imagetools inspect "${IMAGE}:${VERSION}" | head -n 3
+411
View File
@@ -0,0 +1,411 @@
// Package agent ist `sultan agent`: Er registriert den Host beim Server
// (Enrollment mit Einladung und Pinning), hält den SSE-Stream offen,
// beobachtet Docker und meldet seinen vollständigen Zustand
// (service_state) – nach jedem (Wieder-)Verbinden und bei jeder Änderung.
//
// Im Datenverzeichnis (Default ./suldata) liegen node.key (0600) und
// node.json (inkl. server_fingerprint, dem Pin des Servers).
package agent
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/docker"
"sultan/pkg/policy"
"sultan/pkg/registry"
"sultan/pkg/stream"
)
// NoDocker als DockerSocket bzw. ConfigDir schaltet die jeweilige Quelle ab.
const NoDocker = "none"
// Config ist die Konfiguration des Agenten; Nullwerte bekommen Defaults.
type Config struct {
DataDir string // Default ./suldata
ServerURL string // https://ingress:8443 – Override; sonst aus der Einladung bzw. node.json
Token string // Einladung, nur beim ersten Start
NodeID string // Default aus dem Hostnamen
Hostname string // Default os.Hostname()
DockerSocket string // Default /var/run/docker.sock; "none" schaltet Docker ab
BackendHost string // Adresse, unter der der Ingress die Dienste erreicht
BackendInterface string // alternativ: IP dieses Interfaces (z. B. wt0)
ConfigDir string // Default /etc/sultan.d (*.yaml, Spec 4.1b); "none" schaltet ab
ConfigPoll time.Duration // Default 5s: Prüfintervall für Änderungen in ConfigDir
Logger *slog.Logger
Debounce time.Duration // Default 500ms: Änderungen sammeln, bevor gesendet wird
RetryInterval time.Duration // Default 5s: erneuter Sendeversuch nach Fehler
ReconnectMin time.Duration // Default 1s: kleinste Wartezeit vor Neuverbindung
}
// Agent ist ein startbereiter Agent.
type Agent struct {
cfg Config
log *slog.Logger
nodeIDErr error
signer *auth.Signer
id Identity
serverURL string // tatsächlich genutzte Adresse (Override oder node.json)
mu sync.Mutex
fileSpecs []ServiceSpec
health healthTracker
keyMu sync.RWMutex // schützt signer (rotate_key tauscht ihn im Betrieb)
kick func() // Zustand neu senden (in Run gesetzt)
runCtx context.Context
hc *http.Client
}
// httpClient liefert den gepinnten Client für den Server.
func (a *Agent) httpClient() *http.Client {
if a.hc == nil {
a.hc = &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(a.id.ServerFingerprint)}}
}
return a.hc
}
// New prüft die Konfiguration und setzt Defaults; Dateien werden erst von
// Enroll/Run angefasst.
func New(cfg Config) (*Agent, error) {
if cfg.DataDir == "" {
cfg.DataDir = "./suldata"
}
if cfg.DockerSocket == "" {
cfg.DockerSocket = "/var/run/docker.sock"
}
if cfg.ConfigDir == "" {
cfg.ConfigDir = "/etc/sultan.d"
}
if cfg.ConfigPoll <= 0 {
cfg.ConfigPoll = 5 * time.Second
}
if cfg.Logger == nil {
cfg.Logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
}
if cfg.Debounce <= 0 {
cfg.Debounce = 500 * time.Millisecond
}
if cfg.RetryInterval <= 0 {
cfg.RetryInterval = 5 * time.Second
}
if cfg.ReconnectMin <= 0 {
cfg.ReconnectMin = time.Second
}
if cfg.ServerURL != "" {
u, err := crypto.ValidateServerURL(cfg.ServerURL)
if err != nil {
return nil, fmt.Errorf("agent: --server: %w", err)
}
cfg.ServerURL = u
}
if cfg.Hostname == "" {
cfg.Hostname, _ = os.Hostname()
}
a := &Agent{cfg: cfg, log: cfg.Logger}
if cfg.NodeID != "" {
if !policy.ValidID(cfg.NodeID) {
return nil, fmt.Errorf("agent: Knoten-ID %q ungültig (a–z, 0–9, - und _, kein \"__\")", cfg.NodeID)
}
} else {
a.cfg.NodeID, a.nodeIDErr = DefaultNodeID(cfg.Hostname)
}
return a, nil
}
// errNotConnected: Der Server nimmt den Zustand erst bei offenem Stream an.
var errNotConnected = errors.New("Stream noch nicht verbunden")
// Run registriert den Agenten (falls nötig) und arbeitet, bis ctx endet.
func (a *Agent) Run(ctx context.Context) error {
if err := a.Enroll(ctx); err != nil {
return err
}
host, err := backendHost(a.cfg.BackendHost, a.cfg.BackendInterface, a.serverURL)
if err != nil {
return err
}
hc := a.httpClient()
a.runCtx = ctx
// Abgebrochener Schlüsselwechsel? Vor dem Verbinden abschließen.
if _, err := os.Stat(a.path("node.key.new")); err == nil {
if err := a.completeRotation(ctx); err != nil {
a.log.Error("Begonnenen Schlüsselwechsel abschließen", "err", err)
}
}
a.log.Info("Agent gestartet", "node", a.id.NodeID, "server", a.serverURL, "backend", host,
"docker", a.cfg.DockerSocket, "config_dir", a.cfg.ConfigDir)
trigger := make(chan struct{}, 1)
kick := func() {
select {
case trigger <- struct{}{}:
default:
}
}
a.kick = kick
var sc *stream.Client
sc = &stream.Client{
URL: a.currentServerURL() + "/api/v1/stream",
HTTPClient: hc,
Prepare: func(r *http.Request) error { return a.currentSigner().Sign(r) },
MinBackoff: a.cfg.ReconnectMin,
OnConnect: func() {
a.log.Info("Mit Server verbunden")
a.rememberServerURL()
kick() // Server hat die Routen beim Trennen entfernt: Zustand neu senden
},
OnError: func(err error, retryIn time.Duration) {
// Ausweichweg: nächster Versuch über die nächste bekannte Adresse.
next := a.nextServerURL()
sc.URL = next + "/api/v1/stream"
a.log.Warn("Verbindung zum Server unterbrochen", "err", err, "retry_in", retryIn, "next", next)
},
}
var wg sync.WaitGroup
wg.Go(func() { sc.Run(ctx, a.handleEvent) })
if a.cfg.DockerSocket != NoDocker {
wg.Go(func() { a.watchDocker(ctx, kick) })
}
if a.cfg.ConfigDir != NoDocker {
wg.Go(func() { a.watchServiceDir(ctx, kick) })
}
wg.Go(func() { a.sendLoop(ctx, hc, host, trigger, kick) })
<-ctx.Done()
wg.Wait()
a.log.Info("Agent beendet")
return nil
}
// sendLoop sendet den Zustand, sobald ausgelöst – gesammelt über Debounce.
func (a *Agent) sendLoop(ctx context.Context, hc *http.Client, host string, trigger <-chan struct{}, kick func()) {
for {
select {
case <-ctx.Done():
return
case <-trigger:
}
select {
case <-ctx.Done():
return
case <-time.After(a.cfg.Debounce):
}
state, err := a.collect(ctx, host)
if err == nil {
err = a.postState(ctx, hc, state)
}
switch {
case err == nil:
a.log.Debug("Zustand gesendet", "workloads", len(state.Workloads))
case ctx.Err() != nil:
return
case errors.Is(err, errNotConnected):
// Kein erneuter Versuch nötig: OnConnect löst ohnehin aus.
default:
a.log.Warn("Zustand senden fehlgeschlagen", "err", err, "retry_in", a.cfg.RetryInterval)
time.AfterFunc(a.cfg.RetryInterval, kick)
}
}
}
// collect ermittelt den aktuellen Zustand: Docker-Container und Dienste aus
// ConfigDir samt Healthcheck-Ausfällen. Ohne Docker-Socket (Bare-Metal)
// entfallen die Container; schlägt die Docker-API fehl, wird nichts
// gesendet (sonst würden alle Container-Routen entfernt).
func (a *Agent) collect(ctx context.Context, host string) (registry.NodeState, error) {
state := registry.NodeState{Workloads: []registry.Workload{}}
if a.dockerAvailable() {
containers, err := (&docker.Client{Socket: a.cfg.DockerSocket}).List(ctx)
if err != nil {
return state, err
}
ws, warnings := Workloads(containers, host)
for _, w := range warnings {
a.log.Warn("Container nicht übernommen", "grund", w)
}
state.Workloads = append(state.Workloads, ws...)
}
a.mu.Lock()
specs := a.fileSpecs
a.mu.Unlock()
state.Workloads = append(state.Workloads, FileWorkloads(specs, host)...)
state.DownServices = a.health.down()
return state, nil
}
func (a *Agent) dockerAvailable() bool {
if a.cfg.DockerSocket == NoDocker {
return false
}
_, err := os.Stat(a.cfg.DockerSocket)
return !errors.Is(err, os.ErrNotExist)
}
// watchServiceDir prüft ConfigDir regelmäßig auf Änderungen (Polling statt
// inotify: zuverlässig auch bei Bind-Mounts und atomar ersetzten Dateien).
// Bei einer Änderung werden die Dienste neu geladen, die Healthchecks neu
// gestartet und der Zustand gesendet.
func (a *Agent) watchServiceDir(ctx context.Context, kick func()) {
var last string
var stopHealth context.CancelFunc = func() {}
var healthWG sync.WaitGroup
defer func() { stopHealth(); healthWG.Wait() }()
ticker := time.NewTicker(a.cfg.ConfigPoll)
defer ticker.Stop()
for {
if fp := dirFingerprint(a.cfg.ConfigDir); fp != last {
last = fp
specs, warnings := LoadServiceDir(a.cfg.ConfigDir)
for _, w := range warnings {
a.log.Warn("Dienstdatei fehlerhaft", "grund", w)
}
a.log.Info("Dienste aus Konfigurationsverzeichnis geladen", "dir", a.cfg.ConfigDir, "anzahl", len(specs))
a.mu.Lock()
a.fileSpecs = specs
a.mu.Unlock()
stopHealth()
healthWG.Wait()
hctx, cancel := context.WithCancel(ctx)
stopHealth = cancel
keep := map[string]bool{}
for _, s := range specs {
if s.Healthcheck != nil {
keep[s.Name] = true
healthWG.Go(func() { a.healthLoop(hctx, s.Name, *s.Healthcheck, kick) })
}
}
a.health.retain(keep)
kick()
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (a *Agent) healthLoop(ctx context.Context, name string, spec HealthSpec, kick func()) {
client := &http.Client{}
ticker := time.NewTicker(spec.Interval.Duration)
defer ticker.Stop()
for {
err := checkHealth(ctx, client, spec)
if ctx.Err() != nil {
return
}
if a.health.report(name, spec.Failures, err == nil) {
if err != nil {
a.log.Warn("Dienst ausgefallen – Wartungsseite aktiv", "service", name, "grund", err)
} else {
a.log.Info("Dienst wieder erreichbar", "service", name)
}
kick()
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
// dirFingerprint fasst Namen und Inhalte aller YAML-Dateien zusammen.
func dirFingerprint(dir string) string {
entries, err := os.ReadDir(dir)
if err != nil {
return "fehlt"
}
h := sha256.New()
for _, e := range entries {
if ext := filepath.Ext(e.Name()); e.IsDir() || (ext != ".yaml" && ext != ".yml") {
continue
}
data, _ := os.ReadFile(filepath.Join(dir, e.Name()))
fmt.Fprintf(h, "%s\x00%d\x00", e.Name(), len(data))
h.Write(data)
}
return hex.EncodeToString(h.Sum(nil))
}
func (a *Agent) postState(ctx context.Context, hc *http.Client, state registry.NodeState) error {
body, err := json.Marshal(state)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.currentServerURL()+"/api/v1/state", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if err := a.currentSigner().Sign(req); err != nil {
return err
}
resp, err := hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusNoContent:
return nil
case http.StatusConflict:
return errNotConnected
default:
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("Server antwortet %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
}
}
// watchDocker löst bei jedem Container-Event und nach jedem (Wieder-)Aufbau
// des Event-Streams ein erneutes Senden aus.
func (a *Agent) watchDocker(ctx context.Context, kick func()) {
client := &docker.Client{Socket: a.cfg.DockerSocket}
backoff := a.cfg.ReconnectMin
for ctx.Err() == nil {
events, errc := client.Events(ctx)
kick() // verpasste Änderungen nachholen
received := false
for range events {
received = true
kick()
}
err := <-errc
if ctx.Err() != nil {
return
}
if received {
backoff = a.cfg.ReconnectMin
}
a.log.Debug("Docker-Events unterbrochen", "err", err, "retry_in", backoff)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
backoff = min(backoff*2, 30*time.Second)
}
}
+837
View File
@@ -0,0 +1,837 @@
package agent
import (
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/docker"
"sultan/pkg/license"
"sultan/pkg/server"
"sultan/pkg/store"
"sultan/pkg/stream"
)
var ctx = context.Background()
func eventually(t *testing.T, msg string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("Bedingung nicht erfüllt: %s", msg)
}
time.Sleep(10 * time.Millisecond)
}
}
// testServer ist ein echter sultan-Server auf freien Ports.
type testServer struct {
*server.Server
dataDir string
}
func startServer(t *testing.T, dataDir, agentAddr string) *testServer {
return startServerFull(t, dataDir, agentAddr, nil)
}
func startServerWith(t *testing.T, mutate func(*server.Config)) *testServer {
return startServerFull(t, "", "", mutate)
}
func startServerFull(t *testing.T, dataDir, agentAddr string, mutate func(*server.Config)) *testServer {
t.Helper()
if dataDir == "" {
dataDir = filepath.Join(t.TempDir(), "server")
}
if agentAddr == "" {
agentAddr = "127.0.0.1:0"
}
cfg := server.Config{
DataDir: dataDir, AgentAddr: agentAddr, ProviderAddr: "127.0.0.1:0", LanderAddr: "127.0.0.1:0",
RefreshInterval: 20 * time.Millisecond,
Logger: slog.New(slog.DiscardHandler),
}
if mutate != nil {
mutate(&cfg)
}
s, err := server.New(ctx, cfg)
if err != nil {
t.Fatal(err)
}
if err := s.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Shutdown(ctx) })
return &testServer{Server: s, dataDir: dataDir}
}
func (s *testServer) url() string { return "https://" + s.AgentAddr() }
func (s *testServer) db(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(ctx, filepath.Join(s.dataDir, "sultan.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
// invite legt (falls nötig) den Mandanten an und erzeugt eine Einladung.
func (s *testServer) invite(t *testing.T, tenant string) string {
t.Helper()
st := s.db(t)
if _, err := st.GetTenant(ctx, tenant); err != nil {
if _, err := st.CreateTenant(ctx, store.Tenant{
ID: tenant, Name: tenant, Email: tenant + "@example.com", Tier: store.TierPro, MaxRoutes: 10,
AllowedDomains: []string{"kunde.de", "*.kunde.de"}, ValidUntil: time.Now().AddDate(1, 0, 0),
}); err != nil {
t.Fatal(err)
}
}
tok, _ := crypto.NewEnrollmentToken(s.Fingerprint(), "")
if err := st.AddEnrollmentToken(ctx, tok.Secret, tenant, time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
return tok.String()
}
func (s *testServer) traefikConfig(t *testing.T) 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 (s *testServer) nodeStatus(t *testing.T, id string) store.NodeStatus {
n, err := s.db(t).GetNode(ctx, id)
if err != nil {
return ""
}
return n.Status
}
// fakeDocker bedient /containers/json und /events über einen Unix-Socket.
type fakeDocker struct {
socket string
mu sync.Mutex
containers []docker.Container
subs []chan docker.Event
}
func newFakeDocker(t *testing.T, containers ...docker.Container) *fakeDocker {
t.Helper()
dir, err := os.MkdirTemp("", "ag")
if err != nil {
t.Fatal(err)
}
f := &fakeDocker{socket: filepath.Join(dir, "d.sock"), containers: containers}
ln, err := net.Listen("unix", f.socket)
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
json.NewEncoder(w).Encode(f.containers)
})
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
ch := make(chan docker.Event, 8)
f.mu.Lock()
f.subs = append(f.subs, ch)
f.mu.Unlock()
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
for {
select {
case <-r.Context().Done():
return
case ev := <-ch:
json.NewEncoder(w).Encode(ev)
w.(http.Flusher).Flush()
}
}
})
srv := &http.Server{Handler: mux}
go srv.Serve(ln)
t.Cleanup(func() { srv.Close(); os.RemoveAll(dir) })
return f
}
// set ersetzt die Containerliste und meldet ein Event.
func (f *fakeDocker) set(action string, containers ...docker.Container) {
f.mu.Lock()
f.containers = containers
subs := append([]chan docker.Event(nil), f.subs...)
f.mu.Unlock()
for _, ch := range subs {
ch <- docker.Event{Type: "container", Action: action, Actor: docker.Actor{ID: "x"}}
}
}
func wordpress() docker.Container {
return ctr("wordpress", wpLabels(), pub(80, 8081))
}
func testConfig(t *testing.T, dataDir string, s *testServer, token, socket string) Config {
return Config{
DataDir: dataDir,
ServerURL: s.url(),
Token: token,
NodeID: "node-1",
Hostname: "pi",
DockerSocket: socket,
BackendHost: "100.64.0.42",
Logger: slog.New(slog.DiscardHandler),
Debounce: 20 * time.Millisecond,
RetryInterval: 50 * time.Millisecond,
ReconnectMin: 20 * time.Millisecond,
}
}
// runAgent startet den Agenten im Hintergrund; stop beendet ihn und
// liefert den Rückgabewert von Run.
func runAgent(t *testing.T, cfg Config) (stop func() error) {
t.Helper()
a, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
actx, cancel := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- a.Run(actx) }()
var once sync.Once
var result error
stop = func() error {
once.Do(func() {
cancel()
select {
case result = <-done:
case <-time.After(5 * time.Second):
result = errors.New("Agent endet nicht")
}
})
return result
}
t.Cleanup(func() { stop() })
return stop
}
// Der ganze Weg: Enrollment, Stream, Container → Route bei Traefik,
// Container weg → Route weg, Agent weg → offline, Neustart ohne Token.
func TestRun_EndToEnd(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t, wordpress())
dataDir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dataDir, s, s.invite(t, "kunde"), dock.socket)
stop := runAgent(t, cfg)
eventually(t, "Route bei Traefik", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__wp") && strings.Contains(c, "http://100.64.0.42:8081")
})
if st := s.nodeStatus(t, "node-1"); st != store.NodeOnline {
t.Errorf("Knotenstatus %q", st)
}
dock.set("die") // Container gestoppt
eventually(t, "Route nach Containerstopp entfernt", func() bool { return s.traefikConfig(t) == "{}" })
dock.set("start", wordpress())
eventually(t, "Route nach Containerstart zurück", func() bool {
return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp")
})
if err := stop(); err != nil {
t.Errorf("Run: %v", err)
}
eventually(t, "Knoten offline", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOffline })
// Ungeplant weg: Wartungsseite, solange die Lease gilt.
eventually(t, "Wartungsseite", func() bool { return strings.Contains(s.traefikConfig(t), "sultan__interceptor") })
// Neustart: schon enrollt, kein Token mehr nötig (Einladung ist verbraucht).
cfg.Token = ""
runAgent(t, cfg)
eventually(t, "Route nach Neustart des Agenten wieder normal", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__wp") && !strings.Contains(c, "sultan__interceptor")
})
}
// Nach einem Neustart des Servers verbindet sich der Agent neu und sendet
// seinen Zustand erneut (OnConnect).
func TestRun_ResendsStateAfterServerRestart(t *testing.T) {
serverDir := filepath.Join(t.TempDir(), "server")
s := startServer(t, serverDir, "")
addr := s.AgentAddr()
dock := newFakeDocker(t, wordpress())
runAgent(t, testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket))
eventually(t, "Route", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") })
s.Shutdown(ctx)
s2 := startServer(t, serverDir, addr) // gleiche Identität, gleiche Adresse
eventually(t, "Route nach Server-Neustart", func() bool {
return strings.Contains(s2.traefikConfig(t), "kunde__node-1__wp")
})
}
func TestEnroll_WritesIdentity(t *testing.T) {
s := startServer(t, "", "")
dataDir := filepath.Join(t.TempDir(), "suldata")
a, err := New(testConfig(t, dataDir, s, s.invite(t, "kunde"), ""))
if err != nil {
t.Fatal(err)
}
if err := a.Enroll(ctx); err != nil {
t.Fatalf("Enroll: %v", err)
}
for file, perm := range map[string]os.FileMode{"node.key": 0o600, "node.json": 0o600} {
info, err := os.Stat(filepath.Join(dataDir, file))
if err != nil || info.Mode().Perm() != perm {
t.Errorf("%s: %v %v", file, info, err)
}
}
var id Identity
b, _ := os.ReadFile(filepath.Join(dataDir, "node.json"))
if err := json.Unmarshal(b, &id); err != nil {
t.Fatal(err)
}
if id.NodeID != "node-1" || id.TenantID != "kunde" || id.Server != s.url() ||
id.MasterPublicKey != auth.EncodePublicKey(s.MasterPublicKey()) || id.ServerFingerprint != s.Fingerprint() {
t.Errorf("node.json: %+v", id)
}
if _, err := os.Stat(filepath.Join(dataDir, "known_servers")); err == nil {
t.Error("known_servers ist überflüssig – der Pin steht in node.json")
}
// Zweites Enroll: nichts zu tun, kein Token nötig.
a2, _ := New(Config{DataDir: dataDir, Logger: slog.New(slog.DiscardHandler)})
if err := a2.Enroll(ctx); err != nil {
t.Errorf("erneutes Enroll ohne Token: %v", err)
}
}
func TestEnroll_Errors(t *testing.T) {
s := startServer(t, "", "")
other := startServer(t, "", "")
t.Run("erster Start ohne Token", func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
a, _ := New(testConfig(t, dir, s, "", ""))
if err := a.Enroll(ctx); !errors.Is(err, ErrNotEnrolled) {
t.Errorf("erwartet ErrNotEnrolled, bekommen %v", err)
}
})
t.Run("Token ohne URL und ohne --server", func(t *testing.T) {
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), "")
cfg.ServerURL = ""
a, _ := New(cfg)
if err := a.Enroll(ctx); !errors.Is(err, ErrNotEnrolled) || !strings.Contains(err.Error(), "--server") {
t.Errorf("erwartet Hinweis auf --server, bekommen %v", err)
}
})
t.Run("Token eines anderen Servers", func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
a, _ := New(testConfig(t, dir, s, other.invite(t, "kunde"), ""))
if err := a.Enroll(ctx); !errors.Is(err, crypto.ErrFingerprintMismatch) {
t.Errorf("erwartet ErrFingerprintMismatch, bekommen %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "node.json")); err == nil {
t.Error("node.json trotz Fehlschlag geschrieben")
}
})
t.Run("Einladung verbraucht", func(t *testing.T) {
tok := s.invite(t, "kunde")
a, _ := New(testConfig(t, filepath.Join(t.TempDir(), "a"), s, tok, ""))
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
cfg := testConfig(t, filepath.Join(t.TempDir(), "b"), s, tok, "")
cfg.NodeID = "node-2"
b, _ := New(cfg)
if err := b.Enroll(ctx); err == nil || !strings.Contains(err.Error(), "403") {
t.Errorf("verbrauchte Einladung: %v", err)
}
})
// Registriert bei s, dann kommt eine Einladung von other: anderer
// Fingerprint = anderer Server = nur mit leerem Datenverzeichnis.
t.Run("Einladung eines anderen Servers trotz Registrierung", func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), "")
cfg.NodeID = "node-wechsel" // node-1 ist auf s schon vergeben
first, _ := New(cfg)
if err := first.Enroll(ctx); err != nil {
t.Fatal(err)
}
switched, _ := New(testConfig(t, dir, other, other.invite(t, "kunde"), ""))
if err := switched.Enroll(ctx); err == nil || !strings.Contains(err.Error(), "anderen Server") {
t.Errorf("Serverwechsel ohne neues Enrollment: %v", err)
}
})
}
func TestNew_Validation(t *testing.T) {
dir := t.TempDir()
if _, err := New(Config{DataDir: dir, ServerURL: "http://ingress:8443", Token: "x", NodeID: "n"}); err == nil {
t.Error("http-URL akzeptiert")
}
if _, err := New(Config{DataDir: dir, ServerURL: "https://ingress:8443", NodeID: "a__b"}); err == nil {
t.Error("ungültige Knoten-ID akzeptiert")
}
a, err := New(Config{DataDir: dir, ServerURL: "https://ingress:8443/", Hostname: "Mein.Pi"})
if err != nil {
t.Fatal(err)
}
if a.cfg.NodeID != "mein-pi" || a.cfg.ServerURL != "https://ingress:8443" || a.cfg.DockerSocket != "/var/run/docker.sock" {
t.Errorf("Defaults: %+v", a.cfg)
}
if def, _ := New(Config{}); def.cfg.DataDir != "./suldata" {
t.Errorf("Default-Datenverzeichnis: %q", def.cfg.DataDir)
}
}
// Ohne Docker (Bare-Metal) meldet der Agent einen leeren Zustand und bleibt
// verbunden; ein fehlender Socket ist kein Fehler.
func TestRun_WithoutDocker(t *testing.T) {
s := startServer(t, "", "")
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), "/gibt/es/nicht.sock")
runAgent(t, cfg)
eventually(t, "Knoten online", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
if c := s.traefikConfig(t); c != "{}" {
t.Errorf("Konfiguration ohne Docker: %s", c)
}
}
// Läuft Run ohne Enrollment-Möglichkeit, endet es sofort mit Fehler.
func TestRun_NotEnrolled(t *testing.T) {
s := startServer(t, "", "")
a, _ := New(testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, "", ""))
if err := a.Run(ctx); !errors.Is(err, ErrNotEnrolled) {
t.Errorf("erwartet ErrNotEnrolled, bekommen %v", err)
}
}
// Bare-Metal-Weg: Dienst aus /etc/sultan.d → Route; Healthcheck schlägt fehl →
// Wartungsseite (Interceptor); Dienst erholt sich → wieder normal; Datei
// geändert/gelöscht → Konfiguration folgt.
func TestRun_ServiceDirWithHealthcheck(t *testing.T) {
s := startServer(t, "", "")
var healthy atomic.Bool
healthy.Store(true)
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !healthy.Load() {
http.Error(w, "kaputt", http.StatusInternalServerError)
return
}
w.Write([]byte("<html><head><title>Shop</title></head><body><h2>Impressum</h2></body></html>"))
}))
defer backend.Close()
port := backend.URL[strings.LastIndex(backend.URL, ":")+1:]
confDir := t.TempDir()
writeConf := func(content string) {
if err := os.WriteFile(filepath.Join(confDir, "shop.yaml"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
writeConf(`
services:
- name: shop
url: ` + backend.URL + `
hosts: [shop.kunde.de]
healthcheck: { interval: 30ms, failures: 2, expect_title: Shop }
`)
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), NoDocker)
cfg.ConfigDir = confDir
cfg.ConfigPoll = 20 * time.Millisecond
runAgent(t, cfg)
eventually(t, "Route aus sultan.d", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__shop") && strings.Contains(c, "http://100.64.0.42:"+port)
})
if strings.Contains(s.traefikConfig(t), "sultan__interceptor") {
t.Error("gesunder Dienst wird umgeleitet")
}
healthy.Store(false)
eventually(t, "Wartungsseite bei Ausfall", func() bool {
return strings.Contains(s.traefikConfig(t), "sultan__interceptor")
})
healthy.Store(true)
eventually(t, "zurück zum Dienst nach Erholung", func() bool {
return !strings.Contains(s.traefikConfig(t), "sultan__interceptor")
})
writeConf(`
services:
- name: shop
url: ` + backend.URL + `
hosts: [shop.kunde.de, laden.kunde.de]
`)
eventually(t, "geänderte Datei übernommen", func() bool {
return strings.Contains(s.traefikConfig(t), "laden.kunde.de")
})
os.Remove(filepath.Join(confDir, "shop.yaml"))
eventually(t, "gelöschte Datei → Route weg", func() bool { return s.traefikConfig(t) == "{}" })
}
// Docker- und Datei-Workloads werden gemeinsam gemeldet.
func TestRun_DockerAndServiceDirTogether(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t, wordpress())
confDir := t.TempDir()
os.WriteFile(filepath.Join(confDir, "blog.yaml"), []byte(`
services:
- name: blog
url: http://127.0.0.1:8090
hosts: [blog.kunde.de]
`), 0o644)
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket)
cfg.ConfigDir = confDir
cfg.ConfigPoll = 20 * time.Millisecond
runAgent(t, cfg)
eventually(t, "beide Routen", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__wp") && strings.Contains(c, "kunde__node-1__blog")
})
}
// inviteURL erzeugt eine Einladung mit eingebetteter Server-URL.
func (s *testServer) inviteURL(t *testing.T, tenant, serverURL string) string {
t.Helper()
s.invite(t, tenant) // legt den Mandanten bei Bedarf an
tok, err := crypto.NewEnrollmentToken(s.Fingerprint(), serverURL)
if err != nil {
t.Fatal(err)
}
if err := s.db(t).AddEnrollmentToken(ctx, tok.Secret, tenant, time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
return tok.String()
}
func readIdentity(t *testing.T, dataDir string) Identity {
t.Helper()
var id Identity
b, err := os.ReadFile(filepath.Join(dataDir, "node.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(b, &id); err != nil {
t.Fatal(err)
}
return id
}
// Ein einziger Token genügt: die Adresse steckt darin.
func TestEnroll_URLFromToken(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.inviteURL(t, "kunde", s.url()), "")
cfg.ServerURL = ""
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatalf("Enroll nur mit Token: %v", err)
}
if id := readIdentity(t, dir); id.Server != s.url() || id.ServerFingerprint != s.Fingerprint() {
t.Errorf("node.json: %+v", id)
}
}
// --server schlägt die URL im Token (z. B. interne Adresse statt öffentlicher).
func TestEnroll_ServerFlagOverridesTokenURL(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.inviteURL(t, "kunde", "https://nicht-erreichbar.invalid:8443"), "")
a, _ := New(cfg) // cfg.ServerURL = s.url()
if err := a.Enroll(ctx); err != nil {
t.Fatalf("Enroll mit Override: %v", err)
}
if id := readIdentity(t, dir); id.Server != s.url() {
t.Errorf("gespeicherte Adresse %q, erwartet die tatsächlich genutzte %q", id.Server, s.url())
}
}
// Ist der Agent registriert, darf dieselbe Einladung (bzw. eine des
// gleichen Servers) erneut mitgegeben werden – sie wird ignoriert.
func TestEnroll_SameServerTokenIgnoredWhenEnrolled(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
tok := s.inviteURL(t, "kunde", s.url())
cfg := testConfig(t, dir, s, tok, "")
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
again, _ := New(cfg) // gleicher, inzwischen verbrauchter Token (z. B. in docker-compose stehen geblieben)
if err := again.Enroll(ctx); err != nil {
t.Errorf("erneuter Start mit altem Token: %v", err)
}
}
// Der Pin hängt am Server, nicht an der Adresse: derselbe Server unter
// neuer Adresse funktioniert, die neue Adresse wird übernommen.
func TestRun_AddressChangePersisted(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t, wordpress())
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), dock.socket)
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
moved := "https://localhost" + s.url()[len("https://127.0.0.1"):]
cfg.Token, cfg.ServerURL = "", moved
runAgent(t, cfg)
eventually(t, "Route über neue Adresse", func() bool {
return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp")
})
eventually(t, "neue Adresse gespeichert", func() bool { return readIdentity(t, dir).Server == moved })
if id := readIdentity(t, dir); id.ServerFingerprint != s.Fingerprint() {
t.Errorf("Fingerprint verändert: %+v", id)
}
}
// `sultan agent maintenance on|off` auf dem Knoten, z. B. aus einem Patch-Skript.
func TestSetMaintenance(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t, wordpress())
dir := filepath.Join(t.TempDir(), "suldata")
runAgent(t, testConfig(t, dir, s, s.invite(t, "kunde"), dock.socket))
eventually(t, "Route", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") })
ctl, err := New(Config{DataDir: dir, Logger: slog.New(slog.DiscardHandler)})
if err != nil {
t.Fatal(err)
}
if err := ctl.SetMaintenance(ctx, true, 2*time.Hour, "Kernel-Update", "Sicherheitsupdate"); err != nil {
t.Fatalf("SetMaintenance: %v", err)
}
n, _ := s.db(t).GetNode(ctx, "node-1")
if n.Maintenance == nil || n.Maintenance.Reason != "Kernel-Update" || n.Maintenance.PublicReason != "Sicherheitsupdate" {
t.Errorf("gespeichert: %+v", n.Maintenance)
}
if !strings.Contains(s.traefikConfig(t), "sultan__interceptor") {
t.Error("Wartung nicht wirksam")
}
if err := ctl.SetMaintenance(ctx, false, 0, "", ""); err != nil {
t.Fatal(err)
}
if strings.Contains(s.traefikConfig(t), "sultan__interceptor") {
t.Error("Wartung nicht beendet")
}
notEnrolled, _ := New(Config{DataDir: filepath.Join(t.TempDir(), "leer"), Logger: slog.New(slog.DiscardHandler)})
if err := notEnrolled.SetMaintenance(ctx, true, time.Hour, "", ""); !errors.Is(err, ErrNotEnrolled) {
t.Errorf("nicht registriert: %v", err)
}
}
func TestRun_LeaseRecorded(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
var logs syncBuffer
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker)
cfg.Logger = slog.New(slog.NewTextHandler(&logs, nil))
runAgent(t, cfg)
eventually(t, "lease.json", func() bool {
_, err := os.Stat(filepath.Join(dir, "lease.json"))
return err == nil
})
b, _ := os.ReadFile(filepath.Join(dir, "lease.json"))
l, err := license.ParseLease(b, s.MasterPublicKey())
if err != nil || l.NodeID != "node-1" {
t.Errorf("lease.json: %+v %v", l, err)
}
eventually(t, "Lease im Log", func() bool { return strings.Contains(logs.String(), "Lease erhalten") })
}
// resync: Der Server fordert den Zustand neu an (hier: Container geändert,
// ohne dass Docker ein Event geschickt hat).
func TestRun_ResyncCommand(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t)
runAgent(t, testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket))
eventually(t, "online", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
dock.mu.Lock()
dock.containers = []docker.Container{wordpress()}
dock.mu.Unlock()
if err := s.db(t).EnqueueNodeCommand(ctx, "node-1", stream.CmdResync, ""); err != nil {
t.Fatal(err)
}
eventually(t, "Zustand nach resync", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") })
}
func TestRun_RotateKeyCommand(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t)
dir := filepath.Join(t.TempDir(), "suldata")
runAgent(t, testConfig(t, dir, s, s.invite(t, "kunde"), dock.socket))
eventually(t, "online", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
before, _ := os.ReadFile(filepath.Join(dir, "node.key"))
oldPub, _ := s.db(t).GetNode(ctx, "node-1")
s.db(t).EnqueueNodeCommand(ctx, "node-1", stream.CmdRotateKey, "Routine")
eventually(t, "Schlüssel gewechselt", func() bool {
n, _ := s.db(t).GetNode(ctx, "node-1")
after, _ := os.ReadFile(filepath.Join(dir, "node.key"))
return n.PublicKey != oldPub.PublicKey && string(after) != string(before)
})
if _, err := os.Stat(filepath.Join(dir, "node.key.new")); err == nil {
t.Error("node.key.new nach erfolgreicher Rotation noch vorhanden")
}
// Der Agent arbeitet mit dem neuen Schlüssel weiter.
dock.set("start", wordpress())
eventually(t, "Zustand mit neuem Schlüssel", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") })
}
// Abbruch mitten in der Rotation: Der Server hat den neuen Schlüssel schon,
// der Agent hat node.key.new noch nicht umbenannt. Beim nächsten Start wird
// das sicher abgeschlossen.
func TestRotateKey_RecoveryAfterCrash(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker)
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
newKey, _ := auth.GenerateKey()
if err := auth.WritePrivateKey(filepath.Join(dir, "node.key.new"), newKey); err != nil {
t.Fatal(err)
}
newPub := auth.EncodePublicKey(newKey.Public().(ed25519.PublicKey))
if err := s.db(t).UpdateNodePublicKey(ctx, "node-1", newPub); err != nil { // Server war schon fertig
t.Fatal(err)
}
cfg.Token = ""
runAgent(t, cfg)
eventually(t, "online mit neuem Schlüssel", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
got, err := auth.ReadPrivateKey(filepath.Join(dir, "node.key"))
if err != nil || !got.Equal(newKey) {
t.Errorf("node.key nicht durch den neuen Schlüssel ersetzt: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "node.key.new")); err == nil {
t.Error("node.key.new noch vorhanden")
}
}
// Unsignierte oder gefälschte Events werden ignoriert – kein Befehl ohne
// gültige Signatur des Servers.
func TestHandleEvent_RejectsUnsigned(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
var logs syncBuffer
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker)
cfg.Logger = slog.New(slog.NewTextHandler(&logs, nil))
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(filepath.Join(dir, "node.key"))
forged, _ := stream.NewEvent(stream.EventNodeCommand, stream.NodeCommand{Command: stream.CmdRotateKey})
if err := a.handleEvent(forged); err != nil {
t.Errorf("handleEvent darf den Stream nicht beenden: %v", err)
}
after, _ := os.ReadFile(filepath.Join(dir, "node.key"))
if string(before) != string(after) {
t.Error("unsignierter Befehl wurde ausgeführt")
}
if !strings.Contains(logs.String(), "verworfen") {
t.Errorf("keine Warnung im Log: %s", logs.String())
}
}
type syncBuffer struct {
mu sync.Mutex
b strings.Builder
}
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() }
// Der Server schickt beim Verbinden seine Adressliste; der Agent merkt sie
// sich in node.json (nur gültige https-Adressen).
func TestRun_StoresServerURLsFromConfig(t *testing.T) {
s := startServerWith(t, func(c *server.Config) {
c.PublicURLs = []string{"https://ingress.example:8443", "https://100.64.0.1:8443"}
})
dir := filepath.Join(t.TempDir(), "suldata")
runAgent(t, testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker))
eventually(t, "server_urls in node.json", func() bool {
b, err := os.ReadFile(filepath.Join(dir, "node.json"))
var id Identity
return err == nil && json.Unmarshal(b, &id) == nil && len(id.ServerURLs) == 2
})
if id := readIdentity(t, dir); id.ServerURLs[0] != "https://ingress.example:8443" || id.Server != s.url() {
t.Errorf("node.json: %+v", id)
}
}
// Ausweichweg: Die zuletzt genutzte Adresse ist tot, eine andere aus der
// Liste funktioniert – der Agent verbindet sich darüber und merkt sie sich.
func TestRun_FailoverToNextServerURL(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker)
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
id := readIdentity(t, dir)
dead := "https://127.0.0.1:1"
id.Server, id.ServerURLs = dead, []string{dead, s.url()}
if err := writeJSON(filepath.Join(dir, "node.json"), id); err != nil {
t.Fatal(err)
}
cfg.Token, cfg.ServerURL = "", ""
runAgent(t, cfg)
eventually(t, "online über Ausweichadresse", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
eventually(t, "funktionierende Adresse gemerkt", func() bool { return readIdentity(t, dir).Server == s.url() })
}
// --server hat Vorrang, die übrigen Adressen dienen als Ausweichweg.
func TestServerCandidates(t *testing.T) {
a := &Agent{cfg: Config{ServerURL: "https://override:8443"}, id: Identity{
Server: "https://zuletzt:8443", ServerURLs: []string{"https://a:8443", "https://zuletzt:8443", "https://b:8443"},
}}
want := []string{"https://override:8443", "https://zuletzt:8443", "https://a:8443", "https://b:8443"}
if got := a.serverCandidates(); !reflect.DeepEqual(got, want) {
t.Errorf("Kandidaten %v, erwartet %v", got, want)
}
a.cfg.ServerURL = ""
if got := a.serverCandidates(); got[0] != "https://zuletzt:8443" || len(got) != 3 {
t.Errorf("ohne Override: %v", got)
}
}
+175
View File
@@ -0,0 +1,175 @@
package agent
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"sultan/pkg/auth"
"sultan/pkg/license"
"sultan/pkg/server"
"sultan/pkg/stream"
)
// currentSigner liefert den aktuellen Signierer (rotate_key tauscht ihn aus).
func (a *Agent) currentSigner() *auth.Signer {
a.keyMu.RLock()
defer a.keyMu.RUnlock()
return a.signer
}
func (a *Agent) setSigner(s *auth.Signer) {
a.keyMu.Lock()
a.signer = s
a.keyMu.Unlock()
}
// handleEvent verarbeitet Events vom Server. Nur Events mit gültiger
// Signatur des Master-Keys werden beachtet; Fehler beenden nie den Stream.
func (a *Agent) handleEvent(ev stream.Event) error {
pub, err := auth.DecodePublicKey(a.id.MasterPublicKey)
if err != nil {
a.log.Error("Master-Key in node.json unlesbar – Event verworfen", "err", err)
return nil
}
if err := stream.VerifyEvent(ev, pub); err != nil {
a.log.Warn("Event ohne gültige Server-Signatur verworfen", "type", ev.Type, "err", err)
return nil
}
switch ev.Type {
case stream.EventLeaseRenew:
a.recordLease(ev.Payload, pub)
case stream.EventNodeCommand:
cmd, err := stream.ParseNodeCommand(ev)
if err != nil {
a.log.Warn("Befehl unlesbar", "err", err)
return nil
}
a.runCommand(cmd)
default:
a.log.Info("Ereignis vom Server", "type", ev.Type)
}
return nil
}
// recordLease prüft die Lease, legt sie als lease.json ab und protokolliert
// sie (vorerst ohne weitere Konsequenz, Spec 4a).
func (a *Agent) recordLease(doc []byte, pub ed25519.PublicKey) {
l, err := license.ParseLease(doc, pub)
if err != nil {
a.log.Warn("Lease ungültig – verworfen", "err", err)
return
}
if l.NodeID != a.id.NodeID {
a.log.Warn("Lease für anderen Knoten – verworfen", "node", l.NodeID)
return
}
tmp := a.path("lease.json.tmp")
if err := os.WriteFile(tmp, doc, 0o600); err == nil {
err = os.Rename(tmp, a.path("lease.json"))
}
if err != nil {
a.log.Warn("lease.json nicht gespeichert", "err", err)
}
a.log.Info("Lease erhalten", "valid_until", l.ValidUntil, "renew_after", l.RenewAfter, "tier", l.Tier)
}
func (a *Agent) runCommand(cmd stream.NodeCommand) {
switch cmd.Command {
case stream.CmdResync:
a.log.Info("Server fordert Zustand an (resync)")
if a.kick != nil {
a.kick()
}
case stream.CmdRotateKey:
a.log.Info("Server fordert Schlüsselwechsel an", "reason", cmd.Reason)
ctx := a.runCtx
if ctx == nil {
ctx = context.Background()
}
if err := a.rotateKey(ctx); err != nil {
a.log.Error("Schlüsselwechsel fehlgeschlagen", "err", err)
}
case stream.CmdConfig:
a.updateServerURLs(cmd.ServerURLs)
case stream.CmdDrain, stream.CmdMaintenance, stream.CmdResume:
a.log.Info("Hinweis vom Server", "command", cmd.Command, "reason", cmd.Reason)
default:
a.log.Warn("Unbekannter Befehl ignoriert", "command", cmd.Command)
}
}
// rotateKey erzeugt einen neuen Schlüssel (node.key.new) und meldet ihn.
func (a *Agent) rotateKey(ctx context.Context) error {
newPath := a.path("node.key.new")
if _, err := os.Stat(newPath); errors.Is(err, os.ErrNotExist) {
key, err := auth.GenerateKey()
if err != nil {
return err
}
if err := auth.WritePrivateKey(newPath, key); err != nil {
return err
}
}
return a.completeRotation(ctx)
}
// completeRotation meldet node.key.new beim Server und benennt die Datei erst
// danach um. Abbruch-sicher: Lehnt der Server den alten Schlüssel ab (er hat
// den neuen schon), wird mit dem neuen wiederholt.
func (a *Agent) completeRotation(ctx context.Context) error {
newPath := a.path("node.key.new")
newKey, err := auth.ReadPrivateKey(newPath)
if err != nil {
return err
}
body, _ := json.Marshal(server.RotateKeyRequest{
PublicKey: auth.EncodePublicKey(newKey.Public().(ed25519.PublicKey)),
Proof: auth.RotationProof(a.id.NodeID, newKey),
})
newSigner := &auth.Signer{Key: newKey}
status, err := a.postSigned(ctx, "/api/v1/rotate-key", body, a.currentSigner())
if err == nil && status == http.StatusUnauthorized {
status, err = a.postSigned(ctx, "/api/v1/rotate-key", body, newSigner)
}
if err != nil {
return err
}
if status != http.StatusNoContent {
return fmt.Errorf("Server lehnt Schlüsselwechsel ab (%d)", status)
}
if err := os.Rename(newPath, a.path("node.key")); err != nil {
return err
}
a.setSigner(newSigner)
a.log.Info("Knotenschlüssel gewechselt")
return nil
}
func (a *Agent) postSigned(ctx context.Context, path string, body []byte, signer *auth.Signer) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.currentServerURL()+path, bytes.NewReader(body))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
if err := signer.Sign(req); err != nil {
return 0, err
}
resp, err := a.httpClient().Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusUnauthorized {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return resp.StatusCode, fmt.Errorf("Server antwortet %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
}
return resp.StatusCode, nil
}
+137
View File
@@ -0,0 +1,137 @@
package agent
import (
"context"
"fmt"
"io"
"net/http"
"regexp"
"slices"
"strings"
"sync"
)
var (
tagRe = regexp.MustCompile(`(?s)<[^>]*>`)
spaceRe = regexp.MustCompile(`\s+`)
)
// checkHealth ruft spec.URL ab und prüft Status sowie optional <title> und
// ein Element mit Text. Bewusst einfacher Textabgleich statt HTML-Parser:
// Es geht um "liefert die Seite noch das Erwartete", nicht um Validierung.
func checkHealth(ctx context.Context, client *http.Client, spec HealthSpec) error {
ctx, cancel := context.WithTimeout(ctx, spec.Timeout.Duration)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, spec.URL, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != spec.ExpectStatus {
return fmt.Errorf("Status %d statt %d", resp.StatusCode, spec.ExpectStatus)
}
if spec.ExpectTitle == "" && spec.ExpectContains == nil {
return nil
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
if spec.ExpectTitle != "" {
titles := elementTexts(string(body), "title")
if len(titles) == 0 || titles[0] != strings.TrimSpace(spec.ExpectTitle) {
return fmt.Errorf("<title> %q statt %q", strings.Join(titles, ""), spec.ExpectTitle)
}
}
if c := spec.ExpectContains; c != nil {
for _, text := range elementTexts(string(body), c.Tag) {
if strings.Contains(text, c.Text) {
return nil
}
}
return fmt.Errorf("kein <%s> mit %q", c.Tag, c.Text)
}
return nil
}
// elementTexts liefert den Textinhalt aller <tag>-Elemente (ohne innere Tags,
// Leerraum zusammengefasst). Groß-/Kleinschreibung des Tags ist egal.
func elementTexts(html, tag string) []string {
t := regexp.QuoteMeta(tag)
re := regexp.MustCompile(`(?is)<` + t + `\b[^>]*>(.*?)</` + t + `\s*>`)
var out []string
for _, m := range re.FindAllStringSubmatch(html, -1) {
text := tagRe.ReplaceAllString(m[1], " ")
out = append(out, strings.TrimSpace(spaceRe.ReplaceAllString(text, " ")))
}
return out
}
// healthTracker merkt sich den Zustand je Dienst: "down" erst nach
// threshold Fehlschlägen in Folge, "up" nach einem Erfolg.
type healthTracker struct {
mu sync.Mutex
state map[string]*healthState
}
type healthState struct {
fails int
down bool
}
// report verbucht ein Ergebnis und meldet, ob sich up/down geändert hat.
func (t *healthTracker) report(name string, threshold int, ok bool) bool {
t.mu.Lock()
defer t.mu.Unlock()
if t.state == nil {
t.state = map[string]*healthState{}
}
st := t.state[name]
if st == nil {
st = &healthState{}
t.state[name] = st
}
if ok {
st.fails = 0
if st.down {
st.down = false
return true
}
return false
}
st.fails++
if !st.down && st.fails >= threshold {
st.down = true
return true
}
return false
}
// down liefert die ausgefallenen Dienste, sortiert (nil, wenn keiner).
func (t *healthTracker) down() []string {
t.mu.Lock()
defer t.mu.Unlock()
var out []string
for name, st := range t.state {
if st.down {
out = append(out, name)
}
}
slices.Sort(out)
return out
}
// retain vergisst Dienste, die es nicht mehr gibt.
func (t *healthTracker) retain(keep map[string]bool) {
t.mu.Lock()
defer t.mu.Unlock()
for name := range t.state {
if !keep[name] {
delete(t.state, name)
}
}
}
+98
View File
@@ -0,0 +1,98 @@
package agent
import (
"context"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"time"
)
const page = `<!doctype html><html><head><TITLE> Shop - Willkommen </TITLE></head>
<body><h1>Hallo</h1><h2 class="fuss"><a href="/impressum">Impressum</a></h2></body></html>`
func healthServer(t *testing.T, status int, body string, delay time.Duration) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if delay > 0 {
select {
case <-time.After(delay):
case <-r.Context().Done():
return
}
}
w.WriteHeader(status)
w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv.URL
}
func TestCheckHealth(t *testing.T) {
ok := healthServer(t, 200, page, 0)
tests := []struct {
name string
url string
spec HealthSpec
ok bool
}{
{"nur Status", ok, HealthSpec{ExpectStatus: 200}, true},
{"Titel passt (Leerraum, Großschreibung des Tags egal)", ok, HealthSpec{ExpectStatus: 200, ExpectTitle: "Shop - Willkommen"}, true},
{"Tag mit Text, verschachtelt", ok, HealthSpec{ExpectStatus: 200, ExpectContains: &ContainsSpec{Tag: "H2", Text: "Impressum"}}, true},
{"falscher Status", healthServer(t, 500, page, 0), HealthSpec{ExpectStatus: 200}, false},
{"anderer erwarteter Status", healthServer(t, 204, "", 0), HealthSpec{ExpectStatus: 204}, true},
{"Titel weicht ab", ok, HealthSpec{ExpectStatus: 200, ExpectTitle: "Wartung"}, false},
{"Text fehlt", ok, HealthSpec{ExpectStatus: 200, ExpectContains: &ContainsSpec{Tag: "h2", Text: "Datenschutz"}}, false},
{"Text im falschen Tag", ok, HealthSpec{ExpectStatus: 200, ExpectContains: &ContainsSpec{Tag: "h1", Text: "Impressum"}}, false},
{"Zeitüberschreitung", healthServer(t, 200, page, time.Second), HealthSpec{ExpectStatus: 200, Timeout: Duration{50 * time.Millisecond}}, false},
{"nicht erreichbar", "http://127.0.0.1:1", HealthSpec{ExpectStatus: 200}, false},
}
for _, tt := range tests {
if tt.spec.Timeout.Duration == 0 {
tt.spec.Timeout = Duration{2 * time.Second}
}
tt.spec.URL = tt.url
err := checkHealth(context.Background(), http.DefaultClient, tt.spec)
if (err == nil) != tt.ok {
t.Errorf("%s: err=%v, erwartet ok=%v", tt.name, err, tt.ok)
}
}
}
// Ausfall erst nach N Fehlschlägen in Folge, Erholung nach einem Erfolg –
// gegen Flattern.
func TestHealthTracker(t *testing.T) {
var tr healthTracker
steps := []struct {
ok bool
changed bool
down []string
}{
{true, false, nil},
{false, false, nil},
{true, false, nil}, // Zähler zurückgesetzt
{false, false, nil},
{false, true, []string{"shop"}},
{false, false, []string{"shop"}},
{true, true, nil},
}
for i, s := range steps {
if changed := tr.report("shop", 2, s.ok); changed != s.changed {
t.Errorf("Schritt %d: changed=%v, erwartet %v", i, changed, s.changed)
}
if got := tr.down(); !reflect.DeepEqual(got, s.down) {
t.Errorf("Schritt %d: down=%v, erwartet %v", i, got, s.down)
}
}
tr.report("a", 1, false)
tr.report("b", 1, false)
if got := tr.down(); !reflect.DeepEqual(got, []string{"a", "b"}) {
t.Errorf("sortiert: %v", got)
}
tr.retain(map[string]bool{"b": true})
if got := tr.down(); !reflect.DeepEqual(got, []string{"b"}) {
t.Errorf("nach retain: %v", got)
}
}
+94
View File
@@ -0,0 +1,94 @@
package agent
import (
"fmt"
"net"
"net/url"
"regexp"
"strings"
"sultan/pkg/policy"
)
// backendHost bestimmt die Adresse, unter der der Ingress die Dienste
// dieses Hosts erreicht: explizit gesetzt, sonst die IP des angegebenen
// Interfaces (z. B. wt0 für NetBird), sonst die lokale IP, über die der
// Server erreicht wird.
func backendHost(explicit, iface, serverURL string) (string, error) {
if explicit != "" {
return explicit, nil
}
if iface != "" {
return interfaceIP(iface)
}
addr, err := hostPort(serverURL)
if err != nil {
return "", err
}
// UDP-"Verbindung" sendet nichts, ermittelt aber die ausgehende Adresse.
conn, err := net.Dial("udp", addr)
if err != nil {
return "", fmt.Errorf("agent: Backend-Adresse ermitteln: %w", err)
}
defer conn.Close()
return conn.LocalAddr().(*net.UDPAddr).IP.String(), nil
}
func interfaceIP(name string) (string, error) {
ifc, err := net.InterfaceByName(name)
if err != nil {
return "", fmt.Errorf("agent: Interface %s: %w", name, err)
}
addrs, err := ifc.Addrs()
if err != nil {
return "", err
}
var v6 string
for _, a := range addrs {
ipnet, ok := a.(*net.IPNet)
if !ok {
continue
}
if ip4 := ipnet.IP.To4(); ip4 != nil {
return ip4.String(), nil
}
if v6 == "" && !ipnet.IP.IsLinkLocalUnicast() {
v6 = ipnet.IP.String()
}
}
if v6 != "" {
return v6, nil
}
return "", fmt.Errorf("agent: Interface %s hat keine nutzbare IP", name)
}
// hostPort liefert host:port der Server-URL (Default-Port 443).
func hostPort(serverURL string) (string, error) {
u, err := url.Parse(serverURL)
if err != nil || u.Hostname() == "" {
return "", fmt.Errorf("agent: Server-URL %q ungültig", serverURL)
}
if u.Port() == "" {
return net.JoinHostPort(u.Hostname(), "443"), nil
}
return u.Host, nil
}
var (
nodeIDUnsafe = regexp.MustCompile(`[^a-z0-9_-]+`)
nodeIDDashes = regexp.MustCompile(`-{2,}`)
nodeIDUnders = regexp.MustCompile(`_{2,}`)
)
// DefaultNodeID leitet aus dem Hostnamen eine gültige Knoten-ID ab
// (klein, a–z/0–9/-/_, kein "__").
func DefaultNodeID(hostname string) (string, error) {
id := nodeIDUnsafe.ReplaceAllString(strings.ToLower(strings.TrimSpace(hostname)), "-")
id = nodeIDDashes.ReplaceAllString(id, "-")
id = nodeIDUnders.ReplaceAllString(id, "_")
id = strings.Trim(id, "-_")
if !policy.ValidID(id) {
return "", fmt.Errorf("agent: aus Hostname %q lässt sich keine Knoten-ID ableiten – --node-id angeben", hostname)
}
return id, nil
}
+44
View File
@@ -0,0 +1,44 @@
package agent
import "testing"
func TestBackendHost(t *testing.T) {
tests := []struct {
name, explicit, iface, server, want string
wantErr bool
}{
{"explizit", "100.64.0.42", "", "https://127.0.0.1:8443", "100.64.0.42", false},
{"explizit schlägt Interface", "192.168.1.5", "lo", "https://127.0.0.1:8443", "192.168.1.5", false},
{"Interface", "", "lo", "https://127.0.0.1:8443", "127.0.0.1", false},
{"unbekanntes Interface", "", "gibtsnicht0", "https://127.0.0.1:8443", "", true},
{"automatisch: Route zum Server", "", "", "https://127.0.0.1:8443", "127.0.0.1", false},
{"automatisch ohne Port", "", "", "https://127.0.0.1", "127.0.0.1", false},
}
for _, tt := range tests {
got, err := backendHost(tt.explicit, tt.iface, tt.server)
if (err != nil) != tt.wantErr || got != tt.want {
t.Errorf("%s: %q, %v; erwartet %q (Fehler=%v)", tt.name, got, err, tt.want, tt.wantErr)
}
}
}
func TestDefaultNodeID(t *testing.T) {
tests := map[string]string{
"pi": "pi",
"Proxmox-VM.42.local": "proxmox-vm-42-local",
"web__01": "web_01",
" -host- ": "host",
"äöü-box": "box",
}
for in, want := range tests {
got, err := DefaultNodeID(in)
if err != nil || got != want {
t.Errorf("DefaultNodeID(%q) = %q, %v; erwartet %q", in, got, err, want)
}
}
for _, bad := range []string{"", "---", "äöü"} {
if _, err := DefaultNodeID(bad); err == nil {
t.Errorf("DefaultNodeID(%q): erwartet Fehler", bad)
}
}
}
+286
View File
@@ -0,0 +1,286 @@
package agent
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"time"
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/server"
)
// ErrNotEnrolled: Es gibt noch keine Registrierung und keine (vollständige) Einladung.
var ErrNotEnrolled = errors.New("agent: noch nicht registriert – beim ersten Start --token angeben (und --server, falls die Einladung keine Adresse enthält)")
// Identity ist das Ergebnis des Enrollments (node.json). ServerFingerprint
// ist der Pin: Er gilt für den Server, nicht für eine Adresse – Server kann
// sich ändern (DNS-Umzug, NetBird), solange das Zertifikat dasselbe ist.
type Identity struct {
Server string `json:"server"`
ServerFingerprint crypto.Fingerprint `json:"server_fingerprint"`
// ServerURLs: weitere Adressen des Servers (per config-Befehl), Ausweichweg.
ServerURLs []string `json:"server_urls,omitempty"`
NodeID string `json:"node_id"`
TenantID string `json:"tenant_id"`
MasterPublicKey string `json:"master_public_key"`
}
func (a *Agent) path(name string) string { return filepath.Join(a.cfg.DataDir, name) }
// Enroll stellt sicher, dass der Agent registriert ist: Liegt node.json
// vor, wird sie geladen; sonst wird die Einladung eingelöst. Der Server wird
// dabei gegen den Fingerprint aus der Einladung geprüft.
//
// Server-Adresse: --server vor der Adresse aus der Einladung vor node.json.
func (a *Agent) Enroll(ctx context.Context) error {
if err := os.MkdirAll(a.cfg.DataDir, 0o700); err != nil {
return fmt.Errorf("agent: Datenverzeichnis: %w", err)
}
key, err := loadOrCreateKey(a.path("node.key"))
if err != nil {
return err
}
a.setSigner(&auth.Signer{Key: key})
if data, err := os.ReadFile(a.path("node.json")); err == nil {
var id Identity
if err := json.Unmarshal(data, &id); err != nil {
return fmt.Errorf("agent: node.json beschädigt: %w", err)
}
if id.ServerFingerprint == "" {
return errors.New("agent: node.json ohne server_fingerprint – Datenverzeichnis leeren und neu einladen")
}
if a.cfg.Token != "" {
// Eine stehen gebliebene Einladung desselben Servers ist harmlos;
// eine Einladung eines anderen Servers ist ein Fehler.
if tok, err := crypto.ParseEnrollmentToken(a.cfg.Token); err == nil && tok.Fingerprint != id.ServerFingerprint {
return fmt.Errorf("agent: bereits bei einem anderen Server registriert (Fingerprint %s) – für den neuen Server das Datenverzeichnis leeren und neu einladen", id.ServerFingerprint)
}
}
a.id = id
a.serverURL = id.Server
if a.cfg.ServerURL != "" {
a.serverURL = a.cfg.ServerURL
}
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if a.cfg.Token == "" {
return ErrNotEnrolled
}
if a.nodeIDErr != nil {
return a.nodeIDErr
}
tok, err := crypto.ParseEnrollmentToken(a.cfg.Token)
if err != nil {
return err
}
serverURL := a.cfg.ServerURL
if serverURL == "" {
serverURL = tok.ServerURL
}
if serverURL == "" {
return fmt.Errorf("%w: die Einladung enthält keine Server-Adresse – --server angeben", ErrNotEnrolled)
}
hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(tok.Fingerprint)}}
body, _ := json.Marshal(server.EnrollRequest{Token: tok.Secret, NodeID: a.cfg.NodeID, Hostname: a.cfg.Hostname})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, serverURL+"/api/v1/enroll", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if err := a.currentSigner().Sign(req); err != nil {
return err
}
resp, err := hc.Do(req)
if err != nil {
return fmt.Errorf("agent: Enrollment: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("agent: Enrollment abgelehnt (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg)))
}
var er server.EnrollResponse
if err := json.NewDecoder(resp.Body).Decode(&er); err != nil {
return fmt.Errorf("agent: Enrollment-Antwort: %w", err)
}
a.id = Identity{
Server: serverURL, ServerFingerprint: tok.Fingerprint,
NodeID: er.NodeID, TenantID: er.TenantID, MasterPublicKey: er.MasterPublicKey,
}
a.serverURL = serverURL
if err := writeJSON(a.path("node.json"), a.id); err != nil {
return err
}
a.log.Info("Registriert", "node", er.NodeID, "tenant", er.TenantID, "server", serverURL)
return nil
}
// serverCandidates liefert die Adressen in Versuchsreihenfolge: --server,
// zuletzt funktionierende Adresse, dann die Liste des Servers.
func (a *Agent) serverCandidates() []string {
var out []string
for _, u := range append([]string{a.cfg.ServerURL, a.id.Server}, a.id.ServerURLs...) {
if u != "" && !slices.Contains(out, u) {
out = append(out, u)
}
}
return out
}
func (a *Agent) currentServerURL() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.serverURL
}
// nextServerURL wechselt nach einem Verbindungsfehler zur nächsten Adresse.
func (a *Agent) nextServerURL() string {
a.mu.Lock()
defer a.mu.Unlock()
cands := a.serverCandidates()
next := cands[(slices.Index(cands, a.serverURL)+1)%len(cands)]
a.serverURL = next
return next
}
// updateServerURLs übernimmt die Adressliste des Servers (nur gültige).
func (a *Agent) updateServerURLs(urls []string) {
var valid []string
for _, u := range urls {
if v, err := crypto.ValidateServerURL(u); err == nil && !slices.Contains(valid, v) {
valid = append(valid, v)
} else if err != nil {
a.log.Warn("Ungültige Server-Adresse ignoriert", "url", u)
}
}
a.mu.Lock()
defer a.mu.Unlock()
if slices.Equal(valid, a.id.ServerURLs) {
return
}
updated := a.id
updated.ServerURLs = valid
if err := writeJSON(a.path("node.json"), updated); err != nil {
a.log.Warn("Server-Adressen nicht gespeichert", "err", err)
return
}
a.id = updated
a.log.Info("Server-Adressen übernommen", "server_urls", valid)
}
// rememberServerURL übernimmt eine abweichende, erfolgreich genutzte
// Adresse in node.json (der Pin bleibt unverändert).
func (a *Agent) rememberServerURL() {
a.mu.Lock()
defer a.mu.Unlock()
if a.id.Server == a.serverURL {
return
}
updated := a.id
updated.Server = a.serverURL
if err := writeJSON(a.path("node.json"), updated); err != nil {
a.log.Warn("Neue Server-Adresse nicht gespeichert", "err", err)
return
}
a.id = updated
a.log.Info("Neue Server-Adresse übernommen", "server", a.serverURL)
}
func loadOrCreateKey(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, err
}
}
return auth.ReadPrivateKey(path)
}
// writeJSON schreibt atomar (temporäre Datei + rename) mit Rechten 0600.
func writeJSON(path string, v any) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// loadIdentity lädt Identität und Schlüssel eines registrierten Agenten,
// ohne etwas anzulegen (für Befehle neben dem laufenden Agenten).
func (a *Agent) loadIdentity() error {
data, err := os.ReadFile(a.path("node.json"))
if errors.Is(err, os.ErrNotExist) {
return ErrNotEnrolled
}
if err != nil {
return err
}
if err := json.Unmarshal(data, &a.id); err != nil {
return fmt.Errorf("agent: node.json beschädigt: %w", err)
}
key, err := auth.ReadPrivateKey(a.path("node.key"))
if err != nil {
return err
}
a.setSigner(&auth.Signer{Key: key})
a.serverURL = a.id.Server
if a.cfg.ServerURL != "" {
a.serverURL = a.cfg.ServerURL
}
return nil
}
// SetMaintenance meldet beim Server Wartung für diesen Knoten an (on) oder
// ab. d = 0 bedeutet unbefristet; reason ist intern, publicReason erscheint
// auf der Wartungsseite.
func (a *Agent) SetMaintenance(ctx context.Context, on bool, d time.Duration, reason, publicReason string) error {
if err := a.loadIdentity(); err != nil {
return err
}
req := server.MaintenanceRequest{Enabled: on}
if on {
req.Duration, req.Reason, req.PublicReason = d.String(), reason, publicReason
}
body, _ := json.Marshal(req)
hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.serverURL+"/api/v1/maintenance", bytes.NewReader(body))
if err != nil {
return err
}
hreq.Header.Set("Content-Type", "application/json")
if err := a.currentSigner().Sign(hreq); err != nil {
return err
}
hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(a.id.ServerFingerprint)}}
resp, err := hc.Do(hreq)
if err != nil {
return fmt.Errorf("agent: Wartung melden: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("agent: Server lehnt Wartung ab (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg)))
}
return nil
}
+303
View File
@@ -0,0 +1,303 @@
package agent
import (
"bytes"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"unicode"
"unicode/utf8"
"gopkg.in/yaml.v3"
"sultan/pkg/license"
"sultan/pkg/registry"
)
// ServiceSpec ist ein Dienst aus /etc/sultan.d/*.yaml (Spec 4.1b) – für
// Bare-Metal-/LAMP-Hosts ohne Docker-Labels.
type ServiceSpec struct {
Name string `yaml:"name"`
URL string `yaml:"url"` // lokaler Dienst; sein Port gilt auch für den Ingress
Hosts []string `yaml:"hosts"`
HTTPS bool `yaml:"https"`
CertResolver string `yaml:"certresolver"`
EntryPoints []string `yaml:"entrypoints"` // Default websecure (https) bzw. web
RedirectHTTPToHTTPS bool `yaml:"redirect_http_to_https"`
RedirectWWW string `yaml:"redirect_www"` // none | to_www | to_non_www
Healthcheck *HealthSpec `yaml:"healthcheck"`
Maintenance bool `yaml:"maintenance"` // Dienst in Wartung (Wartungsseite)
MaintenancePublic string `yaml:"maintenance_public_reason"` // optional, ersetzt den Standardtext
Labels map[string]string `yaml:"labels"` // rohe traefik.*-Labels, überschreiben die erzeugten
}
// HealthSpec beschreibt den Healthcheck eines Dienstes.
type HealthSpec struct {
URL string `yaml:"url"` // Default: URL des Dienstes
Interval Duration `yaml:"interval"` // Default 30s
Timeout Duration `yaml:"timeout"` // Default 5s
Failures int `yaml:"failures"` // Fehlschläge in Folge bis "down", Default 2
ExpectStatus int `yaml:"expect_status"`
ExpectTitle string `yaml:"expect_title"`
ExpectContains *ContainsSpec `yaml:"expect_contains"`
}
// ContainsSpec: Ein Element <Tag> muss Text enthalten.
type ContainsSpec struct {
Tag string `yaml:"tag"`
Text string `yaml:"text"`
}
// Duration liest Go-Dauern wie "30s" aus YAML.
type Duration struct{ time.Duration }
func (d *Duration) UnmarshalYAML(n *yaml.Node) error {
var s string
if err := n.Decode(&s); err != nil {
return err
}
v, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("Dauer %q nicht lesbar (z. B. 30s, 1m)", s)
}
d.Duration = v
return nil
}
var serviceNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$`)
// ParseServices liest eine Datei; unbekannte Felder sind Fehler.
func ParseServices(data []byte) ([]ServiceSpec, error) {
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
var f struct {
Services []ServiceSpec `yaml:"services"`
}
if err := dec.Decode(&f); err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
seen := map[string]bool{}
for i := range f.Services {
s := &f.Services[i]
if err := s.normalize(); err != nil {
return nil, fmt.Errorf("Dienst %d (%q): %w", i+1, s.Name, err)
}
if seen[s.Name] {
return nil, fmt.Errorf("Dienst %q doppelt", s.Name)
}
seen[s.Name] = true
}
return f.Services, nil
}
func (s *ServiceSpec) normalize() error {
if !serviceNameRe.MatchString(s.Name) {
return errors.New("name fehlt oder ungültig (a–z, 0–9, - und _)")
}
u, err := url.Parse(s.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
return fmt.Errorf("url %q muss eine http(s)-URL sein", s.URL)
}
if len(s.Hosts) == 0 {
return errors.New("hosts fehlt")
}
for i, h := range s.Hosts {
n, err := license.NormalizeDomain(h)
if err != nil || strings.HasPrefix(n, "*.") {
return fmt.Errorf("Host %q ungültig (keine Wildcards)", h)
}
s.Hosts[i] = n
}
switch s.RedirectWWW {
case "", "none", "to_www", "to_non_www":
default:
return fmt.Errorf("redirect_www %q ungültig (none, to_www, to_non_www)", s.RedirectWWW)
}
if utf8.RuneCountInString(s.MaintenancePublic) > 300 || strings.ContainsFunc(s.MaintenancePublic, unicode.IsControl) {
return errors.New("maintenance_public_reason: einzeilig, höchstens 300 Zeichen")
}
if s.RedirectHTTPToHTTPS && !s.HTTPS {
return errors.New("redirect_http_to_https erfordert https: true")
}
if h := s.Healthcheck; h != nil {
if h.URL == "" {
h.URL = s.URL
}
if h.Interval.Duration <= 0 {
h.Interval.Duration = 30 * time.Second
}
if h.Timeout.Duration <= 0 {
h.Timeout.Duration = 5 * time.Second
}
if h.Failures <= 0 {
h.Failures = 2
}
if h.ExpectStatus == 0 {
h.ExpectStatus = 200
}
if h.ExpectStatus < 100 || h.ExpectStatus > 599 {
return fmt.Errorf("expect_status %d ungültig", h.ExpectStatus)
}
if c := h.ExpectContains; c != nil && (c.Tag == "" || c.Text == "") {
return errors.New("expect_contains braucht tag und text")
}
}
return nil
}
// LoadServiceDir liest alle *.yaml/*.yml eines Verzeichnisses (alphabetisch).
// Fehlerhafte Dateien und doppelte Dienstnamen werden mit Warnung
// übersprungen, damit eine kaputte Datei nicht alle Dienste abschaltet. Ein
// fehlendes Verzeichnis bedeutet: keine Dienste.
func LoadServiceDir(dir string) ([]ServiceSpec, []string) {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, []string{err.Error()}
}
var specs []ServiceSpec
var warnings []string
origin := map[string]string{}
for _, e := range entries {
ext := filepath.Ext(e.Name())
if e.IsDir() || (ext != ".yaml" && ext != ".yml") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
warnings = append(warnings, fmt.Sprintf("%s: %v", e.Name(), err))
continue
}
parsed, err := ParseServices(data)
if err != nil {
warnings = append(warnings, fmt.Sprintf("%s: %v", e.Name(), err))
continue
}
for _, s := range parsed {
if first, dup := origin[s.Name]; dup {
warnings = append(warnings, fmt.Sprintf("%s: Dienst %q schon in %s definiert – übersprungen", e.Name(), s.Name, first))
continue
}
origin[s.Name] = e.Name()
specs = append(specs, s)
}
}
return specs, warnings
}
// FileWorkloads übersetzt Dienste in Workloads mit Traefik-Labels; danach
// gelten dieselben Regeln (FromLabels, Policy) wie für Docker-Container.
func FileWorkloads(specs []ServiceSpec, host string) []registry.Workload {
var out []registry.Workload
for _, s := range specs {
out = append(out, registry.Workload{Name: "sultan.d/" + s.Name, Host: host, Labels: s.labels()})
}
return out
}
func (s ServiceSpec) labels() map[string]string {
u, _ := url.Parse(s.URL)
port := u.Port()
if port == "" {
port = map[string]string{"http": "80", "https": "443"}[u.Scheme]
}
scheme := "http"
if s.HTTPS {
scheme = "https"
}
hosts := slices.Clone(s.Hosts)
var bases []string
if s.RedirectWWW == "to_www" || s.RedirectWWW == "to_non_www" {
for _, h := range s.Hosts {
if b := strings.TrimPrefix(h, "www."); !slices.Contains(bases, b) {
bases = append(bases, b)
}
}
// Beide Varianten müssen beim Router ankommen, sonst greift die
// Umleitung nie.
for _, b := range bases {
for _, h := range []string{b, "www." + b} {
if !slices.Contains(hosts, h) {
hosts = append(hosts, h)
}
}
}
}
var rules []string
for _, h := range hosts {
rules = append(rules, "Host(`"+h+"`)")
}
rule := strings.Join(rules, " || ")
entryPoints := s.EntryPoints
if len(entryPoints) == 0 {
entryPoints = []string{"web"}
if s.HTTPS {
entryPoints = []string{"websecure"}
}
}
n := s.Name
router := "traefik.http.routers." + n + "."
l := map[string]string{
"traefik.enable": "true",
router + "rule": rule,
router + "entrypoints": strings.Join(entryPoints, ","),
router + "service": n,
"traefik.http.services." + n + ".loadbalancer.server.port": port,
}
if u.Scheme == "https" {
l["traefik.http.services."+n+".loadbalancer.server.scheme"] = "https"
}
if s.HTTPS {
l[router+"tls"] = "true"
if s.CertResolver != "" {
l[router+"tls.certresolver"] = s.CertResolver
}
}
if len(bases) > 0 {
alts := make([]string, len(bases))
for i, b := range bases {
alts[i] = regexp.QuoteMeta(b)
}
mw := "traefik.http.middlewares." + n + "-www.redirectregex."
if s.RedirectWWW == "to_non_www" {
l[mw+"regex"] = `^https?://www\.(` + strings.Join(alts, "|") + `)(.*)`
l[mw+"replacement"] = scheme + "://${1}${2}"
} else {
l[mw+"regex"] = `^https?://(` + strings.Join(alts, "|") + `)(.*)`
l[mw+"replacement"] = scheme + "://www.${1}${2}"
}
l[mw+"permanent"] = "true"
l[router+"middlewares"] = n + "-www"
}
if s.RedirectHTTPToHTTPS {
httpRouter := "traefik.http.routers." + n + "-http."
l[httpRouter+"rule"] = rule
l[httpRouter+"entrypoints"] = "web"
l[httpRouter+"service"] = n
l[httpRouter+"middlewares"] = n + "-https"
l["traefik.http.middlewares."+n+"-https.redirectscheme.scheme"] = "https"
l["traefik.http.middlewares."+n+"-https.redirectscheme.permanent"] = "true"
}
if s.Maintenance {
l[registry.LabelMaintenance] = "true"
if s.MaintenancePublic != "" {
l[registry.LabelMaintenancePublicReason] = s.MaintenancePublic
}
}
for k, v := range s.Labels {
l[k] = v
}
return l
}
+236
View File
@@ -0,0 +1,236 @@
package agent
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"sultan/pkg/traefik"
)
func parse(t *testing.T, yaml string) []ServiceSpec {
t.Helper()
specs, err := ParseServices([]byte(yaml))
if err != nil {
t.Fatalf("ParseServices: %v", err)
}
return specs
}
func TestFileWorkloads_Minimal(t *testing.T) {
specs := parse(t, `
services:
- name: shop
url: http://127.0.0.1:8080
hosts: [shop.kunde.de]
`)
ws := FileWorkloads(specs, "100.64.0.42")
want := map[string]string{
"traefik.enable": "true",
"traefik.http.routers.shop.rule": "Host(`shop.kunde.de`)",
"traefik.http.routers.shop.entrypoints": "web",
"traefik.http.routers.shop.service": "shop",
"traefik.http.services.shop.loadbalancer.server.port": "8080",
}
if len(ws) != 1 || ws[0].Name != "sultan.d/shop" || ws[0].Host != "100.64.0.42" || !reflect.DeepEqual(ws[0].Labels, want) {
t.Errorf("Workloads = %+v", ws)
}
}
func TestFileWorkloads_ComfortSwitches(t *testing.T) {
specs := parse(t, `
services:
- name: kunde-a
url: https://127.0.0.1:8443/
hosts: [Kunde-A.de]
https: true
certresolver: dns-resolver
redirect_http_to_https: true
redirect_www: to_non_www
labels:
traefik.http.middlewares.kunde-a-www.redirectregex.permanent: "false"
`)
rule := "Host(`kunde-a.de`) || Host(`www.kunde-a.de`)"
want := map[string]string{
"traefik.enable": "true",
"traefik.http.routers.kunde-a.rule": rule,
"traefik.http.routers.kunde-a.entrypoints": "websecure",
"traefik.http.routers.kunde-a.service": "kunde-a",
"traefik.http.routers.kunde-a.tls": "true",
"traefik.http.routers.kunde-a.tls.certresolver": "dns-resolver",
"traefik.http.routers.kunde-a.middlewares": "kunde-a-www",
"traefik.http.services.kunde-a.loadbalancer.server.port": "8443",
"traefik.http.services.kunde-a.loadbalancer.server.scheme": "https",
"traefik.http.routers.kunde-a-http.rule": rule,
"traefik.http.routers.kunde-a-http.entrypoints": "web",
"traefik.http.routers.kunde-a-http.service": "kunde-a",
"traefik.http.routers.kunde-a-http.middlewares": "kunde-a-https",
"traefik.http.middlewares.kunde-a-https.redirectscheme.scheme": "https",
"traefik.http.middlewares.kunde-a-https.redirectscheme.permanent": "true",
"traefik.http.middlewares.kunde-a-www.redirectregex.regex": `^https?://www\.(kunde-a\.de)(.*)`,
"traefik.http.middlewares.kunde-a-www.redirectregex.replacement": "https://${1}${2}",
"traefik.http.middlewares.kunde-a-www.redirectregex.permanent": "false", // rohes Label gewinnt
}
got := FileWorkloads(specs, "h")[0].Labels
if !reflect.DeepEqual(got, want) {
for k, v := range want {
if got[k] != v {
t.Errorf("%s = %q, erwartet %q", k, got[k], v)
}
}
for k := range got {
if _, ok := want[k]; !ok {
t.Errorf("unerwartetes Label %s = %q", k, got[k])
}
}
}
}
func TestFileWorkloads_RedirectToWWW(t *testing.T) {
specs := parse(t, `
services:
- name: web
url: http://127.0.0.1
hosts: [www.shop.de, kunde.de]
redirect_www: to_www
`)
l := FileWorkloads(specs, "h")[0].Labels
if l["traefik.http.routers.web.rule"] != "Host(`www.shop.de`) || Host(`kunde.de`) || Host(`shop.de`) || Host(`www.kunde.de`)" {
t.Errorf("Regel = %s", l["traefik.http.routers.web.rule"])
}
if l["traefik.http.middlewares.web-www.redirectregex.regex"] != `^https?://(shop\.de|kunde\.de)(.*)` ||
l["traefik.http.middlewares.web-www.redirectregex.replacement"] != "http://www.${1}${2}" {
t.Errorf("Middleware: %v", l)
}
if l["traefik.http.services.web.loadbalancer.server.port"] != "80" {
t.Errorf("Default-Port: %s", l["traefik.http.services.web.loadbalancer.server.port"])
}
}
// Die erzeugten Labels müssen dieselbe Übersetzung bestehen wie Docker-Labels.
func TestFileWorkloads_LabelsAreValidTraefik(t *testing.T) {
specs := parse(t, `
services:
- name: kunde-a
url: https://127.0.0.1:8443/
hosts: [kunde-a.de]
https: true
redirect_http_to_https: true
redirect_www: to_non_www
`)
w := FileWorkloads(specs, "100.64.0.42")[0]
cfg, err := traefik.FromLabels(w.Host, w.Labels)
if err != nil {
t.Fatalf("FromLabels: %v", err)
}
if len(cfg.HTTP.Routers) != 2 || cfg.HTTP.Services["kunde-a"].LoadBalancer.Servers[0].URL != "https://100.64.0.42:8443" {
t.Errorf("Konfiguration: %+v", cfg.HTTP)
}
}
func TestParseServices_Healthcheck(t *testing.T) {
specs := parse(t, `
services:
- name: shop
url: http://127.0.0.1:8080/start
hosts: [shop.kunde.de]
healthcheck:
interval: 10s
expect_title: "Shop - Willkommen"
expect_contains: { tag: h2, text: Impressum }
- name: blog
url: http://127.0.0.1:8081
hosts: [blog.kunde.de]
healthcheck: {}
`)
h := specs[0].Healthcheck
if h == nil || h.Interval.Duration != 10*time.Second || h.Timeout.Duration != 5*time.Second || h.Failures != 2 ||
h.ExpectStatus != 200 || h.URL != "http://127.0.0.1:8080/start" || h.ExpectTitle != "Shop - Willkommen" ||
h.ExpectContains == nil || h.ExpectContains.Tag != "h2" || h.ExpectContains.Text != "Impressum" {
t.Errorf("Healthcheck: %+v", h)
}
if d := specs[1].Healthcheck; d == nil || d.Interval.Duration != 30*time.Second || d.ExpectStatus != 200 {
t.Errorf("Healthcheck-Defaults: %+v", d)
}
}
func TestParseServices_Errors(t *testing.T) {
tests := map[string]string{
"kein YAML": "services: [",
"unbekanntes Feld": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n farbe: blau\n",
"Name fehlt": "services:\n - url: http://x\n hosts: [a.de]\n",
"Name ungültig": "services:\n - name: a.b\n url: http://x\n hosts: [a.de]\n",
"URL fehlt": "services:\n - name: a\n hosts: [a.de]\n",
"URL kein http": "services:\n - name: a\n url: ftp://x\n hosts: [a.de]\n",
"Hosts fehlen": "services:\n - name: a\n url: http://x\n",
"Host mit Wildcard": "services:\n - name: a\n url: http://x\n hosts: ['*.a.de']\n",
"redirect_www ungültig": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n redirect_www: manchmal\n",
"Redirect ohne https": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n redirect_http_to_https: true\n",
"Intervall unlesbar": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n healthcheck: { interval: oft }\n",
"Status ungültig": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n healthcheck: { expect_status: 42 }\n",
"contains ohne Tag": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n healthcheck: { expect_contains: { text: x } }\n",
"doppelter Name": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n - name: a\n url: http://y\n hosts: [b.de]\n",
}
for name, yaml := range tests {
if _, err := ParseServices([]byte(yaml)); err == nil {
t.Errorf("%s: erwartet Fehler", name)
}
}
}
func TestLoadServiceDir(t *testing.T) {
dir := t.TempDir()
write := func(name, content string) {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
write("b.yaml", "services:\n - name: shop\n url: http://127.0.0.1:8080\n hosts: [shop.kunde.de]\n")
write("a.yml", "services:\n - name: blog\n url: http://127.0.0.1:8081\n hosts: [blog.kunde.de]\n")
write("c.yaml", "services: [") // kaputt
write("d.yaml", "services:\n - name: shop\n url: http://127.0.0.1:9\n hosts: [x.kunde.de]\n") // doppelt
write("notizen.txt", "services:\n - name: txt\n url: http://127.0.0.1:1\n hosts: [t.kunde.de]\n") // ignoriert
specs, warnings := LoadServiceDir(dir)
var names []string
for _, s := range specs {
names = append(names, s.Name)
}
if !reflect.DeepEqual(names, []string{"blog", "shop"}) {
t.Errorf("Dienste = %v (Dateien alphabetisch, Duplikat und .txt ignoriert)", names)
}
if len(warnings) != 2 || !strings.Contains(strings.Join(warnings, "\n"), "c.yaml") || !strings.Contains(strings.Join(warnings, "\n"), "d.yaml") {
t.Errorf("Warnungen = %v", warnings)
}
if specs, warnings := LoadServiceDir(filepath.Join(dir, "fehlt")); len(specs) != 0 || len(warnings) != 0 {
t.Errorf("fehlendes Verzeichnis: %v %v", specs, warnings)
}
}
func TestFileWorkloads_Maintenance(t *testing.T) {
specs := parse(t, `
services:
- name: shop
url: http://127.0.0.1:8080
hosts: [shop.kunde.de]
maintenance: true
maintenance_public_reason: "Inventur bis Montag"
- name: blog
url: http://127.0.0.1:8081
hosts: [blog.kunde.de]
`)
ws := FileWorkloads(specs, "h")
if ws[0].Labels["sultan.maintenance"] != "true" || ws[0].Labels["sultan.maintenance.public_reason"] != "Inventur bis Montag" {
t.Errorf("Wartungs-Labels fehlen: %v", ws[0].Labels)
}
if _, ok := ws[1].Labels["sultan.maintenance"]; ok {
t.Error("Dienst ohne Wartung hat Wartungs-Label")
}
if _, err := ParseServices([]byte("services:\n - name: a\n url: http://x\n hosts: [a.de]\n maintenance_public_reason: \"a\\nb\"\n")); err == nil {
t.Error("mehrzeiliger Grund akzeptiert")
}
}
+147
View File
@@ -0,0 +1,147 @@
package agent
import (
"fmt"
"maps"
"net"
"regexp"
"slices"
"strconv"
"strings"
"sultan/pkg/docker"
"sultan/pkg/registry"
)
const servicesPrefix = "traefik.http.services."
var unsafeName = regexp.MustCompile(`[^A-Za-z0-9_-]`)
// Workloads übersetzt laufende Container in Workloads für service_state.
//
// Berücksichtigt werden nur Container mit traefik.enable=true. Weil der
// Ingress auf einem anderen Host sitzt, wird loadbalancer.server.port (Port
// im Container) auf den veröffentlichten Host-Port umgerechnet. Fehlt das
// Port-Label, gilt wie bei Traefiks Docker-Provider der einzige
// veröffentlichte Port; ohne Service-Labels entsteht ein Service mit dem
// Containernamen. Container, deren Ports von außen nicht erreichbar sind
// (nicht veröffentlicht, nur Loopback, nur UDP), werden mit Warnung
// weggelassen.
func Workloads(containers []docker.Container, host string) ([]registry.Workload, []string) {
var out []registry.Workload
var warnings []string
for _, c := range containers {
if !enabled(c.Labels) {
continue
}
labels, warning := translatePorts(c)
if warning != "" {
warnings = append(warnings, warning)
continue
}
out = append(out, registry.Workload{Name: c.Name(), Host: host, Labels: labels})
}
slices.SortFunc(out, func(a, b registry.Workload) int { return strings.Compare(a.Name, b.Name) })
return out, warnings
}
func enabled(labels map[string]string) bool {
for k, v := range labels {
if strings.EqualFold(k, "traefik.enable") {
on, err := strconv.ParseBool(strings.TrimSpace(v))
return err == nil && on
}
}
return false
}
// reachablePorts: Container-Port → von außen erreichbarer Host-Port (TCP).
func reachablePorts(ports []docker.Port) map[uint16]uint16 {
out := map[uint16]uint16{}
for _, p := range ports {
if p.Type != "tcp" || p.PublicPort == 0 {
continue
}
if ip := net.ParseIP(p.IP); ip != nil && ip.IsLoopback() {
continue
}
out[p.PrivatePort] = p.PublicPort
}
return out
}
type serviceLabels struct {
name string // Schreibweise aus dem Label
portKey string // Original-Schlüssel des Port-Labels, "" wenn keins
}
func translatePorts(c docker.Container) (map[string]string, string) {
labels := maps.Clone(c.Labels)
reachable := reachablePorts(c.Ports)
single := func() (uint16, bool) {
for _, pub := range reachable {
return pub, len(reachable) == 1
}
return 0, false
}
services := map[string]*serviceLabels{}
hasRouters := false
for k := range labels {
lk := strings.ToLower(k)
if strings.HasPrefix(lk, "traefik.http.routers.") {
hasRouters = true
}
rest, ok := strings.CutPrefix(lk, servicesPrefix)
if !ok {
continue
}
svc, attr, ok := strings.Cut(rest, ".")
if !ok {
continue
}
s := services[svc]
if s == nil {
name, _, _ := strings.Cut(k[len(servicesPrefix):], ".")
s = &serviceLabels{name: name}
services[svc] = s
}
if attr == "loadbalancer.server.port" {
s.portKey = k
}
}
if len(services) == 0 {
if !hasRouters {
return labels, ""
}
pub, ok := single()
if !ok {
return nil, fmt.Sprintf("Container %s: %d erreichbare Ports – Label loadbalancer.server.port angeben", c.Name(), len(reachable))
}
svc := unsafeName.ReplaceAllString(c.Name(), "-")
labels[servicesPrefix+svc+".loadbalancer.server.port"] = strconv.Itoa(int(pub))
return labels, ""
}
for _, s := range services {
if s.portKey == "" {
pub, ok := single()
if !ok {
return nil, fmt.Sprintf("Container %s, Service %s: %d erreichbare Ports – Label loadbalancer.server.port angeben", c.Name(), s.name, len(reachable))
}
labels[servicesPrefix+s.name+".loadbalancer.server.port"] = strconv.Itoa(int(pub))
continue
}
private, err := strconv.Atoi(strings.TrimSpace(labels[s.portKey]))
if err != nil {
return nil, fmt.Sprintf("Container %s: Port %q nicht lesbar", c.Name(), labels[s.portKey])
}
pub, ok := reachable[uint16(private)]
if !ok {
return nil, fmt.Sprintf("Container %s: Port %d ist von außen nicht erreichbar (nicht veröffentlicht oder nur 127.0.0.1)", c.Name(), private)
}
labels[s.portKey] = strconv.Itoa(int(pub))
}
return labels, ""
}
+150
View File
@@ -0,0 +1,150 @@
package agent
import (
"reflect"
"strings"
"testing"
"sultan/pkg/docker"
"sultan/pkg/registry"
)
func ctr(name string, labels map[string]string, ports ...docker.Port) docker.Container {
return docker.Container{ID: name + "-id", Names: []string{"/" + name}, State: "running", Labels: labels, Ports: ports}
}
func pub(private, public uint16) docker.Port {
return docker.Port{IP: "0.0.0.0", PrivatePort: private, PublicPort: public, Type: "tcp"}
}
func wpLabels() map[string]string {
return map[string]string{
"traefik.enable": "true",
"traefik.http.routers.wp.rule": "Host(`kunde.de`)",
"traefik.http.services.wp.loadbalancer.server.port": "80",
}
}
func TestWorkloads_TranslatesPublishedPort(t *testing.T) {
in := ctr("wordpress", wpLabels(), pub(80, 8081), pub(443, 8443))
got, warnings := Workloads([]docker.Container{in}, "100.64.0.42")
if len(warnings) != 0 {
t.Errorf("Warnungen: %v", warnings)
}
want := []registry.Workload{{
Name: "wordpress", Host: "100.64.0.42",
Labels: map[string]string{
"traefik.enable": "true",
"traefik.http.routers.wp.rule": "Host(`kunde.de`)",
"traefik.http.services.wp.loadbalancer.server.port": "8081",
},
}}
if !reflect.DeepEqual(got, want) {
t.Errorf("Workloads =\n%+v\nerwartet\n%+v", got, want)
}
if in.Labels["traefik.http.services.wp.loadbalancer.server.port"] != "80" {
t.Error("Eingabe-Labels wurden verändert")
}
}
func TestWorkloads_SkipsDisabled(t *testing.T) {
off := wpLabels()
off["traefik.enable"] = "false"
none := wpLabels()
delete(none, "traefik.enable")
got, warnings := Workloads([]docker.Container{
ctr("aus", off, pub(80, 8081)),
ctr("ohne", none, pub(80, 8082)),
ctr("db", map[string]string{}, pub(5432, 5432)),
}, "h")
if len(got) != 0 || len(warnings) != 0 {
t.Errorf("got=%v warnings=%v", got, warnings)
}
}
func TestWorkloads_UnreachablePorts(t *testing.T) {
tests := map[string][]docker.Port{
"nicht veröffentlicht": {{PrivatePort: 80, Type: "tcp"}},
"nur IPv4-Loopback": {{IP: "127.0.0.1", PrivatePort: 80, PublicPort: 8081, Type: "tcp"}},
"nur IPv6-Loopback": {{IP: "::1", PrivatePort: 80, PublicPort: 8081, Type: "tcp"}},
"nur UDP": {{IP: "0.0.0.0", PrivatePort: 80, PublicPort: 8081, Type: "udp"}},
"anderer Port": {pub(8080, 8081)},
}
for name, ports := range tests {
got, warnings := Workloads([]docker.Container{ctr("wordpress", wpLabels(), ports...)}, "h")
if len(got) != 0 {
t.Errorf("%s: Workload trotz unerreichbarem Port: %+v", name, got)
}
if len(warnings) != 1 || !strings.Contains(warnings[0], "wordpress") || !strings.Contains(warnings[0], "80") {
t.Errorf("%s: Warnungen = %v", name, warnings)
}
}
}
func TestWorkloads_IPv6AnyIsReachable(t *testing.T) {
got, _ := Workloads([]docker.Container{ctr("wordpress", wpLabels(),
docker.Port{IP: "::", PrivatePort: 80, PublicPort: 8081, Type: "tcp"})}, "h")
if len(got) != 1 || got[0].Labels["traefik.http.services.wp.loadbalancer.server.port"] != "8081" {
t.Errorf("got = %+v", got)
}
}
// Wie Traefiks Docker-Provider: ohne Service-Labels gibt es einen Service
// mit dem Containernamen und dem einzigen veröffentlichten Port.
func TestWorkloads_DefaultService(t *testing.T) {
labels := map[string]string{"traefik.enable": "true", "traefik.http.routers.app.rule": "Host(`app.kunde.de`)"}
got, warnings := Workloads([]docker.Container{ctr("my.app", labels, pub(3000, 13000))}, "h")
if len(warnings) != 0 || len(got) != 1 {
t.Fatalf("got=%+v warnings=%v", got, warnings)
}
if got[0].Labels["traefik.http.services.my-app.loadbalancer.server.port"] != "13000" {
t.Errorf("Default-Service fehlt: %v", got[0].Labels)
}
_, warnings = Workloads([]docker.Container{ctr("multi", labels, pub(3000, 13000), pub(3001, 13001))}, "h")
if len(warnings) != 1 || !strings.Contains(warnings[0], "multi") {
t.Errorf("mehrere Ports ohne Port-Label: Warnungen = %v", warnings)
}
}
func TestWorkloads_ServiceWithoutPortLabel(t *testing.T) {
labels := map[string]string{
"traefik.enable": "true",
"traefik.http.routers.wp.rule": "Host(`kunde.de`)",
"traefik.http.services.wp.loadbalancer.passhostheader": "true",
}
got, warnings := Workloads([]docker.Container{ctr("wordpress", labels, pub(80, 8081))}, "h")
if len(warnings) != 0 || len(got) != 1 || got[0].Labels["traefik.http.services.wp.loadbalancer.server.port"] != "8081" {
t.Errorf("got=%+v warnings=%v", got, warnings)
}
}
func TestWorkloads_MultipleServicesAndCaseInsensitiveKeys(t *testing.T) {
labels := map[string]string{
"Traefik.Enable": "TRUE",
"traefik.http.routers.web.rule": "Host(`kunde.de`)",
"traefik.http.routers.web.service": "web",
"traefik.http.routers.api.rule": "Host(`api.kunde.de`)",
"traefik.http.routers.api.service": "api",
"traefik.http.services.web.loadbalancer.server.port": "80",
"Traefik.HTTP.Services.Api.LoadBalancer.Server.Port": "9000",
}
got, warnings := Workloads([]docker.Container{ctr("app", labels, pub(80, 8080), pub(9000, 19000))}, "h")
if len(warnings) != 0 || len(got) != 1 {
t.Fatalf("got=%+v warnings=%v", got, warnings)
}
l := got[0].Labels
if l["traefik.http.services.web.loadbalancer.server.port"] != "8080" || l["Traefik.HTTP.Services.Api.LoadBalancer.Server.Port"] != "19000" {
t.Errorf("Labels = %v", l)
}
}
func TestWorkloads_SortedByName(t *testing.T) {
got, _ := Workloads([]docker.Container{
ctr("b", wpLabels(), pub(80, 8082)),
ctr("a", wpLabels(), pub(80, 8081)),
}, "h")
if len(got) != 2 || got[0].Name != "a" || got[1].Name != "b" {
t.Errorf("Reihenfolge: %+v", got)
}
}
+6 -10
View File
@@ -212,7 +212,7 @@ func TestIntegration_TokenWithPinnedTLS(t *testing.T) {
srv, sc, _ := pinnedServer(t, db, hub)
// Server-Seite (sultan token): Token erzeugen, nur das Secret speichern.
tok, err := crypto.NewEnrollmentToken(sc.Fingerprint)
tok, err := crypto.NewEnrollmentToken(sc.Fingerprint, "")
if err != nil {
t.Fatal(err)
}
@@ -225,9 +225,7 @@ func TestIntegration_TokenWithPinnedTLS(t *testing.T) {
if err != nil {
t.Fatal(err)
}
addr := srv.Listener.Addr().String()
ks := &crypto.KnownServers{Path: filepath.Join(t.TempDir(), "known_servers")}
firstContact := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.ClientTLSConfig(addr, ks, parsed.Fingerprint)}}
firstContact := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(parsed.Fingerprint)}}
priv, _ := GenerateKey()
signer := &Signer{Key: priv}
@@ -240,8 +238,8 @@ func TestIntegration_TokenWithPinnedTLS(t *testing.T) {
t.Fatalf("Enrollment: Status %d", resp.StatusCode)
}
// Ab jetzt nur noch über den Pin, ohne Token-Fingerprint.
pinned := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.ClientTLSConfig(addr, ks, "")}}
// Ab jetzt über den gespeicherten Pin (beim Agent: node.json).
pinned := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(parsed.Fingerprint)}}
client := &stream.Client{URL: srv.URL + "/api/v1/stream", HTTPClient: pinned, Prepare: signer.Sign}
got := make(chan stream.Event, 1)
runCtx, cancel := context.WithCancel(ctx)
@@ -275,12 +273,10 @@ func TestIntegration_MITMNeverSeesToken(t *testing.T) {
_, realCert, _ := pinnedServer(t, db, hub)
mitm, _, mitmHits := pinnedServer(t, homelabStore(t), &stream.Hub{})
tok, _ := crypto.NewEnrollmentToken(realCert.Fingerprint)
tok, _ := crypto.NewEnrollmentToken(realCert.Fingerprint, "")
db.AddEnrollmentToken(ctx, tok.Secret, "homelab", time.Now().Add(time.Hour))
ks := &crypto.KnownServers{Path: filepath.Join(t.TempDir(), "known_servers")}
addr := mitm.Listener.Addr().String()
hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.ClientTLSConfig(addr, ks, tok.Fingerprint)}}
hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(tok.Fingerprint)}}
priv, _ := GenerateKey()
_, err := enroll(&Signer{Key: priv}, hc, mitm.URL, tok.Secret)
+21
View File
@@ -104,3 +104,24 @@ func DecodePublicKey(s string) (ed25519.PublicKey, error) {
}
return ed25519.PublicKey(b), nil
}
// rotationVersion trennt Rotationsnachweise von anderen Signaturen.
const rotationVersion = "SULTAN-ROTATE-V1\n"
func rotationMessage(nodeID string, pub ed25519.PublicKey) []byte {
return []byte(rotationVersion + nodeID + "\n" + EncodePublicKey(pub))
}
// RotationProof beweist beim Schlüsselwechsel (rotate_key) den Besitz des
// neuen Schlüssels; der Nachweis ist an die Knoten-ID gebunden.
func RotationProof(nodeID string, newKey ed25519.PrivateKey) string {
pub := newKey.Public().(ed25519.PublicKey)
return base64.StdEncoding.EncodeToString(ed25519.Sign(newKey, rotationMessage(nodeID, pub)))
}
// VerifyRotationProof prüft einen RotationProof.
func VerifyRotationProof(nodeID string, pub ed25519.PublicKey, proof string) bool {
sig, err := base64.StdEncoding.DecodeString(proof)
return err == nil && len(sig) == ed25519.SignatureSize && len(pub) == ed25519.PublicKeySize &&
ed25519.Verify(pub, rotationMessage(nodeID, pub), sig)
}
+200
View File
@@ -0,0 +1,200 @@
// Package checkmk erzeugt die Ausgabe für Checkmk im Local-Check-Format
// (Spec 6.1): `<status> "<service>" <metriken> <text>` mit 0 OK, 1 WARN,
// 2 CRIT. Der Text ist ASCII (Umlaute umschrieben), einzeilig.
package checkmk
import (
"fmt"
"math"
"slices"
"strings"
"time"
)
// Schwellen (Spec 6.2 / Entscheidungen 2026-10-02).
const (
ContractWarnDays = 30
ContractCritDays = 14
OfflineCritAfter = 15 * time.Minute
)
const (
statusOK = 0
statusW = 1
statusCrit = 2
)
// Tenant ist ein Mandant aus Sicht des Monitorings.
type Tenant struct {
ID string
Commercial bool // Community: keine Vertragsfristen, kein Mahnwesen
Status string
ValidUntil time.Time
DunningLevel int
OverdueSince time.Time
}
// Node ist ein Knoten aus Sicht des Monitorings.
type Node struct {
ID string
Status string // online, offline, drained
OfflineSince time.Time
MaintenanceActive bool
MaintenanceUntil time.Time // Nullwert = unbefristet
MaintenanceReason string // intern – Monitoring ist intern
LeaseUntil time.Time // Nullwert = keine Lease
}
// State ist alles, was ausgegeben wird.
type State struct {
Tenants []Tenant
Nodes []Node
Violations24h int
// OfflineCrit: ab wann ein getrennter Knoten CRIT ist (Default 15 min).
OfflineCrit time.Duration
// Location: Zeitzone für angezeigte Zeitpunkte (Default UTC).
Location *time.Location
}
var label = map[int]string{statusOK: "OK", statusW: "WARN", statusCrit: "CRIT"}
const day = 24 * time.Hour
// Render liefert die Ausgabe, sortiert nach Mandant bzw. Knoten.
func Render(s State, now time.Time) string {
var b strings.Builder
line := func(status int, service, metrics, text string) {
fmt.Fprintf(&b, "%d %q %s %s - %s\n", status, service, metrics, label[status], ascii(text))
}
tenants := slices.Clone(s.Tenants)
slices.SortFunc(tenants, func(a, b Tenant) int { return strings.Compare(a.ID, b.ID) })
for _, t := range tenants {
if !t.Commercial {
continue
}
renderContract(line, t, now)
renderPayment(line, t, now)
}
nodes := slices.Clone(s.Nodes)
slices.SortFunc(nodes, func(a, b Node) int { return strings.Compare(a.ID, b.ID) })
crit := s.OfflineCrit
if crit <= 0 {
crit = OfflineCritAfter
}
loc := s.Location
if loc == nil {
loc = time.UTC
}
for _, n := range nodes {
renderNode(line, n, now, crit, loc)
}
if s.Violations24h == 0 {
line(statusOK, "Sultan_Policy_Violations", "violations_24h=0;1", "keine Policy-Verstoesse in 24 h")
} else {
line(statusW, "Sultan_Policy_Violations", fmt.Sprintf("violations_24h=%d;1", s.Violations24h),
fmt.Sprintf("%d Policy-Verstoesse in 24 h (siehe Server-Log)", s.Violations24h))
}
return b.String()
}
func renderContract(line func(int, string, string, string), t Tenant, now time.Time) {
svc := "Sultan_Tenant_" + t.ID
days := int(math.Floor(t.ValidUntil.Sub(now).Hours() / 24))
metrics := func(d int) string {
return fmt.Sprintf("days_left=%d;%d;%d", max(d, 0), ContractWarnDays, ContractCritDays)
}
switch {
case t.Status == "expired" || !now.Before(t.ValidUntil):
since := int(math.Ceil(now.Sub(t.ValidUntil).Hours() / 24))
line(statusCrit, svc, metrics(0), fmt.Sprintf("Abo seit %d Tagen abgelaufen! Status: %s", since, t.Status))
case t.Status == "quarantined":
line(statusCrit, svc, metrics(days), "Mandant in Quarantaene")
case days <= ContractCritDays:
line(statusCrit, svc, metrics(days), fmt.Sprintf("Abo laeuft in %d Tagen ab! Verlaengerung erforderlich.", days))
case days <= ContractWarnDays:
line(statusW, svc, metrics(days), fmt.Sprintf("Abo laeuft in %d Tagen ab! Verlaengerung erforderlich.", days))
default:
line(statusOK, svc, metrics(days), fmt.Sprintf("Abo aktiv bis %s (%d Tage verbleibend)", t.ValidUntil.UTC().Format(time.DateOnly), days))
}
}
func renderPayment(line func(int, string, string, string), t Tenant, now time.Time) {
svc := "Sultan_Payment_" + t.ID
metrics := fmt.Sprintf("dunning_level=%d;1;2", t.DunningLevel)
if t.DunningLevel == 0 {
line(statusOK, svc, metrics, "keine offene Zahlung")
return
}
text := fmt.Sprintf("Mahnstufe %d (Zahlung seit %d Tagen offen)", t.DunningLevel, int(now.Sub(t.OverdueSince)/day))
if t.DunningLevel >= 3 {
text += " - Kunde offline"
}
status := statusW
if t.DunningLevel >= 2 {
status = statusCrit
}
line(status, svc, metrics, text)
}
func renderNode(line func(int, string, string, string), n Node, now time.Time, offlineCrit time.Duration, loc *time.Location) {
svc := "Sultan_Node_" + n.ID
leaseHours := 0
if n.LeaseUntil.After(now) {
leaseHours = int(n.LeaseUntil.Sub(now) / time.Hour)
}
metrics := fmt.Sprintf("lease_hours_left=%d", leaseHours)
switch {
case n.Status == "drained":
line(statusOK, svc, metrics, "drained")
case n.MaintenanceActive:
text := "Wartung unbefristet"
if !n.MaintenanceUntil.IsZero() {
text = "Wartung bis " + n.MaintenanceUntil.In(loc).Format("2006-01-02 15:04 MST")
}
if n.MaintenanceReason != "" {
text += " (" + n.MaintenanceReason + ")"
}
line(statusW, svc, metrics, text)
case n.Status == "offline":
since := now.Sub(n.OfflineSince)
status := statusW
if since >= offlineCrit {
status = statusCrit
}
line(status, svc, metrics, "offline seit "+shortDuration(since))
default:
line(statusOK, svc, metrics, "online")
}
}
// shortDuration: unter einer Minute in Sekunden, sonst in Minuten.
func shortDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%d s", int(d/time.Second))
}
return fmt.Sprintf("%d min", int(d/time.Minute))
}
var umlauts = strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "Ä", "Ae", "Ö", "Oe", "Ü", "Ue", "ß", "ss")
// ascii macht Text Checkmk-tauglich: einzeilig, Umlaute umschrieben,
// übrige Nicht-ASCII-Zeichen ersetzt.
func ascii(s string) string {
s = umlauts.Replace(s)
var b strings.Builder
for _, r := range s {
switch {
case r == '\n' || r == '\r' || r == '\t':
b.WriteByte(' ')
case r < 32 || r == 127:
case r > 127:
b.WriteByte('?')
default:
b.WriteRune(r)
}
}
return b.String()
}
+153
View File
@@ -0,0 +1,153 @@
package checkmk
import (
"strings"
"testing"
"time"
)
var now = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
func lines(out string) map[string]string {
m := map[string]string{}
for _, l := range strings.Split(strings.TrimSpace(out), "\n") {
start := strings.Index(l, `"`)
end := strings.Index(l[start+1:], `"`)
m[l[start+1:start+1+end]] = l
}
return m
}
func TestTenants(t *testing.T) {
out := Render(State{Tenants: []Tenant{
{ID: "kunde_schmidt", Commercial: true, Status: "active", ValidUntil: now.AddDate(0, 0, 74)},
{ID: "meier_gmbh", Commercial: true, Status: "active", ValidUntil: now.AddDate(0, 0, 25)},
{ID: "bald", Commercial: true, Status: "active", ValidUntil: now.AddDate(0, 0, 12)},
{ID: "alt_ag", Commercial: true, Status: "expired", ValidUntil: now.AddDate(0, 0, -1)},
{ID: "homelab", Commercial: false, Status: "active", ValidUntil: now.AddDate(0, 0, -100)},
}}, now)
l := lines(out)
want := map[string]string{
"Sultan_Tenant_kunde_schmidt": `0 "Sultan_Tenant_kunde_schmidt" days_left=74;30;14 OK - Abo aktiv bis 2026-12-15 (74 Tage verbleibend)`,
"Sultan_Tenant_meier_gmbh": `1 "Sultan_Tenant_meier_gmbh" days_left=25;30;14 WARN - Abo laeuft in 25 Tagen ab! Verlaengerung erforderlich.`,
"Sultan_Tenant_bald": `2 "Sultan_Tenant_bald" days_left=12;30;14 CRIT - Abo laeuft in 12 Tagen ab! Verlaengerung erforderlich.`,
"Sultan_Tenant_alt_ag": `2 "Sultan_Tenant_alt_ag" days_left=0;30;14 CRIT - Abo seit 1 Tagen abgelaufen! Status: expired`,
}
for svc, w := range want {
if l[svc] != w {
t.Errorf("%s:\n got: %s\nwant: %s", svc, l[svc], w)
}
}
if _, ok := l["Sultan_Tenant_homelab"]; ok {
t.Error("Community-Mandant mit Vertragsfrist (5c: keine)")
}
}
func TestPayment(t *testing.T) {
out := Render(State{Tenants: []Tenant{
{ID: "a", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 0},
{ID: "b", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 1, OverdueSince: now.AddDate(0, 0, -9)},
{ID: "c", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 2, OverdueSince: now.AddDate(0, 0, -26)},
{ID: "d", Commercial: true, Status: "suspended_payment", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 3, OverdueSince: now.AddDate(0, 0, -40)},
{ID: "h", Commercial: false, Status: "active", ValidUntil: now},
}}, now)
l := lines(out)
for svc, prefix := range map[string]string{
"Sultan_Payment_a": `0 "Sultan_Payment_a" dunning_level=0;1;2 OK - keine offene Zahlung`,
"Sultan_Payment_b": `1 "Sultan_Payment_b" dunning_level=1;1;2 WARN - Mahnstufe 1 (Zahlung seit 9 Tagen offen)`,
"Sultan_Payment_c": `2 "Sultan_Payment_c" dunning_level=2;1;2 CRIT - Mahnstufe 2 (Zahlung seit 26 Tagen offen)`,
"Sultan_Payment_d": `2 "Sultan_Payment_d" dunning_level=3;1;2 CRIT - Mahnstufe 3 (Zahlung seit 40 Tagen offen) - Kunde offline`,
} {
if l[svc] != prefix {
t.Errorf("%s:\n got: %s\nwant: %s", svc, l[svc], prefix)
}
}
if _, ok := l["Sultan_Payment_h"]; ok {
t.Error("Community-Mandant mit Mahnwesen")
}
}
func TestNodes(t *testing.T) {
out := Render(State{Nodes: []Node{
{ID: "online", Status: "online", LeaseUntil: now.Add(48 * time.Hour)},
{ID: "wartung", Status: "online", MaintenanceActive: true, MaintenanceUntil: now.Add(90 * time.Minute), MaintenanceReason: "Kernel 6.12", LeaseUntil: now.Add(time.Hour)},
{ID: "kurz-weg", Status: "offline", OfflineSince: now.Add(-5 * time.Minute), LeaseUntil: now.Add(time.Hour)},
{ID: "lange-weg", Status: "offline", OfflineSince: now.Add(-20 * time.Minute)},
{ID: "abgezogen", Status: "drained"},
}}, now)
l := lines(out)
want := map[string]string{
"Sultan_Node_online": `0 "Sultan_Node_online" lease_hours_left=48 OK - online`,
"Sultan_Node_wartung": `1 "Sultan_Node_wartung" lease_hours_left=1 WARN - Wartung bis 2026-10-02 13:30 UTC (Kernel 6.12)`,
"Sultan_Node_kurz-weg": `1 "Sultan_Node_kurz-weg" lease_hours_left=1 WARN - offline seit 5 min`,
"Sultan_Node_lange-weg": `2 "Sultan_Node_lange-weg" lease_hours_left=0 CRIT - offline seit 20 min`,
"Sultan_Node_abgezogen": `0 "Sultan_Node_abgezogen" lease_hours_left=0 OK - drained`,
}
for svc, w := range want {
if l[svc] != w {
t.Errorf("%s:\n got: %s\nwant: %s", svc, l[svc], w)
}
}
}
// Angemeldete Wartung: auch offline nur WARN (geplant), nicht CRIT.
func TestNodeOfflineDuringMaintenance(t *testing.T) {
out := Render(State{Nodes: []Node{{ID: "pi", Status: "offline", OfflineSince: now.Add(-time.Hour), MaintenanceActive: true}}}, now)
if !strings.HasPrefix(strings.TrimSpace(out), `1 "Sultan_Node_pi"`) || !strings.Contains(out, "Wartung unbefristet") {
t.Errorf("out = %s", out)
}
}
func TestViolations(t *testing.T) {
for n, want := range map[int]string{
0: `0 "Sultan_Policy_Violations" violations_24h=0;1 OK - keine Policy-Verstoesse in 24 h`,
3: `1 "Sultan_Policy_Violations" violations_24h=3;1 WARN - 3 Policy-Verstoesse in 24 h (siehe Server-Log)`,
} {
if got := strings.TrimSpace(Render(State{Violations24h: n}, now)); got != want {
t.Errorf("%d:\n got: %s\nwant: %s", n, got, want)
}
}
}
func TestOutputIsSortedAndASCII(t *testing.T) {
out := Render(State{
Tenants: []Tenant{{ID: "b", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0)}, {ID: "a", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0)}},
Nodes: []Node{{ID: "y", Status: "online"}, {ID: "x", Status: "online", MaintenanceActive: true, MaintenanceReason: "Grün & Ölwechsel\nzweite Zeile"}},
}, now)
if strings.Index(out, "Sultan_Tenant_a") > strings.Index(out, "Sultan_Tenant_b") || strings.Index(out, "Sultan_Node_x") > strings.Index(out, "Sultan_Node_y") {
t.Errorf("nicht sortiert:\n%s", out)
}
for _, r := range out {
if r > 127 {
t.Fatalf("Nicht-ASCII-Zeichen %q in:\n%s", r, out)
}
}
if strings.Count(out, "\n") != len(lines(out)) {
t.Errorf("Zeilenumbruch aus dem Grund in der Ausgabe:\n%s", out)
}
if !strings.Contains(out, "Gruen & Oelwechsel zweite Zeile") {
t.Errorf("Umlaute nicht umschrieben:\n%s", out)
}
}
func TestShortOfflineInSeconds(t *testing.T) {
out := Render(State{Nodes: []Node{{ID: "pi", Status: "offline", OfflineSince: now.Add(-45 * time.Second)}}}, now)
if !strings.Contains(out, "offline seit 45 s") {
t.Errorf("out = %s", out)
}
}
// Wartungsende in der Zeitzone des Servers (wie auf der Lander-Seite).
func TestMaintenanceUntilInLocation(t *testing.T) {
berlin, err := time.LoadLocation("Europe/Berlin")
if err != nil {
t.Skip("Zeitzonendaten fehlen")
}
out := Render(State{
Location: berlin,
Nodes: []Node{{ID: "pi", Status: "online", MaintenanceActive: true, MaintenanceUntil: now.Add(90 * time.Minute)}},
}, now)
if !strings.Contains(out, "Wartung bis 2026-10-02 15:30 CEST") {
t.Errorf("out = %s", out)
}
}
+115
View File
@@ -0,0 +1,115 @@
package cli
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"sultan/pkg/agent"
)
func runAgent(ctx context.Context, args []string, stderr io.Writer) int {
if len(args) > 0 && args[0] == "maintenance" {
return runAgentMaintenance(ctx, args[1:], stderr)
}
cfg, err := parseAgentFlags(args, stderr, os.Getenv)
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))
a, err := agent.New(cfg)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
if err := a.Run(ctx); err != nil {
fmt.Fprintln(stderr, "Agent beendet mit Fehler:", err)
return 1
}
return 0
}
// parseAgentFlags liest die Flags von `sultan agent`. SULTAN_TOKEN und
// SULTAN_SERVER dienen als Defaults (curl | sh, Compose; Argumente wären über
// ps für andere Benutzer sichtbar) – Flags gewinnen.
func parseAgentFlags(args []string, output io.Writer, getenv func(string) string) (agent.Config, error) {
fs := flag.NewFlagSet("sultan agent", flag.ContinueOnError)
fs.SetOutput(output)
var cfg agent.Config
fs.StringVar(&cfg.ServerURL, "server", "", "Server-Adresse, z. B. https://ingress.example.com:8443 (Override; sonst aus der Einladung)")
fs.StringVar(&cfg.Token, "token", "", "Einladung von `sultan token invite` (nur beim ersten Start)")
fs.StringVar(&cfg.DataDir, "data-dir", "./suldata", "Datenverzeichnis (node.key, node.json)")
fs.StringVar(&cfg.NodeID, "node-id", "", "Knoten-ID (Default: aus dem Hostnamen)")
fs.StringVar(&cfg.DockerSocket, "docker-socket", "/var/run/docker.sock", `Docker-Socket ("none" = ohne Docker)`)
fs.StringVar(&cfg.BackendHost, "backend-host", "", "Adresse, unter der der Ingress die Dienste erreicht (Default: automatisch)")
fs.StringVar(&cfg.BackendInterface, "backend-interface", "", "alternativ: IP dieses Interfaces verwenden (z. B. wt0 für NetBird)")
fs.StringVar(&cfg.ConfigDir, "config-dir", "/etc/sultan.d", `Dienste als YAML (*.yaml) für Hosts ohne Docker-Labels ("none" = aus)`)
annotateEnv(fs)
if err := fs.Parse(args); err != nil {
return agent.Config{}, err
}
if err := applyEnv(fs, getenv); err != nil {
return agent.Config{}, err
}
if fs.NArg() > 0 {
return agent.Config{}, fmt.Errorf("unerwartetes Argument %q", fs.Arg(0))
}
return cfg, nil
}
// runAgentMaintenance: `sultan agent maintenance on|off [--for] [--reason]
// [--public-reason]` – meldet Wartung dieses Knotens beim Server an oder ab,
// z. B. aus einem Patch- oder Reboot-Skript.
func runAgentMaintenance(ctx context.Context, args []string, stderr io.Writer) int {
if len(args) == 0 || (args[0] != "on" && args[0] != "off") {
fmt.Fprintln(stderr, "Aufruf: sultan agent maintenance on|off [--for 4h|0] [--reason TEXT] [--public-reason TEXT] [--data-dir DIR]")
return 2
}
on := args[0] == "on"
fs := flag.NewFlagSet("sultan agent maintenance", flag.ContinueOnError)
fs.SetOutput(stderr)
dataDir := fs.String("data-dir", "./suldata", "Datenverzeichnis des Agenten")
server := fs.String("server", "", "Server-Adresse (Override)")
forRaw := fs.String("for", "4h", "Dauer (z. B. 30m, 4h, 2d; 0 = unbefristet)")
reason := fs.String("reason", "", "interner Grund (Log, Monitoring)")
publicReason := fs.String("public-reason", "", "öffentlicher Grund – ersetzt den Standardtext der Wartungsseite")
annotateEnv(fs)
if err := fs.Parse(args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if err := applyEnv(fs, os.Getenv); err != nil {
fmt.Fprintln(stderr, err)
return 2
}
d, err := parseMaintenanceFor(*forRaw)
if err != nil {
fmt.Fprintln(stderr, "--for:", err)
return 2
}
a, err := agent.New(agent.Config{DataDir: *dataDir, ServerURL: *server, Logger: slog.New(slog.NewTextHandler(stderr, nil))})
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
if err := a.SetMaintenance(ctx, on, d, *reason, *publicReason); err != nil {
fmt.Fprintln(stderr, "Fehler:", err)
return 1
}
if on {
fmt.Fprintln(stderr, "Wartung angemeldet – Besucher sehen jetzt die Wartungsseite.")
} else {
fmt.Fprintln(stderr, "Wartung beendet.")
}
return 0
}
+97
View File
@@ -0,0 +1,97 @@
package cli
import (
"bytes"
"context"
"path/filepath"
"strings"
"testing"
)
func TestAgentFlags(t *testing.T) {
cfg, err := parseAgentFlags(nil, &bytes.Buffer{}, noEnv)
if err != nil {
t.Fatal(err)
}
if cfg.DataDir != "./suldata" || cfg.DockerSocket != "/var/run/docker.sock" || cfg.ServerURL != "" || cfg.Token != "" ||
cfg.ConfigDir != "/etc/sultan.d" {
t.Errorf("Defaults: %+v", cfg)
}
cfg, err = parseAgentFlags([]string{
"--server=https://ingress:8443", "--token", "sltn_enroll_x.y", "--data-dir=/srv/sul",
"--node-id=pi", "--docker-socket=none", "--backend-host=100.64.0.42", "--backend-interface=wt0",
"--config-dir=/srv/sultan.d",
}, &bytes.Buffer{}, noEnv)
if err != nil {
t.Fatal(err)
}
if cfg.ServerURL != "https://ingress:8443" || cfg.Token != "sltn_enroll_x.y" || cfg.DataDir != "/srv/sul" ||
cfg.NodeID != "pi" || cfg.DockerSocket != "none" || cfg.BackendHost != "100.64.0.42" || cfg.BackendInterface != "wt0" ||
cfg.ConfigDir != "/srv/sultan.d" {
t.Errorf("Overrides: %+v", cfg)
}
if _, err := parseAgentFlags([]string{"--gibts-nicht"}, &bytes.Buffer{}, noEnv); err == nil {
t.Error("unbekanntes Flag akzeptiert")
}
}
func TestRun_AgentNotEnrolled(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
code, _, stderr := run(context.Background(), "agent", "--data-dir="+dir, "--docker-socket=none")
if code != 1 || !strings.Contains(stderr, "--token") {
t.Errorf("code=%d stderr=%q", code, stderr)
}
}
func TestRun_AgentBadConfig(t *testing.T) {
code, _, stderr := run(context.Background(), "agent", "--server=http://ingress:8443", "--token=x")
if code != 2 || !strings.Contains(stderr, "https") {
t.Errorf("http-URL: code=%d stderr=%q", code, stderr)
}
if code, _, _ := run(context.Background(), "agent", "--help"); code != 0 {
t.Errorf("--help: code=%d", code)
}
}
func noEnv(string) string { return "" }
// Token und Server auch per Umgebung (curl | sh, Compose) – Flags gewinnen.
func TestAgentFlags_Environment(t *testing.T) {
env := map[string]string{"SULTAN_TOKEN": "sltn_enroll_env", "SULTAN_SERVER": "https://env:8443"}
getenv := func(k string) string { return env[k] }
cfg, err := parseAgentFlags(nil, &bytes.Buffer{}, getenv)
if err != nil || cfg.Token != "sltn_enroll_env" || cfg.ServerURL != "https://env:8443" {
t.Errorf("aus Umgebung: %+v %v", cfg, err)
}
cfg, _ = parseAgentFlags([]string{"--token=flag", "--server=https://flag:8443"}, &bytes.Buffer{}, getenv)
if cfg.Token != "flag" || cfg.ServerURL != "https://flag:8443" {
t.Errorf("Flags müssen gewinnen: %+v", cfg)
}
}
func TestRun_AgentMaintenanceCommand(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
code, _, stderr := run(context.Background(), "agent", "maintenance", "on", "--data-dir="+dir)
if code != 1 || !strings.Contains(stderr, "registriert") {
t.Errorf("nicht registriert: code=%d stderr=%q", code, stderr)
}
for _, args := range [][]string{
{"agent", "maintenance"},
{"agent", "maintenance", "vielleicht"},
{"agent", "maintenance", "on", "--for=bald"},
} {
if code, _, _ := run(context.Background(), args...); code != 2 {
t.Errorf("%v: code=%d", args, code)
}
}
}
func TestAgentFlags_EnvironmentAll(t *testing.T) {
cfg, err := parseAgentFlags(nil, &bytes.Buffer{}, envMap(map[string]string{
"SULTAN_DATA_DIR": "/srv/agent", "SULTAN_NODE_ID": "pi", "SULTAN_CONFIG_DIR": "none", "SULTAN_BACKEND_INTERFACE": "wt0",
}))
if err != nil || cfg.DataDir != "/srv/agent" || cfg.NodeID != "pi" || cfg.ConfigDir != "none" || cfg.BackendInterface != "wt0" {
t.Errorf("aus Umgebung: %+v %v", cfg, err)
}
}
+38 -11
View File
@@ -1,8 +1,8 @@
// 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 agent Agent: Enrollment, Stream, Docker-Watcher
// sultan token Mandanten, Einladungen, Knoten, Lizenzen verwalten
// sultan version
//
// Run ist ohne Prozessgrenzen testbar; main ruft nur Run auf.
@@ -15,6 +15,8 @@ import (
"fmt"
"io"
"log/slog"
"os"
"strings"
"time"
"sultan/pkg/server"
@@ -27,8 +29,9 @@ const usage = `sultan – verteiltes Ingress-Routing mit ZeroTrust-Service-Disco
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 agent Agent starten (Docker-Host oder Bare-Metal)
sultan token Mandanten, Einladungen, Knoten und Lizenzen verwalten
sultan dump-landing eingebaute Lander-Seiten als Vorlagen ausgeben
sultan version Version ausgeben
sultan help diese Hilfe
@@ -51,9 +54,12 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
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
case "token":
return runToken(ctx, args[1:], stdout, stderr)
case "agent":
return runAgent(ctx, args[1:], stderr)
case "dump-landing":
return runDump(args[1:], stdout, stderr)
default:
fmt.Fprintf(stderr, "unbekannter Befehl %q\n\n%s", args[0], usage)
return 2
@@ -61,7 +67,7 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
}
func runServer(ctx context.Context, args []string, stdout, stderr io.Writer) int {
cfg, err := parseServerFlags(args, stderr)
cfg, err := parseServerFlags(args, stderr, os.Getenv)
if errors.Is(err, flag.ErrHelp) {
return 0
}
@@ -83,8 +89,8 @@ func runServer(ctx context.Context, args []string, stdout, stderr io.Writer) int
}
// parseServerFlags liest die Flags von `sultan server`; Defaults wie in
// server.Config.
func parseServerFlags(args []string, output io.Writer) (server.Config, error) {
// server.Config. Jedes Flag ist auch als SULTAN_<FLAG> setzbar (Flags gewinnen).
func parseServerFlags(args []string, output io.Writer, getenv func(string) string) (server.Config, error) {
fs := flag.NewFlagSet("sultan server", flag.ContinueOnError)
fs.SetOutput(output)
var cfg server.Config
@@ -92,13 +98,34 @@ func parseServerFlags(args []string, output io.Writer) (server.Config, error) {
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.Var((*stringList)(&cfg.PublicURLs), "public-url", "Adresse, unter der Agents den Server erreichen (z. B. https://ingress.example.com:8443); mehrfach angebbar (öffentlich, NetBird …); wird gemerkt, die erste steckt in Einladungen")
fs.StringVar(&cfg.LanderAddr, "lander-addr", "127.0.0.1:8081", `Adresse des eingebauten Landers (Hinweis-/Wartungsseiten); "none" = externer Lander`)
fs.StringVar(&cfg.InterceptorURL, "interceptor-url", "", "URL, unter der Traefik den Lander erreicht (Default: --lander-addr)")
fs.StringVar(&cfg.HosterName, "hoster-name", "", "Name des Hosting-Anbieters (Einladungsseiten)")
fs.StringVar(&cfg.HosterURL, "hoster-url", "", "Angebotsseite des Hosting-Anbieters")
fs.StringVar(&cfg.ProURL, "pro-url", "", "Pro-Angebot, verlinkt auf Community-Wartungsseiten")
fs.DurationVar(&cfg.RefreshInterval, "refresh", time.Minute, "Intervall der Neubewertung aller Knoten")
fs.Var((*stringList)(&cfg.SlackWebhooks), "notify-slack", "Slack-kompatibler Webhook für Benachrichtigungen (mehrfach angebbar)")
fs.Var((*stringList)(&cfg.NtfyURLs), "notify-ntfy", "ntfy-Topic-URL für Benachrichtigungen, z. B. https://ntfy.sh/sultan (mehrfach angebbar)")
fs.StringVar(&cfg.NtfyToken, "notify-ntfy-token", "", "Access-Token für ntfy")
fs.DurationVar(&cfg.OfflineAlertAfter, "offline-alert-after", 15*time.Minute, "Alarm, wenn ein Knoten so lange getrennt ist (nicht bei Wartung)")
fs.IntVar(&cfg.EnrollLimit, "enroll-limit", 10, "Enrollment-Versuche pro Minute und Client-IP (-1 = unbegrenzt)")
fs.DurationVar(&cfg.LeaseDuration, "lease-duration", 0, "feste Lease-Laufzeit für alle Knoten (0 = je Tier: Community 14 Tage, kommerziell 7 Tage)")
annotateEnv(fs)
if err := fs.Parse(args); err != nil {
return server.Config{}, err
}
if err := applyEnv(fs, getenv); err != nil {
return server.Config{}, err
}
if fs.NArg() > 0 {
return server.Config{}, fmt.Errorf("unerwartetes Argument %q", fs.Arg(0))
}
return cfg, nil
}
// stringList ist ein mehrfach angebbares String-Flag.
type stringList []string
func (l *stringList) String() string { return strings.Join(*l, ",") }
func (l *stringList) Set(v string) error { *l = append(*l, v); return nil }
+99 -17
View File
@@ -38,41 +38,42 @@ func TestRun_Version(t *testing.T) {
}
}
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{})
cfg, err := parseServerFlags(nil, &bytes.Buffer{}, noEnv)
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 {
cfg.LanderAddr != "127.0.0.1:8081" || cfg.InterceptorURL != "" || 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{})
"--interceptor-url=http://lander:8081", "--refresh=30s", "--lander-addr=none",
"--hoster-name=Beispiel-Hosting", "--hoster-url=https://hoster.example", "--pro-url=https://hoster.example/pro",
"--public-url=https://ingress.example:8443", "--public-url=https://100.64.0.1:8443",
"--lease-duration=1h",
"--notify-slack=https://hooks.example/a", "--notify-slack=https://hooks.example/b",
"--notify-ntfy=https://ntfy.example/sultan", "--notify-ntfy-token=tk_x", "--offline-alert-after=10m",
}, &bytes.Buffer{}, noEnv)
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 {
cfg.InterceptorURL != "http://lander:8081" || cfg.RefreshInterval != 30*time.Second || cfg.LanderAddr != "none" ||
cfg.HosterName != "Beispiel-Hosting" || cfg.HosterURL != "https://hoster.example" || cfg.ProURL != "https://hoster.example/pro" ||
strings.Join(cfg.PublicURLs, ",") != "https://ingress.example:8443,https://100.64.0.1:8443" || cfg.LeaseDuration != time.Hour ||
strings.Join(cfg.SlackWebhooks, ",") != "https://hooks.example/a,https://hooks.example/b" ||
strings.Join(cfg.NtfyURLs, ",") != "https://ntfy.example/sultan" || cfg.NtfyToken != "tk_x" ||
cfg.OfflineAlertAfter != 10*time.Minute {
t.Errorf("Overrides: %+v", cfg)
}
if _, err := parseServerFlags([]string{"--gibts-nicht"}, &bytes.Buffer{}); err == nil {
if _, err := parseServerFlags([]string{"--gibts-nicht"}, &bytes.Buffer{}, noEnv); err == nil {
t.Error("unbekanntes Flag akzeptiert")
}
if _, err := parseServerFlags([]string{"überzählig"}, &bytes.Buffer{}); err == nil {
if _, err := parseServerFlags([]string{"überzählig"}, &bytes.Buffer{}, noEnv); err == nil {
t.Error("überzähliges Argument akzeptiert")
}
}
@@ -99,7 +100,7 @@ func TestRun_ServerStartsAndStops(t *testing.T) {
}
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")
code, _, stderr := run(ctx, "server", "--data-dir="+dir, "--agent-addr=127.0.0.1:0", "--provider-addr=127.0.0.1:0", "--lander-addr=127.0.0.1:0")
done <- result{code, stderr}
}()
@@ -137,3 +138,84 @@ func TestRun_ServerStartError(t *testing.T) {
t.Errorf("unbrauchbares Datenverzeichnis: code=%d stderr=%q", code, stderr)
}
}
func TestRun_DumpLanding(t *testing.T) {
dir := filepath.Join(t.TempDir(), "landing")
code, stdout, stderr := run(context.Background(), "dump-landing", "--out="+dir)
if code != 0 {
t.Fatalf("code=%d stderr=%s", code, stderr)
}
if !strings.Contains(stdout, "maintenance.html") {
t.Errorf("Ausgabe ohne Dateiliste: %q", stdout)
}
if _, err := os.Stat(filepath.Join(dir, "README.txt")); err != nil {
t.Error("README.txt fehlt")
}
if code, _, stderr := run(context.Background(), "dump-landing", "--out="+dir); code != 1 || !strings.Contains(stderr, "--force") {
t.Errorf("ohne --force: code=%d stderr=%q", code, stderr)
}
if code, _, _ := run(context.Background(), "dump-landing", "--out="+dir, "--force"); code != 0 {
t.Errorf("mit --force: code=%d", code)
}
if code, _, _ := run(context.Background(), "dump-landing", "überzählig"); code != 2 {
t.Errorf("überzähliges Argument: code=%d", code)
}
}
func TestDumpLandingDefaultDir(t *testing.T) {
out, err := parseDumpFlags(nil, &bytes.Buffer{})
if err != nil || out.dir != "./suldata/landing" || out.force {
t.Errorf("Defaults: %+v %v", out, err)
}
}
func envMap(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
// Jedes Flag ist auch als SULTAN_<FLAG> setzbar; Flags gewinnen, Listen
// sind kommagetrennt und werden durch ein Flag ersetzt, nicht ergänzt.
func TestServerFlags_Environment(t *testing.T) {
env := envMap(map[string]string{
"SULTAN_DATA_DIR": "/srv/sul",
"SULTAN_PUBLIC_URL": "https://a.example:8443, https://100.64.0.1:8443",
"SULTAN_NOTIFY_NTFY_TOKEN": "tk_geheim",
"SULTAN_REFRESH": "30s",
"SULTAN_ENROLL_LIMIT": "5",
})
cfg, err := parseServerFlags(nil, &bytes.Buffer{}, env)
if err != nil {
t.Fatal(err)
}
if cfg.DataDir != "/srv/sul" || strings.Join(cfg.PublicURLs, ",") != "https://a.example:8443,https://100.64.0.1:8443" ||
cfg.NtfyToken != "tk_geheim" || cfg.RefreshInterval != 30*time.Second || cfg.EnrollLimit != 5 {
t.Errorf("aus Umgebung: %+v", cfg)
}
cfg, err = parseServerFlags([]string{"--public-url=https://c.example:8443", "--data-dir=/anders"}, &bytes.Buffer{}, env)
if err != nil {
t.Fatal(err)
}
if strings.Join(cfg.PublicURLs, ",") != "https://c.example:8443" || cfg.DataDir != "/anders" {
t.Errorf("Flags müssen gewinnen (Listen ersetzen): %v %q", cfg.PublicURLs, cfg.DataDir)
}
if _, err := parseServerFlags(nil, &bytes.Buffer{}, envMap(map[string]string{"SULTAN_REFRESH": "bald"})); err == nil ||
!strings.Contains(err.Error(), "SULTAN_REFRESH") {
t.Errorf("fehlerhafte Variable nicht benannt: %v", err)
}
var help bytes.Buffer
parseServerFlags([]string{"--help"}, &help, noEnv)
if !strings.Contains(help.String(), "SULTAN_PUBLIC_URL") || !strings.Contains(help.String(), "SULTAN_NOTIFY_NTFY_TOKEN") {
t.Errorf("Hilfe ohne Variablennamen:\n%s", help.String())
}
}
func TestServerFlags_EnrollLimit(t *testing.T) {
cfg, _ := parseServerFlags(nil, &bytes.Buffer{}, noEnv)
if cfg.EnrollLimit != 10 {
t.Errorf("Default %d", cfg.EnrollLimit)
}
cfg, _ = parseServerFlags([]string{"--enroll-limit=-1"}, &bytes.Buffer{}, noEnv) // -1 = unbegrenzt
if cfg.EnrollLimit != -1 {
t.Errorf("--enroll-limit=-1: %d", cfg.EnrollLimit)
}
}
+52
View File
@@ -0,0 +1,52 @@
package cli
import (
"errors"
"flag"
"fmt"
"io"
"sultan/pkg/lander"
)
type dumpOptions struct {
dir string
force bool
}
func parseDumpFlags(args []string, output io.Writer) (dumpOptions, error) {
fs := flag.NewFlagSet("sultan dump-landing", flag.ContinueOnError)
fs.SetOutput(output)
var o dumpOptions
fs.StringVar(&o.dir, "out", "./suldata/landing", "Zielverzeichnis (der Server liest <data-dir>/landing)")
fs.BoolVar(&o.force, "force", false, "vorhandene Dateien überschreiben")
if err := fs.Parse(args); err != nil {
return o, err
}
if fs.NArg() > 0 {
return o, fmt.Errorf("unerwartetes Argument %q", fs.Arg(0))
}
return o, nil
}
// runDump schreibt die eingebauten Lander-Seiten als Vorlagen zum Anpassen.
func runDump(args []string, stdout, stderr io.Writer) int {
o, err := parseDumpFlags(args, stderr)
if errors.Is(err, flag.ErrHelp) {
return 0
}
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
files, err := lander.Dump(o.dir, o.force)
if err != nil {
fmt.Fprintln(stderr, "Fehler:", err)
return 1
}
for _, f := range files {
fmt.Fprintln(stdout, f)
}
fmt.Fprintln(stderr, "Vorlagen geschrieben – Änderungen wirken sofort, ohne Neustart (siehe README.txt).")
return 0
}
+51
View File
@@ -0,0 +1,51 @@
package cli
import (
"flag"
"fmt"
"strings"
)
// envName bildet einen Flag-Namen auf seine Umgebungsvariable ab:
// public-url → SULTAN_PUBLIC_URL.
func envName(flagName string) string {
return "SULTAN_" + strings.ToUpper(strings.ReplaceAll(flagName, "-", "_"))
}
// annotateEnv ergänzt die Hilfe jedes Flags um den Namen der Variable.
func annotateEnv(fs *flag.FlagSet) {
fs.VisitAll(func(f *flag.Flag) { f.Usage += " [" + envName(f.Name) + "]" })
}
// applyEnv setzt nach fs.Parse alle Flags, die nicht auf der Befehlszeile
// standen, aus SULTAN_<FLAG>. Flags gewinnen also; Listen-Flags werden aus
// kommagetrennten Werten gefüllt (und von einem Flag ersetzt, nicht ergänzt).
func applyEnv(fs *flag.FlagSet, getenv func(string) string) error {
explicit := map[string]bool{}
fs.Visit(func(f *flag.Flag) { explicit[f.Name] = true })
var err error
fs.VisitAll(func(f *flag.Flag) {
if err != nil || explicit[f.Name] {
return
}
name := envName(f.Name)
v := strings.TrimSpace(getenv(name))
if v == "" {
return
}
values := []string{v}
if _, isList := f.Value.(*stringList); isList {
values = strings.Split(v, ",")
}
for _, item := range values {
if item = strings.TrimSpace(item); item == "" {
continue
}
if e := f.Value.Set(item); e != nil {
err = fmt.Errorf("%s=%q: %w", name, v, e)
return
}
}
})
return err
}
+639
View File
@@ -0,0 +1,639 @@
package cli
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"text/tabwriter"
"time"
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/license"
"sultan/pkg/policy"
"sultan/pkg/server"
"sultan/pkg/store"
"sultan/pkg/stream"
)
const tokenUsage = `sultan token – Mandanten, Einladungen, Knoten und Lizenzen verwalten.
Arbeitet direkt auf dem Datenverzeichnis des Servers (auch während er läuft).
sultan token tenant add --id ID --name NAME --email MAIL --domains a.de,*.b.de
[--tier community|pro|enterprise] [--max-routes 5] [--valid-until JJJJ-MM-TT]
[--features custom_middleware,websockets,tcp_stream]
sultan token tenant list
sultan token tenant set-status --id ID --status active|suspended_payment|quarantined|expired
sultan token tenant delete --id ID (löscht auch Knoten und Einladungen!)
sultan token tenant payment --id ID --overdue-since JJJJ-MM-TT | --paid
(offene Zahlung → Mahnstufen nach 8/25/32 Tagen, nur kommerziell)
sultan token invite --tenant ID [--ttl 24h|7d] [--server-url https://…]
sultan token node list [--tenant ID]
sultan token node maintenance --id ID [--for 4h|0] [--reason TEXT] [--public-reason TEXT]
(Routen bleiben, Besucher sehen die Wartungsseite)
sultan token node drain --id ID (Routen werden entfernt, bis resume)
sultan token node resume --id ID (beendet Wartung und drain)
sultan token node delete --id ID
sultan token node command --id ID --cmd resync|rotate_key [--reason TEXT]
(wird beim nächsten Refresh bzw. Verbinden zugestellt)
sultan token license issue --tenant ID
sultan token purge (verbrauchte/abgelaufene Einladungen löschen)
Alle Befehle: --data-dir DIR (Default ./suldata)
`
// usageError: Bedienfehler (Exit-Code 2).
type usageError struct{ error }
type tokenEnv struct {
ctx context.Context
stdout, stderr io.Writer
}
type tokenCmd func(env tokenEnv, args []string) error
var tokenCmds = map[string]tokenCmd{
"tenant add": tenantAdd,
"tenant list": tenantList,
"tenant set-status": tenantSetStatus,
"tenant delete": tenantDelete,
"tenant payment": tenantPayment,
"invite": invite,
"node list": nodeList,
"node drain": nodeSetStatus(store.NodeDrained),
"node maintenance": nodeMaintenance,
"node resume": nodeResume,
"node command": nodeCommand,
"node delete": nodeDelete,
"license issue": licenseIssue,
"purge": purge,
}
func runToken(ctx context.Context, args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprint(stderr, tokenUsage)
return 2
}
if args[0] == "help" || args[0] == "-h" || args[0] == "--help" {
fmt.Fprint(stdout, tokenUsage)
return 0
}
name, rest := args[0], args[1:]
if (name == "tenant" || name == "node" || name == "license") && len(rest) > 0 {
name, rest = name+" "+rest[0], rest[1:]
}
cmd, ok := tokenCmds[name]
if !ok {
fmt.Fprintf(stderr, "unbekannter Befehl \"token %s\"\n\n%s", name, tokenUsage)
return 2
}
err := cmd(tokenEnv{ctx: ctx, stdout: stdout, stderr: stderr}, rest)
var ue usageError
switch {
case err == nil:
return 0
case errors.Is(err, flag.ErrHelp):
return 0
case errors.As(err, &ue):
fmt.Fprintln(stderr, err)
return 2
default:
fmt.Fprintln(stderr, "Fehler:", err)
return 1
}
}
// flags legt ein FlagSet mit --data-dir an.
func flags(name string, env tokenEnv) (*flag.FlagSet, *string) {
fs := flag.NewFlagSet("sultan token "+name, flag.ContinueOnError)
fs.SetOutput(env.stderr)
dir := fs.String("data-dir", "./suldata", "Datenverzeichnis des Servers")
return fs, dir
}
func parse(fs *flag.FlagSet, args []string, required ...string) error {
annotateEnv(fs)
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return err
}
return usageError{err}
}
if err := applyEnv(fs, os.Getenv); err != nil {
return usageError{err}
}
if fs.NArg() > 0 {
return usageError{fmt.Errorf("unerwartetes Argument %q", fs.Arg(0))}
}
for _, name := range required {
if fs.Lookup(name).Value.String() == "" {
return usageError{fmt.Errorf("--%s fehlt", name)}
}
}
return nil
}
// openDataDir öffnet die DB eines bereits initialisierten Datenverzeichnisses.
// Ein nicht initialisiertes wird nie angelegt: Ein Tippfehler bei --data-dir
// würde sonst eine neue Server-Identität (Zertifikat, Master-Key) erzeugen.
func openDataDir(env tokenEnv, dir string) (*store.Store, error) {
for _, f := range []string{"sultan.db", "server.crt"} {
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
return nil, fmt.Errorf("Datenverzeichnis %s nicht initialisiert (%s fehlt) – zuerst `sultan server` starten oder --data-dir prüfen", dir, f)
}
}
return store.Open(env.ctx, filepath.Join(dir, "sultan.db"))
}
func tenantAdd(env tokenEnv, args []string) error {
fs, dir := flags("tenant add", env)
id := fs.String("id", "", "Mandanten-ID (a–z, 0–9, - und _)")
name := fs.String("name", "", "Name")
email := fs.String("email", "", "Kontakt-E-Mail")
domains := fs.String("domains", "", "erlaubte Domains, kommagetrennt (z. B. kunde.de,*.kunde.de)")
tier := fs.String("tier", string(store.TierCommunity), "community, pro oder enterprise")
maxRoutes := fs.Int("max-routes", 5, "maximale Anzahl Routen (-1 = unbegrenzt)")
validUntil := fs.String("valid-until", "", "Laufzeitende JJJJ-MM-TT (Default: in einem Jahr)")
featuresRaw := fs.String("features", "", "Features, kommagetrennt (custom_middleware, websockets, tcp_stream)")
if err := parse(fs, args, "id", "name", "email", "domains"); err != nil {
return err
}
until := time.Now().AddDate(1, 0, 0)
if *validUntil != "" {
t, err := parseDate(*validUntil)
if err != nil {
return usageError{err}
}
until = t
}
if !policy.ValidID(*id) || *id == "sultan" {
return fmt.Errorf("Mandanten-ID %q ungültig (a–z, 0–9, - und _, nicht am Rand, kein \"__\", nicht \"sultan\")", *id)
}
var normalized []string
for _, d := range strings.Split(*domains, ",") {
n, err := license.NormalizeDomain(d)
if err != nil {
return fmt.Errorf("Domain %q ungültig (Beispiele: kunde.de, *.kunde.de; keine Wildcard auf TLD)", strings.TrimSpace(d))
}
normalized = append(normalized, n)
}
var features []string
for _, f := range strings.Split(*featuresRaw, ",") {
if f = strings.TrimSpace(f); f == "" {
continue
}
switch license.Feature(f) {
case license.FeatureCustomMiddleware, license.FeatureWebsockets, license.FeatureTCPStream:
features = append(features, f)
default:
return fmt.Errorf("unbekanntes Feature %q (custom_middleware, websockets, tcp_stream)", f)
}
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
t, err := st.CreateTenant(env.ctx, store.Tenant{
ID: *id, Name: *name, Email: *email, Tier: store.Tier(*tier), MaxRoutes: *maxRoutes,
AllowedDomains: normalized, ValidUntil: until, Features: features,
})
if err != nil {
return err
}
fmt.Fprintf(env.stdout, "Mandant %s angelegt (%s, gültig bis %s)\n", t.ID, t.Tier, t.ValidUntil.Format(time.DateOnly))
return nil
}
func parseDate(s string) (time.Time, error) {
if t, err := time.Parse(time.DateOnly, s); err == nil {
return t, nil
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
return time.Time{}, fmt.Errorf("Datum %q nicht lesbar (JJJJ-MM-TT)", s)
}
func tenantList(env tokenEnv, args []string) error {
fs, dir := flags("tenant list", env)
if err := parse(fs, args); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
tenants, err := st.ListTenants(env.ctx)
if err != nil {
return err
}
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tTIER\tSTATUS\tMAX_ROUTES\tVALID_UNTIL\tPAYMENT\tDOMAINS")
for _, t := range tenants {
payment := "-"
if !t.PaymentOverdueSince.IsZero() {
payment = "offen seit " + t.PaymentOverdueSince.UTC().Format(time.DateOnly)
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\n", t.ID, t.Name, t.Tier, t.Status, t.MaxRoutes,
t.ValidUntil.Format(time.DateOnly), payment, strings.Join(t.AllowedDomains, ","))
}
return tw.Flush()
}
func tenantSetStatus(env tokenEnv, args []string) error {
fs, dir := flags("tenant set-status", env)
id := fs.String("id", "", "Mandanten-ID")
status := fs.String("status", "", "active, suspended_payment, quarantined oder expired")
if err := parse(fs, args, "id", "status"); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if err := st.SetTenantStatus(env.ctx, *id, store.TenantStatus(*status)); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Mandant %s: Status %s (wirkt beim nächsten Refresh des Servers)\n", *id, *status)
return nil
}
// tenantPayment setzt bzw. löscht die offene Zahlung (bis zur Admin-API).
func tenantPayment(env tokenEnv, args []string) error {
fs, dir := flags("tenant payment", env)
id := fs.String("id", "", "Mandanten-ID")
since := fs.String("overdue-since", "", "Fälligkeitsdatum der offenen Zahlung (JJJJ-MM-TT)")
paid := fs.Bool("paid", false, "Zahlung eingegangen – Mahnstufe zurücksetzen")
if err := parse(fs, args, "id"); err != nil {
return err
}
if (*since == "") == !*paid {
return usageError{errors.New("genau eines von --overdue-since oder --paid angeben")}
}
var due time.Time
if *since != "" {
t, err := parseDate(*since)
if err != nil {
return usageError{err}
}
due = t
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if err := st.SetTenantPayment(env.ctx, *id, due); err != nil {
return err
}
if *paid {
fmt.Fprintf(env.stdout, "Mandant %s: Zahlung eingegangen – Mahnstufe zurückgesetzt\n", *id)
} else {
fmt.Fprintf(env.stdout, "Mandant %s: Zahlung offen seit %s (Mahnstufen ab 8/25/32 Tagen)\n", *id, due.Format(time.DateOnly))
}
return nil
}
func tenantDelete(env tokenEnv, args []string) error {
fs, dir := flags("tenant delete", env)
id := fs.String("id", "", "Mandanten-ID")
if err := parse(fs, args, "id"); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if err := st.DeleteTenant(env.ctx, *id); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Mandant %s samt Knoten und Einladungen gelöscht\n", *id)
return nil
}
func invite(env tokenEnv, args []string) error {
fs, dir := flags("invite", env)
tenant := fs.String("tenant", "", "Mandanten-ID")
ttlRaw := fs.String("ttl", "24h", "Gültigkeit der Einladung (z. B. 2h, 24h, 7d)")
serverURL := fs.String("server-url", "", "Server-Adresse für diese Einladung (Default: sultan server --public-url)")
if err := parse(fs, args, "tenant"); err != nil {
return err
}
ttl, err := parseTTL(*ttlRaw)
if err != nil {
return usageError{err}
}
if *serverURL != "" {
u, err := crypto.ValidateServerURL(*serverURL)
if err != nil {
return usageError{err}
}
*serverURL = u
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if *serverURL == "" {
urls, err := server.LoadPublicURLs(env.ctx, st)
if err != nil {
return err
}
if len(urls) > 0 {
*serverURL = urls[0] // die übrigen bekommt der Agent per config-Befehl
}
}
cert, err := crypto.LoadOrCreateServerCert(*dir)
if err != nil {
return err
}
tok, err := crypto.NewEnrollmentToken(cert.Fingerprint, *serverURL)
if err != nil {
return err
}
expires := time.Now().Add(ttl)
if err := st.AddEnrollmentToken(env.ctx, tok.Secret, *tenant, expires); err != nil {
return err
}
fmt.Fprintln(env.stdout, tok.String())
fmt.Fprintf(env.stderr, "Einladung für Mandant %s, einmalig gültig bis %s.\n", *tenant, expires.Format(time.DateTime))
if tok.ServerURL != "" {
fmt.Fprintf(env.stderr, "Server-Adresse %s ist enthalten. Auf dem Agent-Host:\n SULTAN_TOKEN=<token> sultan agent\n", tok.ServerURL)
} else {
fmt.Fprint(env.stderr, "Auf dem Agent-Host: sultan agent --server=https://<ingress>:8443 --token=<token>\n"+
"Tipp: Mit `sultan server --public-url=https://…` (oder invite --server-url) steckt die Adresse in der Einladung.\n")
}
return nil
}
// parseTTL versteht Go-Dauern (2h, 90m) und zusätzlich Tage (7d).
func parseTTL(s string) (time.Duration, error) {
var d time.Duration
if days, ok := strings.CutSuffix(s, "d"); ok {
n, err := strconv.Atoi(days)
if err != nil {
return 0, fmt.Errorf("--ttl %q nicht lesbar", s)
}
d = time.Duration(n) * 24 * time.Hour
} else {
var err error
if d, err = time.ParseDuration(s); err != nil {
return 0, fmt.Errorf("--ttl %q nicht lesbar (z. B. 2h, 24h, 7d)", s)
}
}
if d <= 0 {
return 0, fmt.Errorf("--ttl muss positiv sein")
}
return d, nil
}
func nodeList(env tokenEnv, args []string) error {
fs, dir := flags("node list", env)
tenant := fs.String("tenant", "", "nur Knoten dieses Mandanten")
if err := parse(fs, args); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
nodes, err := st.ListNodes(env.ctx, *tenant)
if err != nil {
return err
}
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tTENANT\tHOSTNAME\tSTATUS\tMAINTENANCE\tLEASE\tIP\tLAST_SEEN")
for _, n := range nodes {
lease := "-"
if l, err := st.GetLease(env.ctx, n.ID); err == nil {
lease = "bis " + l.ValidUntil.Local().Format("2006-01-02 15:04")
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", n.ID, n.TenantID, n.Hostname, n.Status,
maintenanceLabel(n.Maintenance), lease, n.IPAddress, n.LastSeen.Local().Format(time.DateTime))
}
return tw.Flush()
}
func nodeSetStatus(status store.NodeStatus) tokenCmd {
return func(env tokenEnv, args []string) error {
fs, dir := flags("node", env)
id := fs.String("id", "", "Knoten-ID")
if err := parse(fs, args, "id"); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if err := st.SetNodeStatus(env.ctx, *id, status); err != nil {
return err
}
notifyAgent(env, st, *id, stream.CmdDrain, "")
fmt.Fprintf(env.stdout, "Knoten %s: Status %s (wirkt beim nächsten Refresh des Servers)\n", *id, status)
return nil
}
}
func maintenanceLabel(m *store.Maintenance) string {
switch {
case m == nil:
return "-"
case !m.Active(time.Now()):
return "abgelaufen"
case m.Until.IsZero():
return "unbefristet"
default:
return "bis " + m.Until.Local().Format("2006-01-02 15:04")
}
}
// parseMaintenanceFor versteht wie --ttl Go-Dauern und Tage; 0 = unbefristet.
func parseMaintenanceFor(s string) (time.Duration, error) {
if s == "0" {
return 0, nil
}
return parseTTL(s)
}
func nodeMaintenance(env tokenEnv, args []string) error {
fs, dir := flags("node maintenance", env)
id := fs.String("id", "", "Knoten-ID")
forRaw := fs.String("for", "4h", "Dauer (z. B. 30m, 4h, 2d; 0 = unbefristet)")
reason := fs.String("reason", "", "interner Grund (Log, Monitoring)")
publicReason := fs.String("public-reason", "", "öffentlicher Grund – ersetzt den Standardtext der Wartungsseite")
if err := parse(fs, args, "id"); err != nil {
return err
}
d, err := parseMaintenanceFor(*forRaw)
if err != nil {
return usageError{fmt.Errorf("--for: %w", err)}
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
m := store.Maintenance{Reason: *reason, PublicReason: *publicReason}
if d > 0 {
m.Until = time.Now().Add(d)
}
if err := st.SetNodeMaintenance(env.ctx, *id, m); err != nil {
return err
}
notifyAgent(env, st, *id, stream.CmdMaintenance, *reason)
fmt.Fprintf(env.stdout, "Knoten %s in Wartung (%s) – wirkt beim nächsten Refresh des Servers (≤ 1 min)\n",
*id, maintenanceLabel(&store.Maintenance{Since: time.Now(), Until: m.Until}))
return nil
}
// nodeResume beendet Wartung und drain.
func nodeResume(env tokenEnv, args []string) error {
fs, dir := flags("node resume", env)
id := fs.String("id", "", "Knoten-ID")
if err := parse(fs, args, "id"); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
n, err := st.GetNode(env.ctx, *id)
if err != nil {
return err
}
if n.Status == store.NodeDrained {
// online setzt erst der Stream.
if err := st.SetNodeStatus(env.ctx, *id, store.NodeOffline); err != nil {
return err
}
}
if n.Maintenance != nil {
if err := st.ClearNodeMaintenance(env.ctx, *id); err != nil {
return err
}
}
notifyAgent(env, st, *id, stream.CmdResume, "")
fmt.Fprintf(env.stdout, "Knoten %s: Wartung/drain beendet – wirkt beim nächsten Refresh des Servers\n", *id)
return nil
}
// notifyAgent reiht einen Hinweis für den Agenten ein (bester Versuch).
func notifyAgent(env tokenEnv, st *store.Store, nodeID, command, reason string) {
if err := st.EnqueueNodeCommand(env.ctx, nodeID, command, reason); err != nil {
fmt.Fprintln(env.stderr, "Hinweis an den Agenten nicht eingereiht:", err)
}
}
// nodeCommand reiht einen Befehl ein. Nur die aktiven Befehle sind erlaubt;
// drain/maintenance/resume erzeugen ihre Hinweise selbst.
func nodeCommand(env tokenEnv, args []string) error {
fs, dir := flags("node command", env)
id := fs.String("id", "", "Knoten-ID")
cmd := fs.String("cmd", "", "resync oder rotate_key")
reason := fs.String("reason", "", "Grund (Log)")
if err := parse(fs, args, "id", "cmd"); err != nil {
return err
}
if *cmd != stream.CmdResync && *cmd != stream.CmdRotateKey {
return usageError{fmt.Errorf("--cmd %q: erlaubt sind resync und rotate_key", *cmd)}
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if err := st.EnqueueNodeCommand(env.ctx, *id, *cmd, *reason); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Befehl %s für %s eingereiht – Zustellung beim nächsten Refresh bzw. Verbinden\n", *cmd, *id)
return nil
}
func nodeDelete(env tokenEnv, args []string) error {
fs, dir := flags("node delete", env)
id := fs.String("id", "", "Knoten-ID")
if err := parse(fs, args, "id"); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if err := st.DeleteNode(env.ctx, *id); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Knoten %s gelöscht (Agent muss neu eingeladen werden)\n", *id)
return nil
}
func licenseIssue(env tokenEnv, args []string) error {
fs, dir := flags("license issue", env)
tenant := fs.String("tenant", "", "Mandanten-ID")
if err := parse(fs, args, "tenant"); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
t, err := st.GetTenant(env.ctx, *tenant)
if err != nil {
return err
}
master, err := auth.ReadPrivateKey(filepath.Join(*dir, "master.key"))
if err != nil {
return err
}
l, err := (&license.Issuer{Key: master}).Issue(license.License{
TenantID: t.ID,
Tier: license.Tier(t.Tier),
MaxRoutes: t.MaxRoutes,
AllowedDomains: t.AllowedDomains,
ValidUntil: t.ValidUntil,
})
if err != nil {
return err
}
out, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
fmt.Fprintln(env.stdout, string(out))
return nil
}
func purge(env tokenEnv, args []string) error {
fs, dir := flags("purge", env)
if err := parse(fs, args); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
n, err := st.PurgeEnrollmentTokens(env.ctx, 0)
if err != nil {
return err
}
fmt.Fprintf(env.stdout, "%d Einladung(en) gelöscht\n", n)
return nil
}
+506
View File
@@ -0,0 +1,506 @@
package cli
import (
"context"
"crypto/ed25519"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/license"
"sultan/pkg/server"
"sultan/pkg/store"
)
// initDataDir initialisiert ein Datenverzeichnis so, wie es der erste
// Serverstart tut.
func initDataDir(t *testing.T) string {
t.Helper()
dir := filepath.Join(t.TempDir(), "suldata")
s, err := server.New(context.Background(), server.Config{DataDir: dir, Logger: slog.New(slog.DiscardHandler)})
if err != nil {
t.Fatal(err)
}
s.Shutdown(context.Background())
return dir
}
func openStore(t *testing.T, dir string) *store.Store {
t.Helper()
st, err := store.Open(context.Background(), filepath.Join(dir, "sultan.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
// tok ruft `sultan token …` mit --data-dir auf.
func tok(t *testing.T, dir string, args ...string) (int, string, string) {
t.Helper()
full := append([]string{"token"}, args...)
// --data-dir hinter den Unterbefehl hängen (z. B. "tenant add --data-dir=…").
full = append(full, "--data-dir="+dir)
return run(context.Background(), full...)
}
func mustTok(t *testing.T, dir string, args ...string) string {
t.Helper()
code, stdout, stderr := tok(t, dir, args...)
if code != 0 {
t.Fatalf("sultan token %s: code=%d stderr=%s", strings.Join(args, " "), code, stderr)
}
return stdout
}
func addTenant(t *testing.T, dir, id string, extra ...string) {
t.Helper()
mustTok(t, dir, append([]string{"tenant", "add", "--id=" + id, "--name=Kunde " + id, "--email=" + id + "@example.com", "--domains=" + id + ".de,*." + id + ".de"}, extra...)...)
}
func TestToken_Usage(t *testing.T) {
code, _, stderr := run(context.Background(), "token")
if code != 2 {
t.Errorf("ohne Unterbefehl: code=%d", code)
}
for _, want := range []string{"tenant add", "invite", "node list", "node maintenance", "node resume", "license issue", "purge"} {
if !strings.Contains(stderr, want) {
t.Errorf("Hilfe ohne %q:\n%s", want, stderr)
}
}
if code, _, _ := run(context.Background(), "token", "zaubern"); code != 2 {
t.Errorf("unbekannter Unterbefehl: code=%d", code)
}
if code, _, _ := run(context.Background(), "token", "tenant"); code != 2 {
t.Errorf("tenant ohne Aktion: code=%d", code)
}
}
// Ein Tippfehler bei --data-dir darf keine neue Server-Identität erzeugen.
func TestToken_RequiresInitializedDataDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "vertippt")
code, _, stderr := tok(t, dir, "tenant", "list")
if code != 1 || !strings.Contains(stderr, "nicht initialisiert") {
t.Errorf("code=%d stderr=%q", code, stderr)
}
if _, err := os.Stat(dir); err == nil {
t.Error("Datenverzeichnis wurde angelegt")
}
}
func TestToken_TenantAddAndList(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde", "--tier=pro", "--max-routes=20", "--valid-until=2027-12-31")
st := openStore(t, dir)
tn, err := st.GetTenant(context.Background(), "kunde")
if err != nil {
t.Fatal(err)
}
if tn.Tier != store.TierPro || tn.MaxRoutes != 20 || tn.Status != store.TenantActive ||
!tn.ValidUntil.Equal(time.Date(2027, 12, 31, 0, 0, 0, 0, time.UTC)) ||
strings.Join(tn.AllowedDomains, ",") != "kunde.de,*.kunde.de" {
t.Errorf("gespeichert: %+v", tn)
}
out := mustTok(t, dir, "tenant", "list")
for _, want := range []string{"ID", "kunde", "pro", "active", "2027-12-31", "*.kunde.de"} {
if !strings.Contains(out, want) {
t.Errorf("Liste ohne %q:\n%s", want, out)
}
}
}
func TestToken_TenantAddDefaults(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "homelab")
tn, _ := openStore(t, dir).GetTenant(context.Background(), "homelab")
if tn.Tier != store.TierCommunity || tn.MaxRoutes != 5 {
t.Errorf("Defaults: tier=%s max_routes=%d", tn.Tier, tn.MaxRoutes)
}
if d := time.Until(tn.ValidUntil); d < 364*24*time.Hour || d > 366*24*time.Hour {
t.Errorf("Default-Laufzeit: %v", d)
}
}
func TestToken_TenantAddDomainsNormalized(t *testing.T) {
dir := initDataDir(t)
mustTok(t, dir, "tenant", "add", "--id=k", "--name=K", "--email=k@x.de", "--domains= Shop.DE. , *.Firma.de")
tn, _ := openStore(t, dir).GetTenant(context.Background(), "k")
if strings.Join(tn.AllowedDomains, ",") != "shop.de,*.firma.de" {
t.Errorf("Domains: %v", tn.AllowedDomains)
}
}
func TestToken_TenantAddErrors(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
tests := []struct {
name string
args []string
code int
}{
{"doppelt", []string{"tenant", "add", "--id=kunde", "--name=K", "--email=k@x.de", "--domains=k.de"}, 1},
{"ID fehlt", []string{"tenant", "add", "--name=K", "--email=k@x.de", "--domains=k.de"}, 2},
{"Domains fehlen", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de"}, 2},
{"Domain ungültig", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de", "--domains=*.de"}, 1},
{"ID ungültig", []string{"tenant", "add", "--id=a__b", "--name=K", "--email=k@x.de", "--domains=k.de"}, 1},
{"Tier ungültig", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de", "--domains=k.de", "--tier=gold"}, 1},
{"Datum ungültig", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de", "--domains=k.de", "--valid-until=morgen"}, 2},
}
for _, tt := range tests {
if code, _, stderr := tok(t, dir, tt.args...); code != tt.code {
t.Errorf("%s: code=%d (erwartet %d) stderr=%q", tt.name, code, tt.code, stderr)
}
}
}
func TestToken_TenantSetStatusAndDelete(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
mustTok(t, dir, "tenant", "set-status", "--id=kunde", "--status=suspended_payment")
tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde")
if tn.Status != store.TenantSuspendedPayment {
t.Errorf("Status = %s", tn.Status)
}
if code, _, _ := tok(t, dir, "tenant", "set-status", "--id=kunde", "--status=pausiert"); code != 1 {
t.Errorf("ungültiger Status: code=%d", code)
}
if code, _, _ := tok(t, dir, "tenant", "set-status", "--id=fehlt", "--status=active"); code != 1 {
t.Errorf("unbekannter Mandant: code=%d", code)
}
mustTok(t, dir, "tenant", "delete", "--id=kunde")
if _, err := openStore(t, dir).GetTenant(context.Background(), "kunde"); err == nil {
t.Error("Mandant nicht gelöscht")
}
if code, _, _ := tok(t, dir, "tenant", "delete", "--id=kunde"); code != 1 {
t.Errorf("zweites Löschen: code=%d", code)
}
}
// Die Einladung muss den Fingerprint des echten Server-Zertifikats tragen
// und beim Server einlösbar sein.
func TestToken_Invite(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
code, stdout, stderr := tok(t, dir, "invite", "--tenant=kunde")
if code != 0 {
t.Fatalf("invite: code=%d stderr=%s", code, stderr)
}
lines := strings.Split(strings.TrimSpace(stdout), "\n")
if len(lines) != 1 {
t.Fatalf("stdout muss genau den Token enthalten: %q", stdout)
}
inv, err := crypto.ParseEnrollmentToken(lines[0])
if err != nil {
t.Fatalf("Token nicht lesbar: %v", err)
}
cert, _ := crypto.LoadOrCreateServerCert(dir)
if inv.Fingerprint != cert.Fingerprint {
t.Error("Token trägt nicht den Fingerprint des Server-Zertifikats")
}
if !strings.Contains(stderr, "kunde") {
t.Errorf("Hinweis auf stderr fehlt: %q", stderr)
}
st := openStore(t, dir)
if _, err := st.Enroll(context.Background(), inv.Secret, store.Node{ID: "n1", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"}); err != nil {
t.Errorf("Token nicht einlösbar: %v", err)
}
}
func TestToken_InviteErrors(t *testing.T) {
dir := initDataDir(t)
if code, _, _ := tok(t, dir, "invite", "--tenant=fehlt"); code != 1 {
t.Errorf("unbekannter Mandant: code=%d", code)
}
if code, _, _ := tok(t, dir, "invite"); code != 2 {
t.Errorf("ohne --tenant: code=%d", code)
}
addTenant(t, dir, "kunde")
for _, ttl := range []string{"--ttl=0s", "--ttl=-1h", "--ttl=bald"} {
if code, _, _ := tok(t, dir, "invite", "--tenant=kunde", ttl); code != 2 {
t.Errorf("%s: code=%d", ttl, code)
}
}
if code, _, _ := tok(t, dir, "invite", "--tenant=kunde", "--ttl=7d"); code != 0 {
t.Errorf("--ttl=7d (Tage) nicht akzeptiert: code=%d", code)
}
}
func TestToken_Nodes(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
st := openStore(t, dir)
if _, err := st.CreateNode(context.Background(), store.Node{ID: "node-1", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "192.168.1.20"}); err != nil {
t.Fatal(err)
}
out := mustTok(t, dir, "node", "list")
for _, want := range []string{"ID", "node-1", "kunde", "pi", "online", "192.168.1.20"} {
if !strings.Contains(out, want) {
t.Errorf("Liste ohne %q:\n%s", want, out)
}
}
if out := mustTok(t, dir, "node", "list", "--tenant=andere"); strings.Contains(out, "node-1") {
t.Errorf("Filter nach Mandant greift nicht:\n%s", out)
}
mustTok(t, dir, "node", "drain", "--id=node-1")
if n, _ := st.GetNode(context.Background(), "node-1"); n.Status != store.NodeDrained {
t.Errorf("drain: %s", n.Status)
}
mustTok(t, dir, "node", "resume", "--id=node-1")
if n, _ := st.GetNode(context.Background(), "node-1"); n.Status != store.NodeOffline {
t.Errorf("resume nach drain: %s (erwartet offline; online setzt erst der Stream)", n.Status)
}
mustTok(t, dir, "node", "delete", "--id=node-1")
if _, err := st.GetNode(context.Background(), "node-1"); err == nil {
t.Error("Knoten nicht gelöscht")
}
if code, _, _ := tok(t, dir, "node", "drain", "--id=fehlt"); code != 1 {
t.Errorf("unbekannter Knoten: code=%d", code)
}
}
func TestToken_LicenseIssue(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde", "--tier=enterprise", "--max-routes=-1", "--valid-until=2027-06-30")
stdout := mustTok(t, dir, "license", "issue", "--tenant=kunde")
master, err := auth.ReadPrivateKey(filepath.Join(dir, "master.key"))
if err != nil {
t.Fatal(err)
}
l, err := license.Parse([]byte(stdout), master.Public().(ed25519.PublicKey))
if err != nil {
t.Fatalf("Lizenz ungültig: %v\n%s", err, stdout)
}
if l.TenantID != "kunde" || l.Tier != license.TierEnterprise || l.MaxRoutes != -1 || !l.AllowsDomain("www.kunde.de") {
t.Errorf("Lizenz: %+v", l)
}
if !json.Valid([]byte(stdout)) {
t.Error("stdout ist kein reines JSON")
}
if code, _, _ := tok(t, dir, "license", "issue", "--tenant=fehlt"); code != 1 {
t.Errorf("unbekannter Mandant: code=%d", code)
}
}
func TestToken_Purge(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
inv := strings.TrimSpace(mustTok(t, dir, "invite", "--tenant=kunde"))
parsed, _ := crypto.ParseEnrollmentToken(inv)
st := openStore(t, dir)
st.Enroll(context.Background(), parsed.Secret, store.Node{ID: "n1", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"})
out := mustTok(t, dir, "purge")
if !strings.Contains(out, "1") {
t.Errorf("purge-Ausgabe: %q", out)
}
}
// Funktioniert auch, während der Server läuft (gleiche SQLite-DB, WAL).
func TestToken_WhileServerRuns(t *testing.T) {
dir := initDataDir(t)
s, err := server.New(context.Background(), server.Config{
DataDir: dir, AgentAddr: "127.0.0.1:0", ProviderAddr: "127.0.0.1:0", LanderAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatal(err)
}
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Shutdown(context.Background())
addTenant(t, dir, "kunde")
inv := strings.TrimSpace(mustTok(t, dir, "invite", "--tenant=kunde"))
parsed, err := crypto.ParseEnrollmentToken(inv)
if err != nil || parsed.Fingerprint != s.Fingerprint() {
t.Errorf("Token passt nicht zum laufenden Server: %v", err)
}
}
func setPublicURL(t *testing.T, dir, url string) {
t.Helper()
s, err := server.New(context.Background(), server.Config{DataDir: dir, PublicURLs: []string{url, "https://100.64.0.1:8443"}, Logger: slog.New(slog.DiscardHandler)})
if err != nil {
t.Fatal(err)
}
s.Shutdown(context.Background())
}
func TestToken_InviteEmbedsPublicURL(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
// Ohne öffentliche Adresse: Token ohne URL, Hinweis auf --public-url.
code, stdout, stderr := tok(t, dir, "invite", "--tenant=kunde")
inv, _ := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout))
if code != 0 || inv.ServerURL != "" || !strings.Contains(stderr, "--public-url") {
t.Errorf("ohne Adresse: code=%d url=%q stderr=%q", code, inv.ServerURL, stderr)
}
setPublicURL(t, dir, "https://ingress.example:8443")
code, stdout, stderr = tok(t, dir, "invite", "--tenant=kunde")
inv, err := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout))
if code != 0 || err != nil || inv.ServerURL != "https://ingress.example:8443" {
t.Errorf("mit gemerkter Adresse: code=%d %+v %v", code, inv, err)
}
if !strings.Contains(stderr, "SULTAN_TOKEN=") {
t.Errorf("Hinweis ohne fertige Befehlszeile: %q", stderr)
}
// Pro Einladung überschreibbar, z. B. NetBird-Adresse für diesen Knoten.
_, stdout, _ = tok(t, dir, "invite", "--tenant=kunde", "--server-url=https://100.64.0.1:8443")
if inv, _ := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout)); inv.ServerURL != "https://100.64.0.1:8443" {
t.Errorf("--server-url: %+v", inv)
}
if code, _, _ := tok(t, dir, "invite", "--tenant=kunde", "--server-url=http://x:8443"); code != 2 {
t.Errorf("ungültige --server-url: code=%d", code)
}
}
func TestToken_NodeMaintenance(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
st := openStore(t, dir)
st.CreateNode(context.Background(), store.Node{ID: "pi", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "1.2.3.4"})
out := mustTok(t, dir, "node", "maintenance", "--id=pi", "--for=2h", "--reason=Kernel-Update", "--public-reason=Sicherheitsupdate")
if !strings.Contains(out, "pi") {
t.Errorf("Ausgabe: %q", out)
}
n, _ := st.GetNode(context.Background(), "pi")
if m := n.Maintenance; m == nil || m.Reason != "Kernel-Update" || m.PublicReason != "Sicherheitsupdate" ||
time.Until(m.Until) < 119*time.Minute || time.Until(m.Until) > 121*time.Minute {
t.Errorf("gespeichert: %+v", n.Maintenance)
}
if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "MAINTENANCE") || !strings.Contains(list, "bis ") {
t.Errorf("Liste ohne Wartung:\n%s", list)
}
mustTok(t, dir, "node", "maintenance", "--id=pi") // Default 4h
if n, _ := st.GetNode(context.Background(), "pi"); time.Until(n.Maintenance.Until) < 239*time.Minute {
t.Errorf("Default-Dauer: %v", time.Until(n.Maintenance.Until))
}
mustTok(t, dir, "node", "maintenance", "--id=pi", "--for=0")
if n, _ := st.GetNode(context.Background(), "pi"); !n.Maintenance.Until.IsZero() {
t.Error("--for=0 nicht unbefristet")
}
if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "unbefristet") {
t.Errorf("Liste:\n%s", list)
}
mustTok(t, dir, "node", "resume", "--id=pi")
if n, _ := st.GetNode(context.Background(), "pi"); n.Maintenance != nil {
t.Error("resume beendet die Wartung nicht")
}
for _, args := range [][]string{
{"node", "maintenance", "--id=pi", "--for=bald"},
{"node", "maintenance", "--id=pi", "--for=-1h"},
{"node", "maintenance"},
} {
if code, _, _ := tok(t, dir, args...); code != 2 {
t.Errorf("%v: code=%d", args, code)
}
}
if code, _, _ := tok(t, dir, "node", "maintenance", "--id=fehlt"); code != 1 {
t.Errorf("unbekannter Knoten: code=%d", code)
}
}
func TestToken_NodeCommand(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
st := openStore(t, dir)
st.CreateNode(context.Background(), store.Node{ID: "pi", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "1.2.3.4"})
mustTok(t, dir, "node", "command", "--id=pi", "--cmd=rotate_key", "--reason=Routine")
mustTok(t, dir, "node", "drain", "--id=pi") // erzeugt zusätzlich einen Hinweis an den Agent
cmds, _ := st.PendingNodeCommands(context.Background(), "pi")
if len(cmds) != 2 || cmds[0].Command != "rotate_key" || cmds[0].Reason != "Routine" || cmds[1].Command != "drain" {
t.Errorf("Warteschlange: %+v", cmds)
}
if code, _, _ := tok(t, dir, "node", "command", "--id=pi", "--cmd=rm -rf /"); code != 2 {
t.Errorf("beliebiger Befehl: code=%d", code)
}
if code, _, _ := tok(t, dir, "node", "command", "--id=fehlt", "--cmd=resync"); code != 1 {
t.Errorf("unbekannter Knoten: code=%d", code)
}
}
func TestToken_NodeListShowsLease(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
st := openStore(t, dir)
st.CreateNode(context.Background(), store.Node{ID: "pi", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "1.2.3.4"})
if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "LEASE") {
t.Errorf("Spalte LEASE fehlt:\n%s", list)
}
st.PutLease(context.Background(), store.Lease{NodeID: "pi", IssuedAt: time.Now(), RenewAfter: time.Now().Add(time.Hour),
ValidUntil: time.Date(2030, 1, 2, 3, 4, 0, 0, time.Local), Document: "{}"})
if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "2030-01-02 03:04") {
t.Errorf("Lease-Ende fehlt:\n%s", list)
}
}
func TestToken_TenantAddFeatures(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde", "--features=custom_middleware,websockets")
tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde")
if strings.Join(tn.Features, ",") != "custom_middleware,websockets" {
t.Errorf("Features: %v", tn.Features)
}
if code, _, _ := tok(t, dir, "tenant", "add", "--id=x", "--name=x", "--email=x@x.de", "--domains=x.de", "--features=teleport"); code != 1 {
t.Errorf("unbekanntes Feature: code=%d", code)
}
}
func TestToken_TenantPayment(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde", "--tier=pro")
st := openStore(t, dir)
mustTok(t, dir, "tenant", "payment", "--id=kunde", "--overdue-since=2026-09-01")
if tn, _ := st.GetTenant(context.Background(), "kunde"); !tn.PaymentOverdueSince.Equal(time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) {
t.Errorf("offen seit: %v", tn.PaymentOverdueSince)
}
if list := mustTok(t, dir, "tenant", "list"); !strings.Contains(list, "PAYMENT") || !strings.Contains(list, "offen seit 2026-09-01") {
t.Errorf("Liste ohne Zahlungsstatus:\n%s", list)
}
mustTok(t, dir, "tenant", "payment", "--id=kunde", "--paid")
if tn, _ := st.GetTenant(context.Background(), "kunde"); !tn.PaymentOverdueSince.IsZero() {
t.Error("--paid setzt nicht zurück")
}
for _, args := range [][]string{
{"tenant", "payment", "--id=kunde"},
{"tenant", "payment", "--id=kunde", "--paid", "--overdue-since=2026-09-01"},
{"tenant", "payment", "--id=kunde", "--overdue-since=gestern"},
} {
if code, _, _ := tok(t, dir, args...); code != 2 {
t.Errorf("%v: code=%d", args, code)
}
}
if code, _, _ := tok(t, dir, "tenant", "payment", "--id=fehlt", "--paid"); code != 1 {
t.Errorf("unbekannter Mandant: code=%d", code)
}
}
func TestToken_DataDirFromEnvironment(t *testing.T) {
dir := initDataDir(t)
t.Setenv("SULTAN_DATA_DIR", dir)
if code, _, stderr := run(context.Background(), "token", "tenant", "list"); code != 0 {
t.Errorf("code=%d stderr=%s", code, stderr)
}
}
+6 -6
View File
@@ -3,12 +3,12 @@
//
// - Server: selbstsigniertes ECDSA-P-256-Zertifikat, einmalig erzeugt und
// dauerhaft im Datenverzeichnis abgelegt; nur TLS 1.3.
// - Agent: Zertifikats-Pinning über den SHA-256-Fingerprint
// (known_servers). Einen blinden Erstkontakt gibt es nicht: Beim ersten
// Verbinden muss der Fingerprint aus dem Enrollment-Token passen.
// - Enrollment-Token: sltn_enroll_<secret>.<fingerprint>. Der Server
// speichert nur das Secret (gehasht), der Agent nutzt den Fingerprint
// für den Erstkontakt.
// - Agent: Zertifikats-Pinning über den SHA-256-Fingerprint, unabhängig
// von der Adresse. Einen blinden Erstkontakt gibt es nicht: Der
// Fingerprint kommt aus der Einladung, danach aus node.json.
// - Enrollment-Token: sltn_enroll_<secret>.<fingerprint>[.<server-url>].
// Der Server speichert nur das Secret (gehasht), der Agent nutzt
// Fingerprint und Adresse für den Erstkontakt.
package crypto
import (
+20 -117
View File
@@ -1,142 +1,45 @@
package crypto
import (
"bufio"
"bytes"
"crypto/tls"
"errors"
"fmt"
"os"
"strings"
"sync"
)
var (
// ErrUnknownServer: Kein Pin und kein erwarteter Fingerprint – ein
// ungeprüfter Erstkontakt wird verweigert.
ErrUnknownServer = errors.New("crypto: unbekannter Server – Fingerprint aus dem Enrollment-Token erforderlich")
// ErrFingerprintMismatch: Das Server-Zertifikat passt nicht zum Pin bzw.
// zum erwarteten Fingerprint (möglicher Man-in-the-Middle).
// ErrUnknownServer: Kein Fingerprint bekannt – ein ungeprüfter Kontakt
// wird verweigert (kein blindes TOFU).
ErrUnknownServer = errors.New("crypto: unbekannter Server – Fingerprint aus der Einladung erforderlich")
// ErrFingerprintMismatch: Das Server-Zertifikat passt nicht zum Pin
// (möglicher Man-in-the-Middle).
ErrFingerprintMismatch = errors.New("crypto: Server-Fingerprint stimmt nicht – möglicher Man-in-the-Middle")
// ErrCorruptKnownServers: Die known_servers-Datei ist nicht lesbar.
ErrCorruptKnownServers = errors.New("crypto: known_servers beschädigt")
)
// KnownServers ist die Pin-Datei des Agenten (analog zu SSH known_hosts).
// Format: eine Zeile "<server> <fingerprint>", # für Kommentare. Einträge
// werden nur angehängt, nie stillschweigend geändert.
type KnownServers struct {
Path string
mu sync.Mutex
}
// Lookup liefert den Pin für server. Eine fehlende Datei bedeutet: kein Pin.
func (k *KnownServers) Lookup(server string) (Fingerprint, bool, error) {
k.mu.Lock()
defer k.mu.Unlock()
return k.lookup(server)
}
func (k *KnownServers) lookup(server string) (Fingerprint, bool, error) {
data, err := os.ReadFile(k.Path)
if errors.Is(err, os.ErrNotExist) {
return "", false, nil
}
if err != nil {
return "", false, err
}
sc := bufio.NewScanner(bytes.NewReader(data))
for line := 1; sc.Scan(); line++ {
text := strings.TrimSpace(sc.Text())
if text == "" || strings.HasPrefix(text, "#") {
continue
}
fields := strings.Fields(text)
if len(fields) != 2 {
return "", false, fmt.Errorf("%w: %s Zeile %d", ErrCorruptKnownServers, k.Path, line)
}
fp, err := ParseFingerprint(fields[1])
if err != nil {
return "", false, fmt.Errorf("%w: %s Zeile %d: %w", ErrCorruptKnownServers, k.Path, line, err)
}
if fields[0] == server {
return fp, true, nil
}
}
return "", false, sc.Err()
}
// Add pinnt fp für server. Ist bereits ein anderer Fingerprint gepinnt,
// liefert Add ErrFingerprintMismatch und ändert nichts.
func (k *KnownServers) Add(server string, fp Fingerprint) error {
if len(strings.Fields(server)) != 1 || strings.TrimSpace(server) != server {
return fmt.Errorf("crypto: ungültiger Servername %q", server)
}
if _, err := ParseFingerprint(string(fp)); err != nil {
return err
}
k.mu.Lock()
defer k.mu.Unlock()
pinned, ok, err := k.lookup(server)
if err != nil {
return err
}
if ok {
if pinned != fp {
return fmt.Errorf("%w: %s ist auf %s gepinnt, nicht %s", ErrFingerprintMismatch, server, pinned, fp)
}
return nil
}
f, err := os.OpenFile(k.Path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
if err != nil {
return err
}
_, err = fmt.Fprintf(f, "%s %s\n", server, fp)
if cerr := f.Close(); err == nil {
err = cerr
}
return err
}
// ClientTLSConfig liefert die TLS-Konfiguration des Agenten für server
// (host:port aus der --server-URL). Statt einer CA prüft sie den
// Fingerprint:
// PinnedTLSConfig liefert die TLS-Konfiguration des Agenten. Statt einer CA
// wird der Fingerprint des Server-Zertifikats geprüft – unabhängig von der
// Adresse, über die der Server erreicht wird (DNS, interne IP, NetBird):
// Derselbe Server hat überall denselben Fingerprint.
//
// - Pin vorhanden: Das Zertifikat muss dazu passen (expected wird ignoriert).
// - Kein Pin, expected gesetzt (Fingerprint aus dem Enrollment-Token):
// Das Zertifikat muss dazu passen und wird dann gepinnt.
// - Kein Pin, kein expected: Verbindung wird verweigert (ErrUnknownServer).
//
// Die Prüfung läuft im TLS-Handshake, also bevor irgendein Request-Byte
// (Token, Signatur, Body) gesendet wird. Nur TLS 1.3.
func ClientTLSConfig(server string, ks *KnownServers, expected Fingerprint) *tls.Config {
// Beim Erstkontakt kommt want aus der Einladung, danach aus node.json. Die
// Prüfung läuft im TLS-Handshake, also bevor irgendein Request-Byte (Token,
// Signatur, Body) gesendet wird. Nur TLS 1.3.
func PinnedTLSConfig(want Fingerprint) *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS13,
// Die CA-Prüfung entfällt bewusst; VerifyConnection ersetzt sie
// durch die strengere Fingerprint-Prüfung.
// Die CA- und Hostnamen-Prüfung entfällt bewusst; VerifyConnection
// ersetzt sie durch die strengere Fingerprint-Prüfung.
InsecureSkipVerify: true,
VerifyConnection: func(cs tls.ConnectionState) error {
if want == "" {
return ErrUnknownServer
}
if len(cs.PeerCertificates) == 0 {
return fmt.Errorf("%w: kein Zertifikat", ErrFingerprintMismatch)
}
actual := FingerprintOf(cs.PeerCertificates[0])
pinned, ok, err := ks.Lookup(server)
if err != nil {
return err
}
if ok {
if actual != pinned {
return fmt.Errorf("%w: %s präsentiert %s, gepinnt ist %s", ErrFingerprintMismatch, server, actual, pinned)
if got := FingerprintOf(cs.PeerCertificates[0]); got != want {
return fmt.Errorf("%w: Server präsentiert %s, erwartet %s", ErrFingerprintMismatch, got, want)
}
return nil
}
if expected == "" {
return fmt.Errorf("%w (%s präsentiert %s)", ErrUnknownServer, server, actual)
}
if actual != expected {
return fmt.Errorf("%w: %s präsentiert %s, Token erwartet %s", ErrFingerprintMismatch, server, actual, expected)
}
return ks.Add(server, actual)
},
}
}
+32 -133
View File
@@ -7,78 +7,10 @@ import (
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
)
func newKnownServers(t *testing.T) *KnownServers {
t.Helper()
return &KnownServers{Path: filepath.Join(t.TempDir(), "known_servers")}
}
func TestKnownServers(t *testing.T) {
ks := newKnownServers(t)
sc, _ := LoadOrCreateServerCert(t.TempDir())
other, _ := LoadOrCreateServerCert(t.TempDir())
if _, ok, err := ks.Lookup("ingress:8443"); ok || err != nil {
t.Fatalf("leere Datei: ok=%v err=%v", ok, err)
}
if err := ks.Add("ingress:8443", sc.Fingerprint); err != nil {
t.Fatalf("Add: %v", err)
}
if fp, ok, err := ks.Lookup("ingress:8443"); !ok || err != nil || fp != sc.Fingerprint {
t.Errorf("Lookup = %q, %v, %v", fp, ok, err)
}
if err := ks.Add("ingress:8443", sc.Fingerprint); err != nil {
t.Errorf("gleichen Eintrag erneut hinzufügen: %v", err)
}
if err := ks.Add("ingress:8443", other.Fingerprint); !errors.Is(err, ErrFingerprintMismatch) {
t.Errorf("Pin überschreiben: erwartet ErrFingerprintMismatch, bekommen %v", err)
}
if err := ks.Add("anderer:8443", other.Fingerprint); err != nil {
t.Errorf("zweiter Server: %v", err)
}
data, _ := os.ReadFile(ks.Path)
if n := strings.Count(string(data), "ingress:8443"); n != 1 {
t.Errorf("ingress:8443 steht %d-mal in der Datei", n)
}
info, _ := os.Stat(ks.Path)
if info.Mode().Perm() != 0o600 {
t.Errorf("Dateirechte %o, erwartet 600", info.Mode().Perm())
}
}
func TestKnownServers_FileFormat(t *testing.T) {
ks := newKnownServers(t)
sc, _ := LoadOrCreateServerCert(t.TempDir())
content := "# Kommentar\n\n ingress:8443 " + string(sc.Fingerprint) + " \n"
os.WriteFile(ks.Path, []byte(content), 0o600)
if fp, ok, err := ks.Lookup("ingress:8443"); !ok || err != nil || fp != sc.Fingerprint {
t.Errorf("Lookup = %q, %v, %v", fp, ok, err)
}
for name, bad := range map[string]string{
"ein Feld": "ingress:8443\n",
"drei Felder": "ingress:8443 " + string(sc.Fingerprint) + " x\n",
"kaputter Fingerprint": "ingress:8443 abc\n",
} {
t.Run(name, func(t *testing.T) {
os.WriteFile(ks.Path, []byte(bad), 0o600)
if _, _, err := ks.Lookup("ingress:8443"); !errors.Is(err, ErrCorruptKnownServers) {
t.Errorf("erwartet ErrCorruptKnownServers, bekommen %v", err)
}
})
}
if err := ks.Add("mit leerzeichen", sc.Fingerprint); err == nil {
t.Error("Servername mit Leerzeichen akzeptiert")
}
}
// tlsServer startet einen Test-Server mit dem Zertifikat aus einem eigenen
// Datenverzeichnis und zählt, ob ein Request ihn erreicht.
func tlsServer(t *testing.T) (*httptest.Server, ServerCert, *atomic.Int32) {
@@ -98,96 +30,63 @@ func tlsServer(t *testing.T) (*httptest.Server, ServerCert, *atomic.Int32) {
return srv, sc, &hits
}
func get(t *testing.T, srv *httptest.Server, cfg *tls.Config) error {
func get(t *testing.T, url string, cfg *tls.Config) error {
t.Helper()
c := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}}
resp, err := c.Get(srv.URL)
resp, err := c.Get(url)
if err == nil {
resp.Body.Close()
}
return err
}
func TestClientTLS_FirstContactWithExpectedFingerprint(t *testing.T) {
func TestPinnedTLS_Matches(t *testing.T) {
srv, sc, hits := tlsServer(t)
ks := newKnownServers(t)
addr := srv.Listener.Addr().String()
if err := get(t, srv, ClientTLSConfig(addr, ks, sc.Fingerprint)); err != nil {
t.Fatalf("erster Kontakt mit korrektem Fingerprint: %v", err)
if err := get(t, srv.URL, PinnedTLSConfig(sc.Fingerprint)); err != nil {
t.Fatalf("passender Fingerprint: %v", err)
}
if hits.Load() != 1 {
t.Error("Request kam nicht an")
}
if fp, ok, _ := ks.Lookup(addr); !ok || fp != sc.Fingerprint {
t.Errorf("Pin nicht gespeichert: %q %v", fp, ok)
}
// Folgekontakt ohne Token-Fingerprint: der Pin genügt.
if err := get(t, srv, ClientTLSConfig(addr, ks, "")); err != nil {
t.Errorf("Folgekontakt mit Pin: %v", err)
// Der Pin hängt am Server, nicht an der Adresse: derselbe Server unter einer
// anderen Adresse (hier localhost statt 127.0.0.1) ist weiterhin vertrauenswürdig.
func TestPinnedTLS_AddressIndependent(t *testing.T) {
srv, sc, _ := tlsServer(t)
port := srv.Listener.Addr().(interface{ String() string }).String()
port = port[len("127.0.0.1"):]
if err := get(t, "https://localhost"+port, PinnedTLSConfig(sc.Fingerprint)); err != nil {
t.Errorf("andere Adresse, gleicher Server: %v", err)
}
}
// Kein blindes TOFU: Ohne Pin und ohne erwarteten Fingerprint wird nicht verbunden.
func TestClientTLS_NoBlindFirstContact(t *testing.T) {
srv, _, hits := tlsServer(t)
ks := newKnownServers(t)
addr := srv.Listener.Addr().String()
if err := get(t, srv, ClientTLSConfig(addr, ks, "")); !errors.Is(err, ErrUnknownServer) {
t.Errorf("erwartet ErrUnknownServer, bekommen %v", err)
}
if hits.Load() != 0 {
t.Error("Request hat den Server erreicht")
}
if _, ok, _ := ks.Lookup(addr); ok {
t.Error("unbekannter Server wurde gepinnt")
}
}
// Angreifer beim Erstkontakt: Er präsentiert sein eigenes Zertifikat, der
// Token enthält aber den Fingerprint des echten Servers.
func TestClientTLS_MITMOnFirstContact(t *testing.T) {
// Angreifer mit eigenem Zertifikat: Abbruch im Handshake, kein Request
// (also auch kein Token, keine Signatur) erreicht ihn.
func TestPinnedTLS_Mismatch(t *testing.T) {
mitm, _, hits := tlsServer(t)
real, _ := LoadOrCreateServerCert(t.TempDir())
ks := newKnownServers(t)
addr := mitm.Listener.Addr().String()
if err := get(t, mitm, ClientTLSConfig(addr, ks, real.Fingerprint)); !errors.Is(err, ErrFingerprintMismatch) {
if err := get(t, mitm.URL, PinnedTLSConfig(real.Fingerprint)); !errors.Is(err, ErrFingerprintMismatch) {
t.Errorf("erwartet ErrFingerprintMismatch, bekommen %v", err)
}
if hits.Load() != 0 {
t.Error("Request (inkl. Token) hat den Angreifer erreicht")
}
if _, ok, _ := ks.Lookup(addr); ok {
t.Error("Angreifer wurde gepinnt")
}
}
// Angreifer nach dem Erstkontakt: Der Pin gewinnt, auch wenn ein (neuer)
// Token-Fingerprint auf den Angreifer passen würde.
func TestClientTLS_PinWinsLater(t *testing.T) {
mitm, mitmCert, hits := tlsServer(t)
real, _ := LoadOrCreateServerCert(t.TempDir())
ks := newKnownServers(t)
addr := mitm.Listener.Addr().String()
if err := ks.Add(addr, real.Fingerprint); err != nil {
t.Fatal(err)
}
for _, expected := range []Fingerprint{"", mitmCert.Fingerprint} {
if err := get(t, mitm, ClientTLSConfig(addr, ks, expected)); !errors.Is(err, ErrFingerprintMismatch) {
t.Errorf("expected=%q: erwartet ErrFingerprintMismatch, bekommen %v", expected, err)
}
}
if hits.Load() != 0 {
t.Error("Request hat den Angreifer erreicht")
}
}
func TestClientTLSConfig_OnlyTLS13(t *testing.T) {
cfg := ClientTLSConfig("x:1", newKnownServers(t), "")
if cfg.MinVersion != tls.VersionTLS13 {
t.Errorf("MinVersion = %x, erwartet TLS 1.3", cfg.MinVersion)
// Kein blindes Vertrauen: ohne erwarteten Fingerprint keine Verbindung.
func TestPinnedTLS_NoFingerprint(t *testing.T) {
srv, _, hits := tlsServer(t)
if err := get(t, srv.URL, PinnedTLSConfig("")); !errors.Is(err, ErrUnknownServer) {
t.Errorf("erwartet ErrUnknownServer, bekommen %v", err)
}
if hits.Load() != 0 {
t.Error("Request erreichte den Server")
}
}
func TestPinnedTLSConfig_OnlyTLS13(t *testing.T) {
if cfg := PinnedTLSConfig("x"); cfg.MinVersion != tls.VersionTLS13 {
t.Errorf("MinVersion = %x", cfg.MinVersion)
}
}
+88 -11
View File
@@ -5,49 +5,80 @@ import (
"encoding/base64"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
)
// TokenPrefix kennzeichnet Enrollment-Tokens.
const TokenPrefix = "sltn_enroll_"
var (
// ErrInvalidToken: Der Token-String hat nicht das Format
// sltn_enroll_<secret>.<fingerprint>.
var ErrInvalidToken = errors.New("crypto: ungültiger Enrollment-Token")
// sltn_enroll_<secret>.<fingerprint>[.<server-url>].
ErrInvalidToken = errors.New("crypto: ungültiger Enrollment-Token")
// ErrInvalidServerURL: keine reine https://host[:port]-Adresse.
ErrInvalidServerURL = errors.New("crypto: ungültige Server-URL")
)
const maxServerURLLen = 255
// EnrollmentToken ist die Einladung für einen neuen Agenten.
//
// Secret (inkl. Präfix) ist der geheime Teil: Der Server speichert davon nur
// den Hash (store.AddEnrollmentToken), der Agent schickt es beim Enrollment.
// Fingerprint ist nicht geheim; er sagt dem Agenten, welches
// Server-Zertifikat er beim Erstkontakt erwarten muss.
// Server-Zertifikat er erwarten muss. ServerURL (optional, nicht geheim) ist
// die Adresse, unter der der Agent den Server erreicht – eine falsche
// Adresse führt dank Fingerprint-Prüfung höchstens zu einem
// Verbindungsfehler, nie zu einem falschen Server.
type EnrollmentToken struct {
Secret string
Fingerprint Fingerprint
ServerURL string
}
// String liefert die Form, die der Admin an den Agenten weitergibt.
func (t EnrollmentToken) String() string { return t.Secret + "." + string(t.Fingerprint) }
// String liefert die Form, die der Admin an den Agenten weitergibt. Alle
// Teile sind Base64url – der Token ist ohne Quoting shell-sicher.
func (t EnrollmentToken) String() string {
s := t.Secret + "." + string(t.Fingerprint)
if t.ServerURL != "" {
s += "." + base64.RawURLEncoding.EncodeToString([]byte(t.ServerURL))
}
return s
}
// NewEnrollmentToken erzeugt einen Token mit 256 Bit Zufall für den Server
// mit Fingerprint fp.
func NewEnrollmentToken(fp Fingerprint) (EnrollmentToken, error) {
// mit Fingerprint fp; serverURL ist optional ("" = nicht einbetten).
func NewEnrollmentToken(fp Fingerprint, serverURL string) (EnrollmentToken, error) {
if _, err := ParseFingerprint(string(fp)); err != nil {
return EnrollmentToken{}, err
}
if serverURL != "" {
u, err := ValidateServerURL(serverURL)
if err != nil {
return EnrollmentToken{}, err
}
serverURL = u
}
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return EnrollmentToken{}, err
}
return EnrollmentToken{Secret: TokenPrefix + base64.RawURLEncoding.EncodeToString(b[:]), Fingerprint: fp}, nil
return EnrollmentToken{
Secret: TokenPrefix + base64.RawURLEncoding.EncodeToString(b[:]),
Fingerprint: fp,
ServerURL: serverURL,
}, nil
}
// ParseEnrollmentToken zerlegt einen Token-String. Leerraum am Rand (beim
// Kopieren) wird ignoriert.
func ParseEnrollmentToken(s string) (EnrollmentToken, error) {
parts := strings.Split(strings.TrimSpace(s), ".")
if len(parts) != 2 {
return EnrollmentToken{}, fmt.Errorf("%w: erwartet %s<secret>.<fingerprint>", ErrInvalidToken, TokenPrefix)
if len(parts) != 2 && len(parts) != 3 {
return EnrollmentToken{}, fmt.Errorf("%w: erwartet %s<secret>.<fingerprint>[.<server-url>]", ErrInvalidToken, TokenPrefix)
}
secret, rawFP := parts[0], parts[1]
body, ok := strings.CutPrefix(secret, TokenPrefix)
@@ -61,5 +92,51 @@ func ParseEnrollmentToken(s string) (EnrollmentToken, error) {
if err != nil {
return EnrollmentToken{}, fmt.Errorf("%w: %w", ErrInvalidToken, err)
}
return EnrollmentToken{Secret: secret, Fingerprint: fp}, nil
tok := EnrollmentToken{Secret: secret, Fingerprint: fp}
if len(parts) == 3 {
raw, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || len(raw) == 0 {
return EnrollmentToken{}, fmt.Errorf("%w: Server-URL beschädigt", ErrInvalidToken)
}
if tok.ServerURL, err = ValidateServerURL(string(raw)); err != nil {
return EnrollmentToken{}, fmt.Errorf("%w: %w", ErrInvalidToken, err)
}
}
return tok, nil
}
// ValidateServerURL prüft eine Server-Adresse und normalisiert sie: nur
// https, nur Host und optional Port (kein Benutzer, Pfad, Query, Fragment),
// Host klein, ohne Schrägstrich am Ende.
func ValidateServerURL(raw string) (string, error) {
bad := func(why string) (string, error) {
return "", fmt.Errorf("%w: %q – %s (erwartet https://host[:port])", ErrInvalidServerURL, raw, why)
}
if len(raw) > maxServerURLLen {
return bad("zu lang")
}
u, err := url.Parse(strings.TrimSpace(raw))
switch {
case err != nil:
return bad("nicht lesbar")
case u.Scheme != "https":
return bad("nur https")
case u.Hostname() == "":
return bad("Host fehlt")
case u.User != nil:
return bad("keine Zugangsdaten in der URL")
case u.Opaque != "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.ForceQuery || u.Fragment != "":
return bad("kein Pfad, keine Query")
}
host := strings.ToLower(u.Hostname())
if port := u.Port(); port != "" {
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
return bad("ungültiger Port")
}
return "https://" + net.JoinHostPort(host, port), nil
}
if strings.Contains(host, ":") {
host = "[" + host + "]"
}
return "https://" + host, nil
}
+78 -4
View File
@@ -1,6 +1,7 @@
package crypto
import (
"encoding/base64"
"errors"
"strings"
"testing"
@@ -24,7 +25,7 @@ func TestFingerprint_ParseRoundTrip(t *testing.T) {
func TestEnrollmentToken_RoundTrip(t *testing.T) {
sc, _ := LoadOrCreateServerCert(t.TempDir())
tok, err := NewEnrollmentToken(sc.Fingerprint)
tok, err := NewEnrollmentToken(sc.Fingerprint, "")
if err != nil {
t.Fatalf("NewEnrollmentToken: %v", err)
}
@@ -52,7 +53,7 @@ func TestEnrollmentToken_Unique(t *testing.T) {
sc, _ := LoadOrCreateServerCert(t.TempDir())
seen := map[string]bool{}
for range 100 {
tok, err := NewEnrollmentToken(sc.Fingerprint)
tok, err := NewEnrollmentToken(sc.Fingerprint, "")
if err != nil {
t.Fatal(err)
}
@@ -65,7 +66,7 @@ func TestEnrollmentToken_Unique(t *testing.T) {
func TestEnrollmentToken_Errors(t *testing.T) {
sc, _ := LoadOrCreateServerCert(t.TempDir())
good, _ := NewEnrollmentToken(sc.Fingerprint)
good, _ := NewEnrollmentToken(sc.Fingerprint, "")
secretPart := strings.TrimPrefix(good.Secret, TokenPrefix)
fp := string(sc.Fingerprint)
@@ -86,7 +87,80 @@ func TestEnrollmentToken_Errors(t *testing.T) {
}
})
}
if _, err := NewEnrollmentToken("kaputt"); !errors.Is(err, ErrInvalidFingerprint) {
if _, err := NewEnrollmentToken("kaputt", ""); !errors.Is(err, ErrInvalidFingerprint) {
t.Errorf("NewEnrollmentToken mit kaputtem Fingerprint: %v", err)
}
}
func TestEnrollmentToken_WithServerURL(t *testing.T) {
sc, _ := LoadOrCreateServerCert(t.TempDir())
tok, err := NewEnrollmentToken(sc.Fingerprint, "https://Ingress.example.de:8443/")
if err != nil {
t.Fatal(err)
}
if tok.ServerURL != "https://ingress.example.de:8443" {
t.Errorf("URL nicht normalisiert: %q", tok.ServerURL)
}
s := tok.String()
if parts := strings.Split(s, "."); len(parts) != 3 {
t.Fatalf("erwartet drei Teile: %q", s)
}
if strings.ContainsAny(s, ":/?=+ ") {
t.Errorf("Token nicht shell-/kopiersicher: %q", s)
}
parsed, err := ParseEnrollmentToken(s)
if err != nil {
t.Fatalf("ParseEnrollmentToken: %v", err)
}
if parsed != tok {
t.Errorf("Round-Trip: %+v vs %+v", parsed, tok)
}
// Ohne URL: zweiteilig wie bisher.
plain, _ := NewEnrollmentToken(sc.Fingerprint, "")
if parsed, err := ParseEnrollmentToken(plain.String()); err != nil || parsed.ServerURL != "" {
t.Errorf("Token ohne URL: %+v %v", parsed, err)
}
if _, err := NewEnrollmentToken(sc.Fingerprint, "http://ingress:8443"); !errors.Is(err, ErrInvalidServerURL) {
t.Errorf("http-URL: %v", err)
}
}
func TestEnrollmentToken_InvalidServerURLPart(t *testing.T) {
sc, _ := LoadOrCreateServerCert(t.TempDir())
base, _ := NewEnrollmentToken(sc.Fingerprint, "")
enc := func(u string) string { return base.String() + "." + base64.RawURLEncoding.EncodeToString([]byte(u)) }
for name, s := range map[string]string{
"kein base64url": base.String() + ".!!!",
"leer": base.String() + ".",
"http": enc("http://ingress:8443"),
"mit Pfad": enc("https://ingress:8443/admin"),
"mit Zugangsdaten": enc("https://user:pw@ingress:8443"),
"zu lang": enc("https://" + strings.Repeat("a", 300) + ".de"),
} {
if _, err := ParseEnrollmentToken(s); !errors.Is(err, ErrInvalidToken) {
t.Errorf("%s: erwartet ErrInvalidToken, bekommen %v", name, err)
}
}
}
func TestValidateServerURL(t *testing.T) {
valid := map[string]string{
"https://ingress.x.de:8443": "https://ingress.x.de:8443",
"https://ingress.x.de:8443/": "https://ingress.x.de:8443",
"https://Ingress.X.de": "https://ingress.x.de",
"https://[fd00::1]:8443": "https://[fd00::1]:8443",
"https://100.64.0.1:8443": "https://100.64.0.1:8443",
}
for in, want := range valid {
if got, err := ValidateServerURL(in); err != nil || got != want {
t.Errorf("ValidateServerURL(%q) = %q, %v; erwartet %q", in, got, err, want)
}
}
for _, bad := range []string{"", "ingress:8443", "http://ingress:8443", "https://", "https://u:p@x:8443",
"https://x:8443/pfad", "https://x:8443?q=1", "https://x:8443#frag", "https://x:99999", "ftp://x"} {
if _, err := ValidateServerURL(bad); !errors.Is(err, ErrInvalidServerURL) {
t.Errorf("ValidateServerURL(%q): erwartet ErrInvalidServerURL, bekommen %v", bad, err)
}
}
}
+140
View File
@@ -0,0 +1,140 @@
// Package docker ist ein minimaler Client für die Docker Engine API über
// den Unix-Socket – genau das, was der Agent braucht: laufende Container
// mit Labels und Ports auflisten und Container-Events verfolgen. Bewusst
// ohne Docker-SDK (schwere Abhängigkeit für zwei Endpunkte).
package docker
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
)
// Container ist ein Eintrag aus GET /containers/json.
type Container struct {
ID string `json:"Id"`
Names []string `json:"Names"`
State string `json:"State"`
Labels map[string]string `json:"Labels"`
Ports []Port `json:"Ports"`
}
// Name liefert den Containernamen ohne führenden Schrägstrich.
func (c Container) Name() string {
if len(c.Names) == 0 {
return c.ID
}
return strings.TrimPrefix(c.Names[0], "/")
}
// Port ist eine Portzuordnung; PublicPort 0 = nicht veröffentlicht.
type Port struct {
IP string `json:"IP,omitempty"`
PrivatePort uint16 `json:"PrivatePort"`
PublicPort uint16 `json:"PublicPort,omitempty"`
Type string `json:"Type"`
}
// Event ist ein Eintrag aus GET /events.
type Event struct {
Type string `json:"Type"`
Action string `json:"Action"`
Actor Actor `json:"Actor"`
}
// Actor beschreibt das betroffene Objekt eines Events.
type Actor struct {
ID string `json:"ID"`
Attributes map[string]string `json:"Attributes,omitempty"`
}
// Client spricht die Engine API über Socket (z. B. /var/run/docker.sock).
type Client struct {
Socket string
}
func (c *Client) http() *http.Client {
return &http.Client{Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", c.Socket)
},
}}
}
func (c *Client) get(ctx context.Context, path string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://docker"+path, nil)
if err != nil {
return nil, err
}
resp, err := c.http().Do(req)
if err != nil {
return nil, fmt.Errorf("docker: %s: %w", path, err)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
resp.Body.Close()
return nil, fmt.Errorf("docker: %s: Status %d: %s", path, resp.StatusCode, strings.TrimSpace(string(body)))
}
return resp, nil
}
// List liefert die laufenden Container.
func (c *Client) List(ctx context.Context) ([]Container, error) {
resp, err := c.get(ctx, "/containers/json")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out []Container
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("docker: Containerliste: %w", err)
}
return out, nil
}
// Events verfolgt Container-Events, bis ctx endet oder der Stream abbricht.
// Der Event-Kanal wird danach geschlossen; errc liefert genau einen Wert
// (ctx.Err() bei Abbruch, sonst den Grund).
func (c *Client) Events(ctx context.Context) (<-chan Event, <-chan error) {
events := make(chan Event)
errc := make(chan error, 1)
go func() {
defer close(events)
filters := url.QueryEscape(`{"type":["container"]}`)
resp, err := c.get(ctx, "/events?filters="+filters)
if err != nil {
errc <- err
return
}
defer resp.Body.Close()
dec := json.NewDecoder(resp.Body)
for {
var ev Event
if err := dec.Decode(&ev); err != nil {
switch {
case ctx.Err() != nil:
errc <- ctx.Err()
case errors.Is(err, io.EOF):
errc <- errors.New("docker: Event-Stream beendet")
default:
errc <- fmt.Errorf("docker: Event-Stream: %w", err)
}
return
}
select {
case events <- ev:
case <-ctx.Done():
errc <- ctx.Err()
return
}
}
}()
return events, errc
}
+154
View File
@@ -0,0 +1,154 @@
package docker
import (
"context"
"encoding/json"
"net"
"net/http"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
// fakeEngine bedient die Docker-API über einen Unix-Socket.
type fakeEngine struct {
path string
events chan Event // was der Server über /events streamt
lastQ chan string
}
func newFakeEngine(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) *fakeEngine {
t.Helper()
// Unix-Socket-Pfade sind kurz begrenzt; TempDir kann zu lang sein.
dir, err := os.MkdirTemp("", "dk")
if err != nil {
t.Fatal(err)
}
f := &fakeEngine{path: filepath.Join(dir, "d.sock"), events: make(chan Event, 8), lastQ: make(chan string, 8)}
ln, err := net.Listen("unix", f.path)
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("/containers/json", handler)
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
f.lastQ <- r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
enc := json.NewEncoder(w)
for {
select {
case <-r.Context().Done():
return
case ev, ok := <-f.events:
if !ok {
return
}
enc.Encode(ev)
w.(http.Flusher).Flush()
}
}
})
srv := &http.Server{Handler: mux}
go srv.Serve(ln)
t.Cleanup(func() { srv.Close(); os.RemoveAll(dir) })
return f
}
const containersJSON = `[
{"Id":"abc","Names":["/wordpress"],"State":"running",
"Labels":{"traefik.enable":"true","traefik.http.routers.wp.rule":"Host(` + "`kunde.de`" + `)"},
"Ports":[{"IP":"0.0.0.0","PrivatePort":80,"PublicPort":8081,"Type":"tcp"},{"PrivatePort":443,"Type":"tcp"}]},
{"Id":"def","Names":["/db"],"State":"running","Labels":{},"Ports":[]}
]`
func TestList(t *testing.T) {
f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(containersJSON))
})
got, err := (&Client{Socket: f.path}).List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
want := []Container{
{
ID: "abc", Names: []string{"/wordpress"}, State: "running",
Labels: map[string]string{"traefik.enable": "true", "traefik.http.routers.wp.rule": "Host(`kunde.de`)"},
Ports: []Port{{IP: "0.0.0.0", PrivatePort: 80, PublicPort: 8081, Type: "tcp"}, {PrivatePort: 443, Type: "tcp"}},
},
{ID: "def", Names: []string{"/db"}, State: "running", Labels: map[string]string{}, Ports: []Port{}},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("List =\n%+v\nerwartet\n%+v", got, want)
}
if got[0].Name() != "wordpress" {
t.Errorf("Name() = %q", got[0].Name())
}
}
func TestList_Errors(t *testing.T) {
f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"message":"kaputt"}`, http.StatusInternalServerError)
})
if _, err := (&Client{Socket: f.path}).List(context.Background()); err == nil || !strings.Contains(err.Error(), "500") {
t.Errorf("Status 500: %v", err)
}
if _, err := (&Client{Socket: "/gibt/es/nicht.sock"}).List(context.Background()); err == nil {
t.Error("fehlender Socket: erwartet Fehler")
}
}
func TestEvents(t *testing.T) {
f := newFakeEngine(t, func(http.ResponseWriter, *http.Request) {})
ctx, cancel := context.WithCancel(context.Background())
events, errc := (&Client{Socket: f.path}).Events(ctx)
q := <-f.lastQ
if !strings.Contains(q, "filters=") || !strings.Contains(q, "container") {
t.Errorf("Events ohne Container-Filter: %q", q)
}
f.events <- Event{Type: "container", Action: "start", Actor: Actor{ID: "abc", Attributes: map[string]string{"name": "wordpress"}}}
f.events <- Event{Type: "container", Action: "die", Actor: Actor{ID: "abc"}}
for _, want := range []string{"start", "die"} {
select {
case ev := <-events:
if ev.Action != want || ev.Actor.ID != "abc" {
t.Errorf("Event = %+v, erwartet %s", ev, want)
}
case <-time.After(2 * time.Second):
t.Fatalf("Event %s nicht empfangen", want)
}
}
cancel()
select {
case _, ok := <-events:
if ok {
t.Error("Kanal nach Abbruch nicht geschlossen")
}
case <-time.After(2 * time.Second):
t.Fatal("Events endet nicht nach Abbruch")
}
if err := <-errc; err != nil && err != context.Canceled {
t.Errorf("Fehler nach Abbruch: %v", err)
}
}
// Endet der Event-Stream serverseitig, meldet Events das über errc.
func TestEvents_StreamEnds(t *testing.T) {
f := newFakeEngine(t, func(http.ResponseWriter, *http.Request) {})
events, errc := (&Client{Socket: f.path}).Events(context.Background())
<-f.lastQ
close(f.events)
for range events {
}
if err := <-errc; err == nil {
t.Error("erwartet Fehler bei beendetem Stream")
}
}
+332
View File
@@ -0,0 +1,332 @@
// Package lander ist der Sultan-Interceptor (Spec 5.2): Traefik leitet
// Router, deren Mandant bzw. Dienst nicht "normal" ist, hierher um und
// teilt die Stufe per Header mit (X-Sultan-Stage, -Tenant, -Tier).
//
// Je Stufe gibt es eine kleine HTML-Seite (Deutsch und Englisch), eingebaut
// oder als eigene html/template-Vorlage aus Dir. CSS und Grafik einer
// eigenen Vorlage werden eingebettet: Bei watermark/firemark ist das Cookie
// sultan_ack schon gesetzt, ein separater Request für Assets ginge über den
// Durchlass-Router ans Backend des Kunden.
package lander
import (
"bytes"
"embed"
"encoding/base64"
"fmt"
"html/template"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
//go:embed templates
var builtinFS embed.FS
// Stages sind die Stufen mit eigener Seite.
var Stages = []string{"watermark", "firemark", "paywall", "landing", "maintenance"}
// MaxAssetSize begrenzt CSS und Grafik einer eigenen Vorlage.
const MaxAssetSize = 512 << 10
// AckCookie muss zu policy.AckCookie passen (Durchlass-Router).
const AckCookie = "sultan_ack"
var builtin = template.Must(template.ParseFS(builtinFS, "templates/*.html"))
// Lander liefert die Seiten aus. Der Nullwert funktioniert (eingebaute
// Seiten, Default-Zeiten); Felder nach der ersten Verwendung nicht ändern.
type Lander struct {
Dir string // eigene Vorlagen (z. B. ./suldata/landing); leer = nur eingebaut
HosterName string // für Einladung (paywall/landing)
HosterURL string // Angebotsseite des Hosters
ProURL string // Pro-Hinweis auf Community-Wartungsseiten
WatermarkDelay time.Duration // Default 5s
FiremarkDelay time.Duration // Default 10s
AckTTL time.Duration // Default 60min: Gültigkeit des Cookies sultan_ack
Location *time.Location // Zeitzone für das angezeigte Wartungsende (Default: lokal)
Logger *slog.Logger
now func() time.Time
mu sync.Mutex
cache map[string]*customPage
}
// PageData sind die Werte, die Vorlagen nutzen können (siehe README.txt).
type PageData struct {
Stage string
Tenant string
Tier string
Community bool
Target string
RefreshSeconds int
HosterName string
HosterURL string
ProURL string
PublicReason string // Wartung: ersetzt den Standardtext
UntilDE string // Wartung: voraussichtliches Ende, z. B. 02.10.2026 15:30
UntilEN string // dasselbe, z. B. 2026-10-02 15:30
CSS template.CSS
Image template.URL
}
type stageInfo struct {
status int
delay func(*Lander) time.Duration // nil = keine Weiterleitung, kein Cookie
headers map[string]string
}
var stages = map[string]stageInfo{
"watermark": {http.StatusOK, func(l *Lander) time.Duration { return or(l.WatermarkDelay, 5*time.Second) }, nil},
"firemark": {http.StatusOK, func(l *Lander) time.Duration { return or(l.FiremarkDelay, 10*time.Second) }, nil},
"paywall": {http.StatusPaymentRequired, nil, nil},
"landing": {http.StatusPaymentRequired, nil, nil},
"maintenance": {http.StatusServiceUnavailable, nil, map[string]string{"Retry-After": "60"}},
}
func or(d, def time.Duration) time.Duration {
if d > 0 {
return d
}
return def
}
func (l *Lander) log() *slog.Logger {
if l.Logger != nil {
return l.Logger
}
return slog.Default()
}
func (l *Lander) ServeHTTP(w http.ResponseWriter, r *http.Request) {
stage := r.Header.Get("X-Sultan-Stage")
info, ok := stages[stage]
if !ok {
http.Error(w, "unbekannte Stufe", http.StatusBadRequest)
return
}
tier := r.Header.Get("X-Sultan-Tier")
data := PageData{
Stage: stage,
Tenant: r.Header.Get("X-Sultan-Tenant"),
Tier: tier,
Community: tier == "community",
Target: localTarget(r.URL),
HosterName: l.HosterName,
HosterURL: l.HosterURL,
ProURL: l.ProURL,
}
if info.delay != nil {
data.RefreshSeconds = int(info.delay(l) / time.Second)
}
retryAfter := ""
if stage == "maintenance" {
retryAfter = l.maintenanceDetails(r, &data)
}
body := l.render(stage, data)
h := w.Header()
h.Set("Content-Type", "text/html; charset=utf-8")
h.Set("Cache-Control", "no-store")
for k, v := range info.headers {
h.Set(k, v)
}
if retryAfter != "" {
h.Set("Retry-After", retryAfter)
}
if info.delay != nil {
http.SetCookie(w, &http.Cookie{
Name: AckCookie,
Value: stage,
Path: "/",
MaxAge: int(or(l.AckTTL, time.Hour) / time.Second),
HttpOnly: true,
Secure: r.Header.Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteLaxMode,
})
}
w.WriteHeader(info.status)
w.Write(body)
}
const maxPublicReason = 300
// maintenanceDetails übernimmt öffentlichen Grund und Ende aus den Headern
// der Policy und liefert Retry-After (Sekunden bis zum Ende, 60 s bis 1 Tag).
func (l *Lander) maintenanceDetails(r *http.Request, data *PageData) string {
if raw := r.Header.Get("X-Sultan-Public-Reason"); raw != "" {
reason, err := url.QueryUnescape(raw)
if err != nil {
reason = raw
}
reason = strings.TrimSpace(reason)
if runes := []rune(reason); len(runes) > maxPublicReason {
reason = string(runes[:maxPublicReason]) + "…"
}
data.PublicReason = reason
}
now := time.Now()
if l.now != nil {
now = l.now()
}
until, err := time.Parse(time.RFC3339, r.Header.Get("X-Sultan-Until"))
if err != nil || !until.After(now) {
return ""
}
loc := l.Location
if loc == nil {
loc = time.Local
}
data.UntilDE = until.In(loc).Format("02.01.2006 15:04")
data.UntilEN = until.In(loc).Format("2006-01-02 15:04")
secs := int(until.Sub(now) / time.Second)
return strconv.Itoa(min(max(secs, 60), 86400))
}
// localTarget liefert den Pfad (+Query) des ursprünglichen Requests als
// Weiterleitungsziel – nur lokal: kein Host, kein Schema, kein "//" oder
// "\" am Anfang (sonst offene Weiterleitung auf fremde Seiten).
func localTarget(u *url.URL) string {
if u == nil {
return "/"
}
p := u.EscapedPath()
if !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") || strings.Contains(u.Path, `\`) {
return "/"
}
if u.RawQuery != "" {
return p + "?" + u.RawQuery
}
return p
}
// render nutzt die eigene Vorlage der Stufe, sonst (oder bei jedem Fehler)
// die eingebaute.
func (l *Lander) render(stage string, data PageData) []byte {
if page := l.customPage(stage); page != nil {
data.CSS, data.Image = page.css, page.image
var buf bytes.Buffer
err := page.tmpl.Execute(&buf, data)
if err == nil {
return buf.Bytes()
}
l.log().Error("Lander-Vorlage fehlerhaft – eingebaute Seite verwendet", "stage", stage, "err", err)
data.CSS, data.Image = "", ""
}
var buf bytes.Buffer
if err := builtin.ExecuteTemplate(&buf, stage+".html", data); err != nil {
l.log().Error("eingebaute Lander-Seite", "stage", stage, "err", err)
return []byte("<!doctype html><title>" + stage + "</title>")
}
return buf.Bytes()
}
type customPage struct {
signature string
tmpl *template.Template // nil: keine (gültige) eigene Vorlage
css template.CSS
image template.URL
}
// customPage lädt die eigene Vorlage einer Stufe und lädt neu, sobald sich
// eine der Dateien ändert (Name, Größe, Zeitstempel).
func (l *Lander) customPage(stage string) *customPage {
if l.Dir == "" {
return nil
}
files := []string{stage + ".html", stage + ".css", stage + ".svg", stage + ".webp"}
var sig strings.Builder
for _, f := range files {
if info, err := os.Stat(filepath.Join(l.Dir, f)); err == nil {
fmt.Fprintf(&sig, "%s:%d:%d;", f, info.Size(), info.ModTime().UnixNano())
}
}
l.mu.Lock()
defer l.mu.Unlock()
if c := l.cache[stage]; c != nil && c.signature == sig.String() {
if c.tmpl == nil {
return nil
}
return c
}
page := &customPage{signature: sig.String()}
if l.cache == nil {
l.cache = map[string]*customPage{}
}
l.cache[stage] = page
htmlPath := filepath.Join(l.Dir, stage+".html")
src, err := os.ReadFile(htmlPath)
if err != nil {
return nil // keine eigene Vorlage
}
tmpl, err := template.New(stage).Parse(string(src))
if err != nil {
l.log().Error("Lander-Vorlage fehlerhaft – eingebaute Seite verwendet", "file", htmlPath, "err", err)
return nil
}
page.tmpl = tmpl
if css, ok := l.readAsset(filepath.Join(l.Dir, stage+".css")); ok {
page.css = template.CSS(css)
}
for ext, mime := range map[string]string{".svg": "image/svg+xml", ".webp": "image/webp"} {
if img, ok := l.readAsset(filepath.Join(l.Dir, stage+ext)); ok {
page.image = template.URL("data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(img))
}
}
return page
}
func (l *Lander) readAsset(path string) ([]byte, bool) {
info, err := os.Stat(path)
if err != nil {
return nil, false
}
if info.Size() > MaxAssetSize {
l.log().Warn("Lander-Asset zu groß – ignoriert", "file", path, "max_bytes", MaxAssetSize)
return nil, false
}
data, err := os.ReadFile(path)
return data, err == nil
}
// Dump schreibt die eingebauten Seiten und eine README.txt als Vorlagen nach
// dir. Bestehende Dateien werden nur mit force überschrieben (sonst
// os.ErrExist, ohne etwas zu schreiben).
func Dump(dir string, force bool) ([]string, error) {
names := []string{"README.txt"}
for _, s := range Stages {
names = append(names, s+".html")
}
if !force {
for _, n := range names {
if _, err := os.Stat(filepath.Join(dir, n)); err == nil {
return nil, fmt.Errorf("%s existiert bereits (überschreiben mit --force): %w", filepath.Join(dir, n), os.ErrExist)
}
}
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
var written []string
for _, n := range names {
data, err := builtinFS.ReadFile("templates/" + n)
if err != nil {
return written, err
}
path := filepath.Join(dir, n)
if err := os.WriteFile(path, data, 0o644); err != nil {
return written, err
}
written = append(written, path)
}
return written, nil
}
+374
View File
@@ -0,0 +1,374 @@
package lander
import (
"bytes"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func request(t *testing.T, l *Lander, stage, tier, target string, header ...string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(http.MethodGet, target, nil)
r.Host = "kunde.de"
if stage != "" {
r.Header.Set("X-Sultan-Stage", stage)
}
r.Header.Set("X-Sultan-Tenant", "kunde")
if tier != "" {
r.Header.Set("X-Sultan-Tier", tier)
}
for i := 0; i+1 < len(header); i += 2 {
r.Header.Set(header[i], header[i+1])
}
rec := httptest.NewRecorder()
l.ServeHTTP(rec, r)
return rec
}
func newLander(t *testing.T) *Lander {
return &Lander{Logger: slog.New(slog.DiscardHandler), HosterName: "Beispiel-Hosting", HosterURL: "https://hoster.example", ProURL: "https://hoster.example/pro"}
}
func TestStages(t *testing.T) {
tests := []struct {
stage string
status int
refresh string // erwarteter meta-refresh-Inhalt, leer = keiner
cookie bool
}{
{"watermark", 200, "5;url=/shop?x=1", true},
{"firemark", 200, "10;url=/shop?x=1", true},
{"paywall", 402, "", false},
{"landing", 402, "", false},
{"maintenance", 503, "", false},
}
for _, tt := range tests {
t.Run(tt.stage, func(t *testing.T) {
rec := request(t, newLander(t), tt.stage, "pro", "/shop?x=1")
if rec.Code != tt.status {
t.Errorf("Status %d, erwartet %d", rec.Code, tt.status)
}
if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" {
t.Errorf("Content-Type %q", ct)
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Error("Seite darf nicht gecacht werden")
}
body := rec.Body.String()
if tt.refresh != "" && !strings.Contains(body, `http-equiv="refresh" content="`+tt.refresh+`"`) {
t.Errorf("meta refresh %q fehlt:\n%s", tt.refresh, body)
}
if tt.refresh == "" && strings.Contains(body, "refresh") {
t.Error("Stufe ohne Durchlass darf nicht weiterleiten")
}
cookie := rec.Header().Get("Set-Cookie")
if tt.cookie {
for _, want := range []string{"sultan_ack=" + tt.stage, "Max-Age=3600", "HttpOnly", "SameSite=Lax", "Path=/"} {
if !strings.Contains(cookie, want) {
t.Errorf("Cookie %q ohne %q", cookie, want)
}
}
} else if cookie != "" {
t.Errorf("unerwartetes Cookie %q", cookie)
}
if !strings.Contains(body, `lang="de"`) || !strings.Contains(body, `lang="en"`) {
t.Error("Text muss auf Deutsch und Englisch vorliegen")
}
})
}
}
func TestMaintenanceRetryAfterAndProHint(t *testing.T) {
l := newLander(t)
rec := request(t, l, "maintenance", "community", "/")
if rec.Header().Get("Retry-After") != "60" {
t.Errorf("Retry-After %q", rec.Header().Get("Retry-After"))
}
if !strings.Contains(rec.Body.String(), "https://hoster.example/pro") {
t.Error("Community-Wartungsseite ohne Pro-Hinweis")
}
if strings.Contains(request(t, l, "maintenance", "pro", "/").Body.String(), "https://hoster.example/pro") {
t.Error("Pro-Kunde bekommt Pro-Werbung")
}
}
func TestPaywallInvitesToHoster(t *testing.T) {
body := request(t, newLander(t), "paywall", "pro", "/").Body.String()
if !strings.Contains(body, "Beispiel-Hosting") || !strings.Contains(body, "https://hoster.example") {
t.Errorf("Einladung des Hosters fehlt:\n%s", body)
}
}
func TestSecureCookieBehindHTTPS(t *testing.T) {
rec := request(t, newLander(t), "watermark", "pro", "/", "X-Forwarded-Proto", "https")
if !strings.Contains(rec.Header().Get("Set-Cookie"), "Secure") {
t.Errorf("Cookie ohne Secure: %q", rec.Header().Get("Set-Cookie"))
}
}
func TestConfigurableDelays(t *testing.T) {
l := newLander(t)
l.WatermarkDelay, l.FiremarkDelay, l.AckTTL = 2*time.Second, 7*time.Second, 15*time.Minute
if body := request(t, l, "watermark", "pro", "/").Body.String(); !strings.Contains(body, `content="2;url=/"`) {
t.Errorf("Wartezeit Stufe 1:\n%s", body)
}
rec := request(t, l, "firemark", "pro", "/")
if !strings.Contains(rec.Body.String(), `content="7;url=/"`) || !strings.Contains(rec.Header().Get("Set-Cookie"), "Max-Age=900") {
t.Errorf("Stufe 2: %q / %s", rec.Header().Get("Set-Cookie"), rec.Body.String())
}
}
func TestUnknownOrMissingStage(t *testing.T) {
for _, stage := range []string{"", "normal", "kaputt"} {
if rec := request(t, newLander(t), stage, "pro", "/"); rec.Code != http.StatusBadRequest {
t.Errorf("Stufe %q: Status %d", stage, rec.Code)
}
}
}
// Werte aus Headern und URL werden nie roh ausgegeben.
func TestEscaping(t *testing.T) {
rec := request(t, newLander(t), "watermark", "pro", `/a"><script>alert(1)</script>`,
"X-Sultan-Tenant", "<script>böse</script>")
body := rec.Body.String()
if strings.Contains(body, "<script>") {
t.Errorf("ungefilterte Ausgabe:\n%s", body)
}
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestCustomTemplateWithInlinedAssets(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "maintenance.html",
`<html><head><style>{{.CSS}}</style></head><body><img src="{{.Image}}"><p>Wartung bei {{.Tenant}} ({{.Tier}})</p></body></html>`)
writeFile(t, dir, "maintenance.css", "body { color: red; }")
writeFile(t, dir, "maintenance.svg", `<svg xmlns="http://www.w3.org/2000/svg"/>`)
l := newLander(t)
l.Dir = dir
rec := request(t, l, "maintenance", "pro", "/")
body := rec.Body.String()
if rec.Code != 503 || !strings.Contains(body, "Wartung bei kunde (pro)") {
t.Errorf("eigene Vorlage nicht verwendet: %d\n%s", rec.Code, body)
}
if !strings.Contains(body, "body { color: red; }") {
t.Error("CSS nicht eingebettet")
}
// html/template schreibt "+" im Attribut als &#43; – der Browser liest es als "+".
if !strings.Contains(body, `src="data:image/svg&#43;xml;base64,`) {
t.Errorf("Grafik nicht als data-URI eingebettet:\n%s", body)
}
// Andere Stufen ohne eigene Vorlage: eingebaut.
if body := request(t, l, "paywall", "pro", "/").Body.String(); !strings.Contains(body, "Beispiel-Hosting") {
t.Error("eingebaute Seite für paywall fehlt")
}
}
func TestCustomTemplateWebP(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "landing.html", `<img src="{{.Image}}">`)
writeFile(t, dir, "landing.webp", "RIFF0000WEBP")
l := newLander(t)
l.Dir = dir
if body := request(t, l, "landing", "pro", "/").Body.String(); !strings.Contains(body, `src="data:image/webp;base64,`) {
t.Errorf("WebP nicht eingebettet: %s", body)
}
}
// Eine kaputte Vorlage darf die Hinweisseite nie ausfallen lassen.
func TestBrokenTemplateFallsBackToBuiltin(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "maintenance.html", `{{.GibtsNicht`)
var logs bytes.Buffer
l := newLander(t)
l.Dir, l.Logger = dir, slog.New(slog.NewTextHandler(&logs, nil))
rec := request(t, l, "maintenance", "pro", "/")
if rec.Code != 503 || !strings.Contains(rec.Body.String(), `lang="de"`) {
t.Errorf("keine eingebaute Seite: %d %s", rec.Code, rec.Body.String())
}
if !strings.Contains(logs.String(), "maintenance.html") {
t.Errorf("Fehler nicht protokolliert: %s", logs.String())
}
// Ausführungsfehler (unbekanntes Feld) ebenfalls.
writeFile(t, dir, "maintenance.html", `{{.GibtsNicht}}`)
rec = request(t, l, "maintenance", "pro", "/")
if rec.Code != 503 || !strings.Contains(rec.Body.String(), `lang="de"`) {
t.Errorf("Ausführungsfehler: %d %s", rec.Code, rec.Body.String())
}
}
func TestOversizedAssetIgnored(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "maintenance.html", `<img src="{{.Image}}">ok`)
writeFile(t, dir, "maintenance.svg", strings.Repeat("x", MaxAssetSize+1))
l := newLander(t)
l.Dir = dir
body := request(t, l, "maintenance", "pro", "/").Body.String()
if strings.Contains(body, "base64") || !strings.Contains(body, "ok") {
t.Errorf("zu große Grafik eingebettet: %.200s", body)
}
}
func TestTemplatesReloadedOnChange(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "maintenance.html", `Version 1`)
l := newLander(t)
l.Dir = dir
if body := request(t, l, "maintenance", "pro", "/").Body.String(); body != "Version 1" {
t.Fatalf("body = %q", body)
}
writeFile(t, dir, "maintenance.html", `Version 2 – länger`)
if body := request(t, l, "maintenance", "pro", "/").Body.String(); body != "Version 2 – länger" {
t.Errorf("Änderung nicht übernommen: %q", body)
}
}
func TestDump(t *testing.T) {
dir := filepath.Join(t.TempDir(), "landing")
files, err := Dump(dir, false)
if err != nil {
t.Fatalf("Dump: %v", err)
}
for _, stage := range Stages {
if _, err := os.Stat(filepath.Join(dir, stage+".html")); err != nil {
t.Errorf("%s.html fehlt", stage)
}
}
if _, err := os.Stat(filepath.Join(dir, "README.txt")); err != nil {
t.Error("README.txt fehlt")
}
if len(files) != len(Stages)+1 {
t.Errorf("geschriebene Dateien: %v", files)
}
// Ausgegebene Vorlagen rendern genauso wie die eingebauten.
builtin := newLander(t)
custom := newLander(t)
custom.Dir = dir
for _, stage := range Stages {
a := request(t, builtin, stage, "community", "/x").Body.String()
b := request(t, custom, stage, "community", "/x").Body.String()
if a != b {
t.Errorf("%s: Ausgabe weicht ab", stage)
}
}
if _, err := Dump(dir, false); !errors.Is(err, os.ErrExist) {
t.Errorf("Überschreiben ohne force: %v", err)
}
writeFile(t, dir, "maintenance.html", "eigen")
if _, err := Dump(dir, true); err != nil {
t.Fatalf("Dump mit force: %v", err)
}
if b, _ := os.ReadFile(filepath.Join(dir, "maintenance.html")); string(b) == "eigen" {
t.Error("force hat nicht überschrieben")
}
}
func TestAnyMethodGetsPage(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/formular", strings.NewReader("a=b"))
r.Header.Set("X-Sultan-Stage", "maintenance")
rec := httptest.NewRecorder()
newLander(t).ServeHTTP(rec, r)
if rec.Code != 503 {
t.Errorf("POST: %d", rec.Code)
}
}
// Das Weiterleitungsziel ist immer ein lokaler Pfad: keine fremden Hosts
// (//evil), keine Schemata (javascript:), keine Backslash-Tricks.
func TestRefreshTargetIsLocal(t *testing.T) {
tests := map[string]string{
"/shop?x=1": "/shop?x=1",
"//evil.example/x": "/",
"/\\evil.example": "/",
"/a%2F%2Fb": "/a%2F%2Fb",
"/pfad mit leer": "/pfad%20mit%20leer",
}
for target, want := range tests {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.URL.Path, r.URL.RawPath, r.URL.RawQuery = "", "", ""
u, _ := r.URL.Parse(target)
if strings.HasPrefix(target, "//") {
u.Host, u.Path = "", target // so wie es bei einem Request "GET //evil.example/x" ankommt
}
r.URL = u
r.Header.Set("X-Sultan-Stage", "watermark")
rec := httptest.NewRecorder()
newLander(t).ServeHTTP(rec, r)
if !strings.Contains(rec.Body.String(), `content="5;url=`+want+`"`) {
t.Errorf("%q: erwartet Ziel %q in\n%s", target, want, rec.Body.String())
}
}
if got := localTarget(nil); got != "/" {
t.Errorf("localTarget(nil) = %q", got)
}
}
func TestMaintenancePublicReasonAndUntil(t *testing.T) {
berlin, _ := time.LoadLocation("Europe/Berlin")
l := newLander(t)
l.Location = berlin
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
l.now = func() time.Time { return now }
until := now.Add(90 * time.Minute) // 15:30 in Berlin (MESZ)
rec := request(t, l, "maintenance", "pro", "/",
"X-Sultan-Public-Reason", url.QueryEscape("Sicherheitsupdate (CVE-1234) – Sicherheit nehmen wir ernst."),
"X-Sultan-Until", until.Format(time.RFC3339))
body := rec.Body.String()
if !strings.Contains(body, "Sicherheitsupdate (CVE-1234) – Sicherheit nehmen wir ernst.") {
t.Errorf("öffentlicher Grund fehlt:\n%s", body)
}
if strings.Contains(body, "Bitte versuchen Sie es in einer Minute") {
t.Error("Standardtext trotz öffentlichem Grund")
}
if !strings.Contains(body, "02.10.2026 15:30") || !strings.Contains(body, "2026-10-02 15:30") {
t.Errorf("voraussichtliches Ende fehlt:\n%s", body)
}
if rec.Header().Get("Retry-After") != "5400" {
t.Errorf("Retry-After %q, erwartet Sekunden bis zum Ende", rec.Header().Get("Retry-After"))
}
}
func TestMaintenanceHeaderHandling(t *testing.T) {
l := newLander(t)
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
l.now = func() time.Time { return now }
tests := []struct {
name, until, retry string
}{
{"kein Ende", "", "60"},
{"unlesbar", "morgen", "60"},
{"vergangen", now.Add(-time.Hour).Format(time.RFC3339), "60"},
{"sehr fern", now.Add(30 * 24 * time.Hour).Format(time.RFC3339), "86400"},
}
for _, tt := range tests {
rec := request(t, l, "maintenance", "pro", "/", "X-Sultan-Until", tt.until)
if got := rec.Header().Get("Retry-After"); got != tt.retry {
t.Errorf("%s: Retry-After %q, erwartet %q", tt.name, got, tt.retry)
}
}
// Eingeschleustes HTML im Grund wird maskiert, überlange Gründe gekürzt.
rec := request(t, l, "maintenance", "pro", "/", "X-Sultan-Public-Reason", url.QueryEscape("<script>x</script>"+strings.Repeat("a", 400)))
body := rec.Body.String()
if strings.Contains(body, "<script>x") || strings.Contains(body, strings.Repeat("a", 301)) {
t.Errorf("Grund nicht maskiert/gekürzt: %.300s", body)
}
}
+28
View File
@@ -0,0 +1,28 @@
Sultan – eigene Lander-Seiten
=============================
Pro Stufe eine Vorlage <stufe>.html (Go html/template), optional <stufe>.css
und eine Grafik <stufe>.svg oder <stufe>.webp (je höchstens 512 KiB).
CSS und Grafik werden in die Seite eingebettet – es gibt keine weiteren URLs.
Stufen: watermark, firemark, paywall, landing, maintenance
Fehlt eine Vorlage oder ist sie fehlerhaft, gilt die eingebaute Seite.
Änderungen wirken sofort (kein Neustart nötig).
Verfügbare Werte:
{{.Stage}} Stufe
{{.Tenant}} Mandanten-ID
{{.Tier}} community | pro | enterprise
{{.Community}} true bei Tier community
{{.Target}} lokaler Pfad der ursprünglichen Seite (für Weiterleitung/Link)
{{.RefreshSeconds}} Wartezeit der Weiterleitung (watermark/firemark)
{{.HosterName}} Name des Hosting-Anbieters (sultan server --hoster-name)
{{.HosterURL}} Angebotsseite des Anbieters (--hoster-url)
{{.ProURL}} Pro-Angebot für Community (--pro-url)
{{.PublicReason}} öffentlicher Wartungsgrund (maintenance, falls angegeben)
{{.UntilDE}} voraussichtliches Ende, z. B. 02.10.2026 15:30 (maintenance)
{{.UntilEN}} dasselbe, z. B. 2026-10-02 15:30
{{.CSS}} Inhalt von <stufe>.css – in <style>{{.CSS}}</style>
{{.Image}} Grafik als data:-URI – in <img src="{{.Image}}">
Syntax: https://pkg.go.dev/html/template (z. B. {{if .ProURL}}…{{end}})
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<meta http-equiv="refresh" content="{{.RefreshSeconds}};url={{.Target}}">
<title>Wichtiger Hinweis / Important notice</title>
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
</head>
<body>
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
<section lang="de">
<h1>Wichtiger Hinweis</h1>
<p>Für diese Website ist beim Hosting-Anbieter noch etwas offen. Wenn der Betreiber es nicht bald klärt,
wird die Website abgeschaltet. Bis dahin ist sie erreichbar – Sie werden in {{.RefreshSeconds}} Sekunden weitergeleitet.</p>
<p><a href="{{.Target}}">Weiter zur Website</a></p>
</section>
<section lang="en">
<h1>Important notice</h1>
<p>Something is still open with the hosting provider of this website. If the owner does not sort it out soon,
the website will be switched off. Until then it is available – you will be redirected in {{.RefreshSeconds}} seconds.</p>
<p><a href="{{.Target}}">Continue to the website</a></p>
</section>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Abgelaufen / Expired</title>
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
</head>
<body>
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
<section lang="de">
<h1>Das Hosting dieser Website ist abgelaufen</h1>
<p>Die Website ist deshalb gerade nicht erreichbar. Sobald der Betreiber verlängert, ist sie wieder da.
{{if .HosterName}}{{.HosterName}}{{else}}Der Hosting-Anbieter{{end}} freut sich, wenn Sie sich in der Zwischenzeit umsehen.</p>
{{if .HosterURL}}<p><a href="{{.HosterURL}}">Zu den Angeboten</a></p>{{end}}
</section>
<section lang="en">
<h1>The hosting of this website has expired</h1>
<p>That is why the website is not available right now. As soon as the owner renews, it will be back.
{{if .HosterName}}{{.HosterName}}{{else}}The hosting provider{{end}} would be happy if you have a look around in the meantime.</p>
{{if .HosterURL}}<p><a href="{{.HosterURL}}">See the offers</a></p>{{end}}
</section>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Gleich wieder da / Back soon</title>
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
</head>
<body>
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
<section lang="de">
<h1>Gleich wieder da</h1>
{{if .PublicReason}}<p>{{.PublicReason}}</p>{{else}}<p>Diese Website ist gerade nicht erreichbar – vermutlich wird sie gewartet oder neu gestartet.
Bitte versuchen Sie es in einer Minute noch einmal.</p>{{end}}
{{if .UntilDE}}<p>Voraussichtlich wieder da: {{.UntilDE}}</p>{{end}}
{{if and .Community .ProURL}}<p>Sie möchten so etwas gar nicht erst erleben? <a href="{{.ProURL}}">Hier gibt es den Pro-Service</a>.</p>{{end}}
</section>
<section lang="en">
<h1>Back in a moment</h1>
{{if .PublicReason}}<p>{{.PublicReason}}</p>{{else}}<p>This website is not available right now – it is probably being maintained or restarted.
Please try again in a minute.</p>{{end}}
{{if .UntilEN}}<p>Expected back: {{.UntilEN}}</p>{{end}}
{{if and .Community .ProURL}}<p>Want to avoid this altogether? <a href="{{.ProURL}}">Get the pro service here</a>.</p>{{end}}
</section>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Derzeit offline / Currently offline</title>
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
</head>
<body>
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
<section lang="de">
<h1>Diese Website ist derzeit offline</h1>
<p>Der Betreiber hat sein Hosting nicht verlängert. Schade – aber vielleicht passt ja etwas für Sie:
{{if .HosterName}}{{.HosterName}}{{else}}Der Hosting-Anbieter{{end}} lädt Sie herzlich ein, sich umzusehen, was hier alles möglich ist.</p>
{{if .HosterURL}}<p><a href="{{.HosterURL}}">Zu den Angeboten</a></p>{{end}}
</section>
<section lang="en">
<h1>This website is currently offline</h1>
<p>The owner has not renewed their hosting. A pity – but maybe there is something for you:
{{if .HosterName}}{{.HosterName}}{{else}}The hosting provider{{end}} warmly invites you to have a look at what is possible here.</p>
{{if .HosterURL}}<p><a href="{{.HosterURL}}">See the offers</a></p>{{end}}
</section>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<meta http-equiv="refresh" content="{{.RefreshSeconds}};url={{.Target}}">
<title>Hinweis / Notice</title>
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
</head>
<body>
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
<section lang="de">
<h1>Kurzer Hinweis</h1>
<p>Der Betreiber dieser Website hat eine Nachricht von seinem Hosting-Anbieter erhalten, um die er sich kümmern sollte.
Die Website ist ganz normal erreichbar – Sie werden in {{.RefreshSeconds}} Sekunden automatisch weitergeleitet.</p>
<p><a href="{{.Target}}">Gleich weiter zur Website</a></p>
</section>
<section lang="en">
<h1>Just a short notice</h1>
<p>The owner of this website has received a message from their hosting provider that needs attention.
The website works as usual – you will be redirected automatically in {{.RefreshSeconds}} seconds.</p>
<p><a href="{{.Target}}">Continue to the website now</a></p>
</section>
</body>
</html>
+4
View File
@@ -66,3 +66,7 @@ func (l License) AllowsDomain(host string) bool {
}
return false
}
// NormalizeDomain prüft und normalisiert einen Eintrag für allowed_domains
// (klein, ohne Punkt am Ende; Wildcard nur vorne, nicht auf einer TLD).
func NormalizeDomain(p string) (string, error) { return normalizePattern(p) }
+9
View File
@@ -55,3 +55,12 @@ func strings63() string {
}
return string(b)
}
func TestNormalizeDomain_Exported(t *testing.T) {
if got, err := NormalizeDomain(" Shop.DE. "); err != nil || got != "shop.de" {
t.Errorf("NormalizeDomain = %q, %v", got, err)
}
if _, err := NormalizeDomain("*.de"); err == nil {
t.Error("*.de akzeptiert")
}
}
+153
View File
@@ -0,0 +1,153 @@
package license
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"math/rand/v2"
"strings"
"time"
)
// Laufzeiten der Leases (Spec 4.1a).
const (
CommunityLease = 14 * 24 * time.Hour
CommunityRenewAfter = 7 * 24 * time.Hour
CommunityRenewJitter = 12 * time.Hour
CommercialLease = 7 * 24 * time.Hour
CommercialMaintenance = 10 * 24 * time.Hour
)
// LeaseTerms sind Erneuerungs- und Ablaufzeitpunkt einer Lease.
type LeaseTerms struct {
RenewAfter time.Time
ValidUntil time.Time
}
// TermsFor berechnet die Lease-Zeiten. Kommerziell: 7 Tage (in Wartung 10),
// Erneuerung nach 2/3, höchstens bis zum Vertragsende; nach Vertragsende
// gibt es keine Lease (ok=false). Community: 14 Tage, Erneuerung nach
// 7 Tagen ± Streuung (jitter liefert einen Wert in [-max, +max]; nil =
// zufällig), Vertragsende ohne Bedeutung. override > 0 setzt eine feste
// Laufzeit (Tests, Betrieb), Erneuerung dann nach 2/3.
func TermsFor(tier Tier, maintenance bool, licenseUntil, now time.Time, override time.Duration,
jitter func(max time.Duration) time.Duration) (LeaseTerms, bool) {
commercial := tier != TierCommunity
if commercial && !now.Before(licenseUntil) {
return LeaseTerms{}, false
}
var t LeaseTerms
switch {
case override > 0:
t.ValidUntil = now.Add(override)
case !commercial:
if jitter == nil {
jitter = randomJitter
}
return LeaseTerms{
ValidUntil: now.Add(CommunityLease),
RenewAfter: now.Add(CommunityRenewAfter + jitter(CommunityRenewJitter)),
}, true
case maintenance:
t.ValidUntil = now.Add(CommercialMaintenance)
default:
t.ValidUntil = now.Add(CommercialLease)
}
if commercial && t.ValidUntil.After(licenseUntil) {
t.ValidUntil = licenseUntil
}
t.RenewAfter = now.Add(t.ValidUntil.Sub(now) * 2 / 3)
return t, true
}
func randomJitter(max time.Duration) time.Duration {
return time.Duration(rand.Int64N(int64(2*max)+1)) - max
}
// Lease ist die signierte, kurzlaufende Kopie der Lizenz für einen Knoten.
type Lease struct {
NodeID string `json:"node_id"`
TenantID string `json:"tenant_id"`
Tier Tier `json:"tier"`
MaxRoutes int `json:"max_routes"`
AllowedDomains []string `json:"allowed_domains"`
Features []Feature `json:"features"`
IssuedAt time.Time `json:"issued_at"`
RenewAfter time.Time `json:"renew_after"`
ValidUntil time.Time `json:"valid_until"`
Signature string `json:"signature"`
}
// Eigenes Präfix: Eine Lease-Signatur gilt nie als Lizenz-Signatur und umgekehrt.
const leaseSignatureVersion = "SULTAN-LEASE-V1\n"
func (l Lease) message() []byte {
c := l
c.Signature = ""
c.IssuedAt, c.RenewAfter, c.ValidUntil = c.IssuedAt.UTC(), c.RenewAfter.UTC(), c.ValidUntil.UTC()
b, _ := json.Marshal(c)
return append([]byte(leaseSignatureVersion), b...)
}
// IssueLease stellt eine Lease für nodeID aus den Werten von l aus.
func (i *Issuer) IssueLease(l License, nodeID string, terms LeaseTerms, now time.Time) (Lease, error) {
if len(i.Key) != ed25519.PrivateKeySize {
return Lease{}, fmt.Errorf("license: Issuer ohne gültigen Master-Key")
}
if strings.TrimSpace(nodeID) == "" {
return Lease{}, fmt.Errorf("%w: node_id fehlt", ErrInvalid)
}
utc := func(t time.Time) time.Time { return t.Round(0).UTC() }
lease := Lease{
NodeID: nodeID,
TenantID: l.TenantID,
Tier: l.Tier,
MaxRoutes: l.MaxRoutes,
AllowedDomains: append([]string{}, l.AllowedDomains...),
Features: append([]Feature{}, l.Features...),
IssuedAt: utc(now),
RenewAfter: utc(terms.RenewAfter),
ValidUntil: utc(terms.ValidUntil),
}
lease.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(i.Key, lease.message()))
return lease, nil
}
// Verify prüft die Signatur mit dem Public Key des Master-Keys.
func (l Lease) Verify(pub ed25519.PublicKey) error {
sig, err := base64.StdEncoding.DecodeString(l.Signature)
if err != nil || len(sig) != ed25519.SignatureSize || len(pub) != ed25519.PublicKeySize {
return ErrInvalidSignature
}
if !ed25519.Verify(pub, l.message(), sig) {
return ErrInvalidSignature
}
return nil
}
// Expired meldet, ob die Lease zum Zeitpunkt now abgelaufen ist.
func (l Lease) Expired(now time.Time) bool { return !now.Before(l.ValidUntil) }
// ParseLease liest und prüft eine Lease (unbekannte Felder sind Fehler).
func ParseLease(data []byte, pub ed25519.PublicKey) (Lease, error) {
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
var l Lease
if err := dec.Decode(&l); err != nil {
return Lease{}, fmt.Errorf("%w: %v", ErrInvalid, err)
}
if l.NodeID == "" || l.ValidUntil.IsZero() {
return Lease{}, fmt.Errorf("%w: node_id oder valid_until fehlt", ErrInvalid)
}
if l.AllowedDomains == nil {
l.AllowedDomains = []string{}
}
if l.Features == nil {
l.Features = []Feature{}
}
if err := l.Verify(pub); err != nil {
return Lease{}, err
}
return l, nil
}
+132
View File
@@ -0,0 +1,132 @@
package license
import (
"encoding/json"
"errors"
"testing"
"time"
)
var t0 = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
const day = 24 * time.Hour
func noJitter(time.Duration) time.Duration { return 0 }
func TestLeaseTerms(t *testing.T) {
far := t0.AddDate(1, 0, 0)
tests := []struct {
name string
tier Tier
maintenance bool
licenseUntil time.Time
valid, renew time.Duration // relativ zu t0
ok bool
}{
{"community", TierCommunity, false, far, 14 * day, 7 * day, true},
{"community ignoriert Vertragsende", TierCommunity, false, t0.Add(day), 14 * day, 7 * day, true},
{"pro", TierPro, false, far, 7 * day, 7 * day * 2 / 3, true},
{"pro in Wartung", TierPro, true, far, 10 * day, 10 * day * 2 / 3, true},
{"enterprise am Vertragsende gekappt", TierEnterprise, false, t0.Add(3 * day), 3 * day, 2 * day, true},
{"Vertrag abgelaufen", TierPro, false, t0, 0, 0, false},
}
for _, tt := range tests {
terms, ok := TermsFor(tt.tier, tt.maintenance, tt.licenseUntil, t0, 0, noJitter)
if ok != tt.ok {
t.Errorf("%s: ok=%v", tt.name, ok)
continue
}
if !ok {
continue
}
if !terms.ValidUntil.Equal(t0.Add(tt.valid)) || !terms.RenewAfter.Equal(t0.Add(tt.renew)) {
t.Errorf("%s: gültig bis +%v, erneuern ab +%v; erwartet +%v / +%v",
tt.name, terms.ValidUntil.Sub(t0), terms.RenewAfter.Sub(t0), tt.valid, tt.renew)
}
}
}
// Community-Erneuerung gestreut (± 12 h), damit nicht alle Agents gleichzeitig erneuern.
func TestLeaseTerms_CommunityJitter(t *testing.T) {
var maxSeen time.Duration
jitter := func(max time.Duration) time.Duration { maxSeen = max; return -5 * time.Hour }
terms, _ := TermsFor(TierCommunity, false, t0.AddDate(1, 0, 0), t0, 0, jitter)
if maxSeen != 12*time.Hour {
t.Errorf("Streuung ±%v, erwartet ±12h", maxSeen)
}
if !terms.RenewAfter.Equal(t0.Add(7*day - 5*time.Hour)) {
t.Errorf("RenewAfter = +%v", terms.RenewAfter.Sub(t0))
}
// Ohne explizite Funktion: echte Streuung, aber innerhalb der Grenzen.
for range 100 {
terms, _ := TermsFor(TierCommunity, false, t0.AddDate(1, 0, 0), t0, 0, nil)
if d := terms.RenewAfter.Sub(t0); d < 7*day-12*time.Hour || d > 7*day+12*time.Hour {
t.Fatalf("Streuung außerhalb: +%v", d)
}
}
}
// Override (Tests, Betrieb): feste Laufzeit, Erneuerung nach 2/3, Vertragsende gilt weiter.
func TestLeaseTerms_Override(t *testing.T) {
terms, ok := TermsFor(TierCommunity, false, t0.AddDate(1, 0, 0), t0, 300*time.Millisecond, noJitter)
if !ok || !terms.ValidUntil.Equal(t0.Add(300*time.Millisecond)) || !terms.RenewAfter.Equal(t0.Add(200*time.Millisecond)) {
t.Errorf("Override: %+v", terms)
}
}
func TestIssueAndParseLease(t *testing.T) {
iss, pub := newIssuer(t)
lic := sample()
terms := LeaseTerms{RenewAfter: t0.Add(4 * day), ValidUntil: t0.Add(7 * day)}
l, err := iss.IssueLease(lic, "node-1", terms, t0)
if err != nil {
t.Fatalf("IssueLease: %v", err)
}
if l.NodeID != "node-1" || l.TenantID != lic.TenantID || !l.IssuedAt.Equal(t0) || !l.ValidUntil.Equal(terms.ValidUntil) ||
l.MaxRoutes != lic.MaxRoutes || len(l.AllowedDomains) != 2 {
t.Errorf("Lease: %+v", l)
}
doc, _ := json.Marshal(l)
parsed, err := ParseLease(doc, pub)
if err != nil {
t.Fatalf("ParseLease: %v", err)
}
if parsed.NodeID != "node-1" || !parsed.ValidUntil.Equal(terms.ValidUntil) {
t.Errorf("geparst: %+v", parsed)
}
if !l.Expired(terms.ValidUntil) || l.Expired(terms.ValidUntil.Add(-time.Second)) {
t.Error("Expired falsch")
}
for name, tamper := range map[string]func(*Lease){
"Knoten": func(l *Lease) { l.NodeID = "node-2" },
"Laufzeit": func(l *Lease) { l.ValidUntil = l.ValidUntil.Add(time.Hour) },
"Domains": func(l *Lease) { l.AllowedDomains = append([]string(nil), "bank.de") },
"Erneuerung": func(l *Lease) { l.RenewAfter = l.RenewAfter.Add(time.Hour) },
} {
c := l
tamper(&c)
if err := c.Verify(pub); !errors.Is(err, ErrInvalidSignature) {
t.Errorf("%s manipuliert: %v", name, err)
}
}
}
// Domänentrennung: Eine Lease ist keine Lizenz und umgekehrt.
func TestLeaseIsNotALicense(t *testing.T) {
iss, pub := newIssuer(t)
lic, _ := iss.Issue(sample())
lease, _ := iss.IssueLease(sample(), "node-1", LeaseTerms{RenewAfter: t0.Add(day), ValidUntil: t0.Add(2 * day)}, t0)
licDoc, _ := json.Marshal(lic)
if _, err := ParseLease(licDoc, pub); err == nil {
t.Error("Lizenz als Lease akzeptiert")
}
asLicense := License{
TenantID: lease.TenantID, Tier: lease.Tier, MaxRoutes: lease.MaxRoutes, AllowedDomains: lease.AllowedDomains,
Features: lease.Features, ValidUntil: lease.ValidUntil, Signature: lease.Signature,
}
if err := asLicense.Verify(pub); !errors.Is(err, ErrInvalidSignature) {
t.Error("Lease-Signatur als Lizenz-Signatur akzeptiert")
}
}
+111
View File
@@ -0,0 +1,111 @@
// Package notify verschickt Benachrichtigungen des Servers (Fristen,
// Mahnstufen, Offline-Alarme, Policy-Verstöße) an Webhooks: Slack-kompatibel
// (auch Mattermost, Rocket.Chat, Discord-Slack-Endpunkt) und ntfy.
package notify
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Severity ist die Dringlichkeit einer Nachricht.
type Severity int
const (
Info Severity = iota
Warning
Critical
)
// Message ist eine Benachrichtigung; Body darf Slack-Markdown enthalten.
type Message struct {
Title string
Body string
Severity Severity
}
// Target ist ein Benachrichtigungsziel.
type Target interface {
Send(ctx context.Context, m Message) error
}
// ErrNoTargets: Multi ohne Ziele.
var ErrNoTargets = errors.New("notify: keine Ziele konfiguriert")
var client = &http.Client{Timeout: 10 * time.Second}
func post(ctx context.Context, url, contentType string, body []byte, headers map[string]string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", contentType)
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("notify: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return fmt.Errorf("notify: %s antwortet %d: %s", url, resp.StatusCode, strings.TrimSpace(string(msg)))
}
return nil
}
var emoji = map[Severity]string{Info: ":information_source:", Warning: ":warning:", Critical: ":rotating_light:"}
// Slack sendet an einen Slack-kompatiblen Incoming Webhook ({"text": …}).
type Slack struct{ URL string }
func (s Slack) Send(ctx context.Context, m Message) error {
body, _ := json.Marshal(map[string]string{"text": fmt.Sprintf("%s *%s*\n%s", emoji[m.Severity], m.Title, m.Body)})
return post(ctx, s.URL, "application/json", body, nil)
}
var ntfyPriority = map[Severity][2]string{
Info: {"default", "information_source"},
Warning: {"high", "warning"},
Critical: {"urgent", "rotating_light"},
}
// Ntfy sendet an ein ntfy-Topic (URL inkl. Topic), optional mit Access-Token.
type Ntfy struct {
URL string
Token string
}
func (n Ntfy) Send(ctx context.Context, m Message) error {
p := ntfyPriority[m.Severity]
headers := map[string]string{"Title": m.Title, "Priority": p[0], "Tags": p[1], "Markdown": "yes"}
if n.Token != "" {
headers["Authorization"] = "Bearer " + n.Token
}
return post(ctx, n.URL, "text/markdown; charset=utf-8", []byte(m.Body), headers)
}
// Multi sendet an alle Ziele; Fehler einzelner Ziele werden gesammelt
// gemeldet, halten die übrigen aber nicht auf.
type Multi []Target
func (m Multi) Send(ctx context.Context, msg Message) error {
if len(m) == 0 {
return ErrNoTargets
}
var errs []error
for _, t := range m {
if err := t.Send(ctx, msg); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
+133
View File
@@ -0,0 +1,133 @@
package notify
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
type received struct {
mu sync.Mutex
reqs []*http.Request
body []string
}
func (r *received) handler(status int) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
b, _ := io.ReadAll(req.Body)
r.mu.Lock()
r.reqs = append(r.reqs, req)
r.body = append(r.body, string(b))
r.mu.Unlock()
w.WriteHeader(status)
}
}
var msg = Message{
Title: "Vertrag kunde läuft ab",
Body: "Noch *14 Tage* bis 2026-12-15.",
Severity: Critical,
}
func TestSlack(t *testing.T) {
var got received
srv := httptest.NewServer(got.handler(200))
defer srv.Close()
if err := (Slack{URL: srv.URL}).Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
if ct := got.reqs[0].Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type %q", ct)
}
var payload map[string]string
if err := json.Unmarshal([]byte(got.body[0]), &payload); err != nil {
t.Fatal(err)
}
text := payload["text"]
if !strings.Contains(text, "*Vertrag kunde läuft ab*") || !strings.Contains(text, "Noch *14 Tage*") || !strings.Contains(text, ":rotating_light:") {
t.Errorf("text = %q", text)
}
}
func TestNtfy(t *testing.T) {
var got received
srv := httptest.NewServer(got.handler(200))
defer srv.Close()
if err := (Ntfy{URL: srv.URL + "/sultan", Token: "tk_geheim"}).Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
r := got.reqs[0]
if r.URL.Path != "/sultan" || got.body[0] != msg.Body {
t.Errorf("Pfad %q, Body %q", r.URL.Path, got.body[0])
}
for h, want := range map[string]string{
"Title": "Vertrag kunde läuft ab",
"Priority": "urgent",
"Tags": "rotating_light",
"Markdown": "yes",
"Authorization": "Bearer tk_geheim",
} {
if r.Header.Get(h) != want {
t.Errorf("%s = %q, erwartet %q", h, r.Header.Get(h), want)
}
}
}
func TestSeverityMapping(t *testing.T) {
for sev, want := range map[Severity][2]string{
Info: {"default", "information_source"},
Warning: {"high", "warning"},
Critical: {"urgent", "rotating_light"},
} {
var got received
srv := httptest.NewServer(got.handler(200))
(Ntfy{URL: srv.URL}).Send(context.Background(), Message{Title: "x", Body: "y", Severity: sev})
srv.Close()
if got.reqs[0].Header.Get("Priority") != want[0] || got.reqs[0].Header.Get("Tags") != want[1] {
t.Errorf("%v: Priority %q Tags %q", sev, got.reqs[0].Header.Get("Priority"), got.reqs[0].Header.Get("Tags"))
}
if got.reqs[0].Header.Get("Authorization") != "" {
t.Error("Authorization ohne Token gesetzt")
}
}
}
func TestErrors(t *testing.T) {
var got received
bad := httptest.NewServer(got.handler(500))
defer bad.Close()
for _, target := range []Target{Slack{URL: bad.URL}, Ntfy{URL: bad.URL}, Slack{URL: "http://127.0.0.1:1"}} {
if err := target.Send(context.Background(), msg); err == nil {
t.Errorf("%T: erwartet Fehler", target)
}
}
}
// Multi sendet an alle Ziele; ein Fehler bei einem Ziel hält die anderen nicht auf.
func TestMulti(t *testing.T) {
var a, b received
okA := httptest.NewServer(a.handler(200))
okB := httptest.NewServer(b.handler(200))
bad := httptest.NewServer((&received{}).handler(500))
defer okA.Close()
defer okB.Close()
defer bad.Close()
m := Multi{Slack{URL: okA.URL}, Ntfy{URL: bad.URL}, Ntfy{URL: okB.URL}}
err := m.Send(context.Background(), msg)
if err == nil {
t.Error("Fehler eines Ziels nicht gemeldet")
}
if len(a.reqs) != 1 || len(b.reqs) != 1 {
t.Errorf("nicht alle Ziele erreicht: %d/%d", len(a.reqs), len(b.reqs))
}
if !errors.Is(Multi{}.Send(context.Background(), msg), ErrNoTargets) {
t.Error("leeres Multi: erwartet ErrNoTargets")
}
}
+56 -3
View File
@@ -21,6 +21,7 @@ package policy
import (
"errors"
"fmt"
"net/url"
"regexp"
"slices"
"strings"
@@ -61,8 +62,23 @@ const (
HeaderStage = "X-Sultan-Stage"
HeaderTenant = "X-Sultan-Tenant"
HeaderTier = "X-Sultan-Tier" // z. B. für den Pro-Hinweis auf Community-Wartungsseiten
// HeaderUntil: voraussichtliches Wartungsende (RFC 3339), nur bei befristeter Wartung.
HeaderUntil = "X-Sultan-Until"
// HeaderPublicReason: öffentlicher Wartungsgrund, URL-kodiert (Header sind
// einzeilig; Umlaute kommen so unverändert beim Lander an).
HeaderPublicReason = "X-Sultan-Public-Reason"
)
// Maintenance ist eine angemeldete Wartung (Knoten oder Dienst).
type Maintenance struct {
Until time.Time // Nullwert = unbefristet
PublicReason string
}
func (m *Maintenance) active(now time.Time) bool {
return m != nil && (m.Until.IsZero() || now.Before(m.Until))
}
// 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
@@ -124,6 +140,14 @@ type Input struct {
// DownServices: Services (Originalnamen), die der Healthcheck des
// Agenten als ausgefallen meldet.
DownServices map[string]bool
// Maintenance: Wartung des ganzen Knotens (nil = keine).
Maintenance *Maintenance
// ServiceMaintenance: Wartung einzelner Services (Originalnamen), z. B.
// per Label sultan.maintenance=true.
ServiceMaintenance map[string]Maintenance
// Disconnected: Der Knoten ist ungeplant getrennt, seine Lease gilt aber
// noch – Besucher sehen die Wartungsseite (ohne Ende und Grund).
Disconnected bool
}
// ViolationKind klassifiziert einen Verstoß.
@@ -202,7 +226,8 @@ 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):
// Community wird automatisch neu lizenziert: nur der explizite Status zählt.
case t.Status == store.TenantExpired || (l.Tier != license.TierCommunity && l.Expired(now)):
return StageLanding
case l.Tier == license.TierCommunity:
return StageNormal // kein Mahnwesen im Community-Betrieb
@@ -226,7 +251,8 @@ func (e *Engine) Apply(in Input) (Result, error) {
if e.now != nil {
now = e.now
}
stage := StageFor(in.Tenant, in.License, now())
at := now()
stage := StageFor(in.Tenant, in.License, at)
res := Result{Config: &traefik.Configuration{}, Stage: stage}
if stage == StageBlocked || in.NodeDrained || in.Config == nil || in.Config.HTTP == nil {
return res, nil
@@ -289,8 +315,11 @@ func (e *Engine) Apply(in Input) (Result, error) {
out.Services[nr.Service] = copyService(src.Services[r.Service])
}
// Vorrang: Ablauf/Paywall > Wartung (Knoten, Dienst, Healthcheck) >
// Mahnhinweis > normal. (Sperre und drain liefern oben gar keine Routen.)
routerStage := stage
if routerStage == StageNormal && in.DownServices[r.Service] {
maint, inMaint := maintenanceFor(in, r.Service, at)
if inMaint && (stage == StageNormal || slices.Contains(ackStages, stage)) {
routerStage = StageMaintenance
}
if routerStage != StageNormal {
@@ -319,6 +348,14 @@ func (e *Engine) Apply(in Input) (Result, error) {
HeaderTenant: in.Tenant.ID,
HeaderTier: string(in.License.Tier),
}
if routerStage == StageMaintenance {
if !maint.Until.IsZero() {
headers[HeaderUntil] = maint.Until.UTC().Format(time.RFC3339)
}
if maint.PublicReason != "" {
headers[HeaderPublicReason] = url.QueryEscape(maint.PublicReason)
}
}
mwName := "sultan__" + prefix + name
out.Middlewares[mwName] = traefik.Middleware{"headers": map[string]any{"customRequestHeaders": headers}}
nr.Middlewares = append(slices.Clone(nr.Middlewares), mwName)
@@ -336,6 +373,22 @@ func (e *Engine) Apply(in Input) (Result, error) {
return res, nil
}
// maintenanceFor liefert die geltende Wartung eines Services: zuerst die des
// Knotens, dann die des Services, dann ein Healthcheck-Ausfall oder ein
// getrennter Knoten (beides ohne Grund).
func maintenanceFor(in Input, service string, now time.Time) (Maintenance, bool) {
if in.Maintenance.active(now) {
return *in.Maintenance, true
}
if m, ok := in.ServiceMaintenance[service]; ok && m.active(now) {
return m, true
}
if in.DownServices[service] || in.Disconnected {
return Maintenance{}, true
}
return Maintenance{}, false
}
func validateInput(in Input) error {
switch {
case !ValidID(in.Tenant.ID) || in.Tenant.ID == "sultan":
+143 -1
View File
@@ -2,6 +2,7 @@ package policy
import (
"errors"
"net/url"
"reflect"
"testing"
"time"
@@ -296,7 +297,10 @@ func TestStageFor(t *testing.T) {
}{
{"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},
// Community wird automatisch neu lizenziert (5c): kein Ablauf über valid_until …
{"Community mit altem valid_until", license.TierCommunity, store.TenantActive, 0, true, StageNormal},
// … nur der explizite Status expired führt zur Landingpage.
{"Community explizit abgelaufen", license.TierCommunity, store.TenantExpired, 0, false, 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},
@@ -585,3 +589,141 @@ func TestValidID(t *testing.T) {
}
}
}
func headersOf(t *testing.T, res Result, router string) map[string]any {
t.Helper()
mw := res.Config.HTTP.Middlewares["sultan__kunde__node-1__"+router]
if mw == nil {
t.Fatalf("keine Interceptor-Middleware für %s", router)
}
return mw["headers"].(map[string]any)["customRequestHeaders"].(map[string]any)
}
func TestApply_NodeMaintenance(t *testing.T) {
in := input(t, wpLabels())
until := now.Add(2 * time.Hour)
in.Maintenance = &Maintenance{Until: until, PublicReason: "Sicherheitsupdate (CVE-1234) – Sicherheit geht vor."}
res, err := engine().Apply(in)
if err != nil {
t.Fatal(err)
}
if res.Config.HTTP.Routers["kunde__node-1__wp"].Service != InterceptorService {
t.Fatal("Knoten in Wartung nicht zum Interceptor umgeleitet")
}
h := headersOf(t, res, "wp")
if h[HeaderStage] != string(StageMaintenance) || h[HeaderUntil] != until.Format(time.RFC3339) {
t.Errorf("Header: %v", h)
}
// Öffentlicher Grund URL-kodiert (Header sind einzeilig, Traefik reicht Bytes nicht zuverlässig durch).
if h[HeaderPublicReason] != url.QueryEscape("Sicherheitsupdate (CVE-1234) – Sicherheit geht vor.") {
t.Errorf("öffentlicher Grund: %v", h[HeaderPublicReason])
}
}
func TestApply_NodeMaintenanceUnlimitedAndExpired(t *testing.T) {
in := input(t, wpLabels())
in.Maintenance = &Maintenance{} // unbefristet, ohne Grund
res, _ := engine().Apply(in)
h := headersOf(t, res, "wp")
if _, ok := h[HeaderUntil]; ok {
t.Error("unbefristete Wartung mit Ende-Header")
}
if _, ok := h[HeaderPublicReason]; ok {
t.Error("Wartung ohne öffentlichen Grund mit Grund-Header")
}
in.Maintenance = &Maintenance{Until: now} // gerade abgelaufen
res, _ = engine().Apply(in)
if res.Config.HTTP.Routers["kunde__node-1__wp"].Service == InterceptorService {
t.Error("abgelaufene Wartung leitet noch um")
}
}
func TestApply_ServiceMaintenance(t *testing.T) {
labels := wpLabels()
labels["traefik.http.routers.wp.service"] = "wp"
labels["traefik.http.routers.shop.rule"] = "Host(`shop.kunde.de`)"
labels["traefik.http.routers.shop.service"] = "shop"
labels["traefik.http.services.shop.loadbalancer.server.port"] = "8080"
in := input(t, labels)
in.ServiceMaintenance = map[string]Maintenance{"shop": {PublicReason: "Inventur"}}
res, _ := engine().Apply(in)
if res.Config.HTTP.Routers["kunde__node-1__shop"].Service != InterceptorService {
t.Error("Dienst in Wartung nicht umgeleitet")
}
if res.Config.HTTP.Routers["kunde__node-1__wp"].Service == InterceptorService {
t.Error("anderer Dienst wurde umgeleitet")
}
if h := headersOf(t, res, "shop"); h[HeaderPublicReason] != "Inventur" {
t.Errorf("Header: %v", h)
}
}
// Vorrang: Sperre/drain > Ablauf/Paywall > Wartung > Mahnhinweis > normal.
func TestApply_MaintenancePrecedence(t *testing.T) {
tests := []struct {
name string
mutate func(*Input)
stage Stage // Stufe am Router; "" = keine Routen
ackSeen bool
}{
{"Wartung schlägt Mahnstufe 1", func(in *Input) { in.Tenant.DunningLevel = 1 }, StageMaintenance, false},
{"Paywall schlägt Wartung", func(in *Input) { in.Tenant.Status = store.TenantSuspendedPayment }, StagePaywall, false},
{"Ablauf schlägt Wartung", func(in *Input) { in.Tenant.Status = store.TenantExpired }, StageLanding, false},
{"drain schlägt Wartung", func(in *Input) { in.NodeDrained = true }, "", false},
{"Quarantäne schlägt Wartung", func(in *Input) { in.Tenant.Status = store.TenantQuarantined }, "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
in := input(t, wpLabels())
in.Maintenance = &Maintenance{}
tt.mutate(&in)
res, err := engine().Apply(in)
if err != nil {
t.Fatal(err)
}
if tt.stage == "" {
if res.Config.HTTP != nil {
t.Errorf("erwartet keine Routen: %v", keys(res.Config.HTTP.Routers))
}
return
}
if h := headersOf(t, res, "wp"); h[HeaderStage] != string(tt.stage) {
t.Errorf("Stufe %v, erwartet %s", h[HeaderStage], tt.stage)
}
if _, ack := res.Config.HTTP.Routers["sultan__kunde__node-1__wp"]; ack != tt.ackSeen {
t.Errorf("Durchlass-Router vorhanden=%v", ack)
}
})
}
}
// Ungeplant getrennt (Lease noch gültig): Wartungsseite ohne Ende und ohne Grund.
func TestApply_DisconnectedNode(t *testing.T) {
in := input(t, wpLabels())
in.Disconnected = true
res, _ := engine().Apply(in)
h := headersOf(t, res, "wp")
if h[HeaderStage] != string(StageMaintenance) {
t.Errorf("Stufe %v", h[HeaderStage])
}
if _, ok := h[HeaderUntil]; ok {
t.Error("getrennter Knoten mit Ende-Header")
}
// Angemeldete Wartung hat Vorrang (deren Grund/Ende wird gezeigt).
in.Maintenance = &Maintenance{PublicReason: "Reboot nach Update"}
res, _ = engine().Apply(in)
if h := headersOf(t, res, "wp"); h[HeaderPublicReason] != url.QueryEscape("Reboot nach Update") {
t.Errorf("Grund der Wartung fehlt: %v", h)
}
// Paywall bleibt Paywall.
in.Maintenance = nil
in.Tenant.Status = store.TenantSuspendedPayment
res, _ = engine().Apply(in)
if h := headersOf(t, res, "wp"); h[HeaderStage] != string(StagePaywall) {
t.Errorf("Paywall verdrängt: %v", h[HeaderStage])
}
}
+38
View File
@@ -16,6 +16,8 @@ import (
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"sync"
"sultan/pkg/license"
@@ -57,8 +59,19 @@ type NodeContext struct {
Tenant policy.Tenant
License license.License
Drained bool
Maintenance *policy.Maintenance // Wartung des Knotens (nil = keine)
// Disconnected: Knoten getrennt, Routen werden (noch) gehalten – sie
// zeigen die Wartungsseite.
Disconnected bool
}
// Labels, mit denen ein Workload (Container, sultan.d-Dienst) seine Services
// in Wartung schickt.
const (
LabelMaintenance = "sultan.maintenance"
LabelMaintenancePublicReason = "sultan.maintenance.public_reason"
)
// Directory liefert den NodeContext; für gelöschte/unbekannte Knoten
// ErrUnknownNode (ggf. verpackt).
type Directory interface {
@@ -207,6 +220,7 @@ func (r *Registry) evaluate(ctx context.Context, nodeID string, state NodeState)
workloads := slices.Clone(state.Workloads)
slices.SortStableFunc(workloads, func(a, b Workload) int { return cmp.Compare(a.Name, b.Name) })
merged := &traefik.Configuration{}
serviceMaint := map[string]policy.Maintenance{}
seen := map[string]bool{}
for _, w := range workloads {
if seen[w.Name] {
@@ -225,6 +239,11 @@ func (r *Registry) evaluate(ctx context.Context, nodeID string, state NodeState)
continue
}
merged = m
if maint, ok := workloadMaintenance(w.Labels); ok && c.HTTP != nil {
for svc := range c.HTTP.Services {
serviceMaint[svc] = maint
}
}
}
down := map[string]bool{}
@@ -238,6 +257,9 @@ func (r *Registry) evaluate(ctx context.Context, nodeID string, state NodeState)
NodeDrained: nc.Drained,
Config: merged,
DownServices: down,
Maintenance: nc.Maintenance,
ServiceMaintenance: serviceMaint,
Disconnected: nc.Disconnected,
})
if err != nil {
return nil, err
@@ -249,6 +271,22 @@ func (r *Registry) evaluate(ctx context.Context, nodeID string, state NodeState)
return entry, nil
}
// workloadMaintenance liest sultan.maintenance(=true) und den optionalen
// öffentlichen Grund; Schlüssel ohne Beachtung der Groß-/Kleinschreibung.
func workloadMaintenance(labels map[string]string) (policy.Maintenance, bool) {
var on bool
var m policy.Maintenance
for k, v := range labels {
switch strings.ToLower(k) {
case LabelMaintenance:
on, _ = strconv.ParseBool(strings.TrimSpace(v))
case LabelMaintenancePublicReason:
m.PublicReason = strings.TrimSpace(v)
}
}
return m, on
}
// store übernimmt entry und meldet nur Verstöße, die vorher nicht bestanden.
func (r *Registry) store(nodeID string, entry *nodeEntry) {
if r.nodes == nil {
+43
View File
@@ -398,3 +398,46 @@ func TestRegistry_Concurrent(t *testing.T) {
t.Errorf("%d Router, erwartet 20", n)
}
}
// Container/Dienst in Wartung per Label: nur dessen Router gehen zum Lander.
func TestRegistry_ServiceMaintenanceLabel(t *testing.T) {
r, _, _ := newRegistry()
shop := wp("shop", "100.64.0.1", "shop.kunde.de")
shop.Labels["traefik.http.routers.wp.rule"] = "Host(`shop.kunde.de`)"
shop.Labels = map[string]string{
"traefik.enable": "true",
"traefik.http.routers.shop.rule": "Host(`shop.kunde.de`)",
"traefik.http.services.shop.loadbalancer.server.port": "8080",
"sultan.maintenance": "true",
"sultan.maintenance.public_reason": "Inventur",
}
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de"), shop}})
cfg := r.Config().HTTP
if cfg.Routers["kunde__n1__shop"].Service != policy.InterceptorService {
t.Error("Dienst mit sultan.maintenance=true nicht umgeleitet")
}
if cfg.Routers["kunde__n1__wp"].Service == policy.InterceptorService {
t.Error("anderer Dienst wurde umgeleitet")
}
hdr := cfg.Middlewares["sultan__kunde__n1__shop"]["headers"].(map[string]any)["customRequestHeaders"].(map[string]any)
if hdr[policy.HeaderPublicReason] != "Inventur" {
t.Errorf("öffentlicher Grund: %v", hdr)
}
shop.Labels["sultan.maintenance"] = "false"
r.Update(ctx, "n1", NodeState{Workloads: []Workload{shop}})
if r.Config().HTTP.Routers["kunde__n1__shop"].Service == policy.InterceptorService {
t.Error("sultan.maintenance=false leitet um")
}
}
func TestRegistry_NodeMaintenanceFromDirectory(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.Maintenance = &policy.Maintenance{Until: time.Now().Add(time.Hour)} })
r.Refresh(ctx)
if r.Config().HTTP.Routers["kunde__n1__wp"].Service != policy.InterceptorService {
t.Error("Knotenwartung aus dem Directory nicht angewendet")
}
}
+31 -8
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"time"
"sultan/pkg/license"
"sultan/pkg/policy"
@@ -14,8 +15,8 @@ import (
// 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.
// Die Lizenz wird aus dem Mandanten-Datensatz abgeleitet (inkl. Features);
// die Mahnstufe ergibt sich aus der offenen Zahlung (DunningSchedule).
type directory struct {
store *store.Store
}
@@ -36,15 +37,37 @@ func (d *directory) NodeContext(ctx context.Context, nodeID string) (registry.No
return registry.NodeContext{}, err
}
return registry.NodeContext{
Tenant: policy.Tenant{ID: t.ID, Status: t.Status},
License: license.License{
Tenant: policy.Tenant{
ID: t.ID,
Status: t.Status,
DunningLevel: policy.DunningSchedule{}.Level(t.PaymentOverdueSince, time.Now()),
},
License: licenseOf(t),
Drained: node.Status == store.NodeDrained,
Maintenance: maintenanceOf(node),
Disconnected: node.Status == store.NodeOffline,
}, nil
}
// licenseOf leitet die (unsignierte) Lizenz aus dem Mandanten-Datensatz ab.
func licenseOf(t store.Tenant) license.License {
features := make([]license.Feature, 0, len(t.Features))
for _, f := range t.Features {
features = append(features, license.Feature(f))
}
return license.License{
TenantID: t.ID,
Tier: license.Tier(t.Tier),
MaxRoutes: t.MaxRoutes,
AllowedDomains: t.AllowedDomains,
Features: []license.Feature{},
Features: features,
ValidUntil: t.ValidUntil,
},
Drained: node.Status == store.NodeDrained,
}, nil
}
}
func maintenanceOf(n store.Node) *policy.Maintenance {
if n.Maintenance == nil {
return nil
}
return &policy.Maintenance{Until: n.Maintenance.Until, PublicReason: n.Maintenance.PublicReason}
}
+118
View File
@@ -3,9 +3,12 @@ package server
import (
"encoding/json"
"errors"
"math"
"net"
"net/http"
"strconv"
"strings"
"time"
"sultan/pkg/auth"
"sultan/pkg/policy"
@@ -28,17 +31,40 @@ type EnrollResponse struct {
MasterPublicKey string `json:"master_public_key"` // zum Prüfen von Lizenzen/Leases
}
// MaintenanceRequest ist der Body von POST /api/v1/maintenance: Der Knoten
// meldet Wartung an oder ab (z. B. aus einem Patch-/Reboot-Skript).
type MaintenanceRequest struct {
Enabled bool `json:"enabled"`
Duration string `json:"duration,omitempty"` // Go-Dauer; "" = 4h, "0" = unbefristet
Reason string `json:"reason,omitempty"` // intern
PublicReason string `json:"public_reason,omitempty"`
}
// RotateKeyRequest ist der Body von POST /api/v1/rotate-key. Der Request ist
// mit dem bisherigen Schlüssel signiert; Proof (auth.RotationProof) beweist
// den Besitz des neuen.
type RotateKeyRequest struct {
PublicKey string `json:"public_key"`
Proof string `json:"proof"`
}
// DefaultMaintenance ist die Wartungsdauer ohne Angabe.
const DefaultMaintenance = 4 * time.Hour
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)
mux.HandleFunc("POST /api/v1/maintenance", s.handleMaintenance)
mux.HandleFunc("POST /api/v1/rotate-key", s.handleRotateKey)
return mux
}
func (s *Server) providerRoutes() http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /v1/traefik-config", s.registry.Handler())
mux.HandleFunc("GET /metrics/checkmk", s.handleCheckmk)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("ok\n"))
})
@@ -46,6 +72,13 @@ func (s *Server) providerRoutes() http.Handler {
}
func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
// Vor der Signaturprüfung: Fluten kosten so kaum etwas.
if ok, retry := s.enrollRL.allow(remoteIP(r)); !ok {
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(retry.Seconds()))))
s.log.Warn("Enrollment gebremst (zu viele Versuche)", "ip", remoteIP(r))
http.Error(w, "zu viele Versuche", http.StatusTooManyRequests)
return
}
pub, err := s.verifier.VerifyUnregistered(r)
if err != nil {
authError(w, err)
@@ -144,6 +177,91 @@ func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleMaintenance(w http.ResponseWriter, r *http.Request) {
id, err := s.verifier.Verify(r)
if err != nil {
authError(w, err)
return
}
var req MaintenanceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger Body", http.StatusBadRequest)
return
}
if !req.Enabled {
err = s.store.ClearNodeMaintenance(r.Context(), id.Node.ID)
} else {
d := DefaultMaintenance
if req.Duration != "" {
if d, err = time.ParseDuration(req.Duration); err != nil || d < 0 {
http.Error(w, "duration ungültig (z. B. 30m, 4h; 0 = unbefristet)", http.StatusBadRequest)
return
}
}
m := store.Maintenance{Reason: req.Reason, PublicReason: req.PublicReason}
if d > 0 {
m.Until = time.Now().Add(d)
}
err = s.store.SetNodeMaintenance(r.Context(), id.Node.ID, m)
}
switch {
case errors.Is(err, store.ErrInvalid):
http.Error(w, err.Error(), http.StatusBadRequest)
return
case err != nil:
s.log.Error("Wartung setzen", "node", id.Node.ID, "err", err)
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
if req.Enabled {
s.log.Info("Wartung angemeldet", "node", id.Node.ID, "duration", req.Duration, "reason", req.Reason,
"public_reason", req.PublicReason)
} else {
s.log.Info("Wartung beendet", "node", id.Node.ID)
}
// Sofort wirksam, nicht erst beim nächsten Refresh.
if err := s.registry.Refresh(r.Context()); err != nil {
s.log.Error("Neubewertung nach Wartungsänderung", "err", err)
}
w.WriteHeader(http.StatusNoContent)
}
// handleRotateKey tauscht den Schlüssel eines Knotens. Idempotent: Ist der
// neue Schlüssel schon aktiv (Wiederholung nach einem Abbruch, signiert mit
// dem neuen Schlüssel), antwortet er ebenfalls mit 204.
func (s *Server) handleRotateKey(w http.ResponseWriter, r *http.Request) {
id, err := s.verifier.Verify(r)
if err != nil {
authError(w, err)
return
}
var req RotateKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger Body", http.StatusBadRequest)
return
}
pub, err := auth.DecodePublicKey(req.PublicKey)
if err != nil || !auth.VerifyRotationProof(id.Node.ID, pub, req.Proof) {
http.Error(w, "Besitznachweis für den neuen Schlüssel ungültig", http.StatusBadRequest)
return
}
encoded := auth.EncodePublicKey(pub)
if encoded != id.Node.PublicKey {
err := s.store.UpdateNodePublicKey(r.Context(), id.Node.ID, encoded)
switch {
case errors.Is(err, store.ErrExists):
http.Error(w, "Schlüssel bereits vergeben", http.StatusConflict)
return
case err != nil:
s.log.Error("Schlüssel tauschen", "node", id.Node.ID, "err", err)
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
s.log.Info("Knotenschlüssel gewechselt", "node", id.Node.ID)
}
w.WriteHeader(http.StatusNoContent)
}
func authError(w http.ResponseWriter, err error) {
code := auth.StatusCode(err)
msg := http.StatusText(code)
+229
View File
@@ -0,0 +1,229 @@
package server
import (
"context"
"fmt"
"net/http"
"time"
"sultan/pkg/checkmk"
"sultan/pkg/notify"
"sultan/pkg/policy"
"sultan/pkg/store"
)
// retention: Aufbewahrung von Einladungen, abgelaufenen Leases und
// zugestellten Befehlen (entspricht der Community-Lease-Laufzeit).
const retention = 10 * 24 * time.Hour
func commercial(t store.Tenant) bool { return t.Tier != store.TierCommunity }
// runJobs läuft bei jedem Refresh: Vertragsablauf, Benachrichtigungen,
// Aufräumen.
func (s *Server) runJobs(ctx context.Context) {
now := time.Now()
tenants, err := s.store.ListTenants(ctx)
if err != nil {
s.log.Error("Job: Mandanten lesen", "err", err)
return
}
for _, t := range tenants {
s.expireContract(ctx, t, now)
if s.notifier != nil && commercial(t) {
s.notifyContract(ctx, t, now)
s.notifyDunning(ctx, t, now)
}
}
if s.notifier != nil {
s.notifyOffline(ctx, now)
}
s.housekeeping(ctx)
}
// expireContract setzt kommerzielle Verträge ab dem Ablaufzeitpunkt dauerhaft
// auf expired (Community wird automatisch neu lizenziert).
func (s *Server) expireContract(ctx context.Context, t store.Tenant, now time.Time) {
if !commercial(t) || t.Status == store.TenantExpired || t.Status == store.TenantQuarantined || now.Before(t.ValidUntil) {
return
}
if err := s.store.SetTenantStatus(ctx, t.ID, store.TenantExpired); err != nil {
s.log.Error("Job: Vertrag auf expired setzen", "tenant", t.ID, "err", err)
return
}
s.log.Warn("Vertrag abgelaufen – Mandant auf expired gesetzt", "tenant", t.ID, "valid_until", t.ValidUntil)
}
func (s *Server) notifyContract(ctx context.Context, t store.Tenant, now time.Time) {
day := t.ValidUntil.UTC().Format(time.DateOnly)
days := int(t.ValidUntil.Sub(now).Hours() / 24)
switch {
case t.Status == store.TenantExpired || !now.Before(t.ValidUntil):
s.sendOnce(ctx, "contract:"+t.ID+":"+day+":0", notify.Message{
Severity: notify.Critical,
Title: "Vertrag " + t.ID + " abgelaufen",
Body: fmt.Sprintf("Der Vertrag von *%s* ist am %s abgelaufen. Die Seiten zeigen die Landingpage.", t.ID, day),
})
case days <= checkmk.ContractCritDays:
s.sendOnce(ctx, "contract:"+t.ID+":"+day+":14", notify.Message{
Severity: notify.Critical,
Title: fmt.Sprintf("Vertrag %s läuft in %d Tagen ab", t.ID, days),
Body: fmt.Sprintf("Vertragsende für *%s*: %s. Verlängerung erforderlich.", t.ID, day),
})
case days <= checkmk.ContractWarnDays:
s.sendOnce(ctx, "contract:"+t.ID+":"+day+":30", notify.Message{
Severity: notify.Warning,
Title: fmt.Sprintf("Vertrag %s läuft in %d Tagen ab", t.ID, days),
Body: fmt.Sprintf("Vertragsende für *%s*: %s.", t.ID, day),
})
}
}
func (s *Server) notifyDunning(ctx context.Context, t store.Tenant, now time.Time) {
level := policy.DunningSchedule{}.Level(t.PaymentOverdueSince, now)
if level == 0 {
return
}
sev := notify.Warning
if level >= 2 {
sev = notify.Critical
}
text := map[int]string{1: "Hinweisseite mit 5 s Weiterleitung", 2: "Hinweisseite mit 10 s Weiterleitung", 3: "Kunde offline (Einladungsseite)"}[min(level, 3)]
s.sendOnce(ctx, fmt.Sprintf("dunning:%s:%s:%d", t.ID, t.PaymentOverdueSince.UTC().Format(time.DateOnly), level), notify.Message{
Severity: sev,
Title: fmt.Sprintf("Mahnstufe %d für %s", level, t.ID),
Body: fmt.Sprintf("Zahlung von *%s* seit %s offen. Wirkung: %s.", t.ID, t.PaymentOverdueSince.UTC().Format(time.DateOnly), text),
})
}
// notifyOffline alarmiert einmal pro Ausfall, wenn ein Knoten länger als
// OfflineAlertAfter getrennt ist – nicht bei angemeldeter Wartung oder drain.
func (s *Server) notifyOffline(ctx context.Context, now time.Time) {
nodes, err := s.store.ListNodes(ctx, "")
if err != nil {
return
}
for _, n := range nodes {
if n.Status != store.NodeOffline || n.Maintenance.Active(now) || now.Sub(n.LastSeen) < s.cfg.OfflineAlertAfter {
continue
}
s.sendOnce(ctx, "offline:"+n.ID+":"+n.LastSeen.UTC().Format(time.RFC3339Nano), notify.Message{
Severity: notify.Critical,
Title: "Knoten " + n.ID + " offline",
Body: fmt.Sprintf("Knoten *%s* (Mandant %s, %s) ist seit %s getrennt. Besucher sehen die Wartungsseite, solange die Lease gilt.",
n.ID, n.TenantID, n.Hostname, n.LastSeen.Local().Format("2006-01-02 15:04")),
})
}
}
// notifyBackOnline meldet „wieder online“ – nur für Ausfälle, zu denen ein
// Offline-Alarm verschickt wurde, und je Ausfall genau einmal.
func (s *Server) notifyBackOnline(nodeID string) {
ctx := context.Background()
prefix := "offline:" + nodeID + ":"
keys, err := s.store.NotificationKeys(ctx, prefix)
if err != nil {
return
}
for _, k := range keys {
msg := notify.Message{Severity: notify.Info, Title: "Knoten " + nodeID + " wieder online"}
if since, err := time.Parse(time.RFC3339Nano, k[len(prefix):]); err == nil {
msg.Body = fmt.Sprintf("Knoten *%s* ist nach %s wieder verbunden.", nodeID, outage(time.Since(since)))
} else {
msg.Body = fmt.Sprintf("Knoten *%s* ist wieder verbunden.", nodeID)
}
s.sendOnce(ctx, "recovered:"+k, msg)
}
}
func outage(d time.Duration) string {
switch {
case d < time.Minute:
return fmt.Sprintf("%d s", int(d/time.Second))
case d < time.Hour:
return fmt.Sprintf("%d min", int(d/time.Minute))
default:
return fmt.Sprintf("%d h %d min", int(d/time.Hour), int(d%time.Hour/time.Minute))
}
}
// sendOnce verschickt eine Nachricht genau einmal je Schlüssel; scheitert
// der Versand, wird es beim nächsten Durchlauf erneut versucht.
func (s *Server) sendOnce(ctx context.Context, key string, m notify.Message) {
if s.store.Notified(ctx, key) {
return
}
if err := s.notifier.Send(ctx, m); err != nil {
s.log.Warn("Benachrichtigung nicht zugestellt – nächster Versuch beim nächsten Durchlauf", "key", key, "err", err)
return
}
if err := s.store.RecordNotification(ctx, key); err != nil {
s.log.Error("Benachrichtigung vermerken", "key", key, "err", err)
}
}
func (s *Server) housekeeping(ctx context.Context) {
for name, purge := range map[string]func(context.Context, time.Duration) (int64, error){
"Einladungen": s.store.PurgeEnrollmentTokens,
"Leases": s.store.PurgeLeases,
"Befehle": s.store.PurgeNodeCommands,
} {
if n, err := purge(ctx, retention); err != nil {
s.log.Error("Aufräumen", "was", name, "err", err)
} else if n > 0 {
s.log.Info("Aufgeräumt", "was", name, "anzahl", n)
}
}
}
// recordViolation zählt Verstöße für Checkmk (letzte 24 h, im Speicher).
func (s *Server) recordViolation(at time.Time) {
s.vmu.Lock()
defer s.vmu.Unlock()
s.violations = append(s.violations, at)
}
func (s *Server) violations24h(now time.Time) int {
s.vmu.Lock()
defer s.vmu.Unlock()
cutoff := now.Add(-24 * time.Hour)
i := 0
for i < len(s.violations) && s.violations[i].Before(cutoff) {
i++
}
s.violations = s.violations[i:]
return len(s.violations)
}
// handleCheckmk liefert den Zustand im Checkmk-Local-Check-Format.
func (s *Server) handleCheckmk(w http.ResponseWriter, r *http.Request) {
ctx, now := r.Context(), time.Now()
state := checkmk.State{Violations24h: s.violations24h(now), OfflineCrit: s.cfg.OfflineAlertAfter, Location: time.Local}
tenants, err := s.store.ListTenants(ctx)
if err != nil {
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
for _, t := range tenants {
state.Tenants = append(state.Tenants, checkmk.Tenant{
ID: t.ID, Commercial: commercial(t), Status: string(t.Status), ValidUntil: t.ValidUntil,
DunningLevel: policy.DunningSchedule{}.Level(t.PaymentOverdueSince, now), OverdueSince: t.PaymentOverdueSince,
})
}
nodes, err := s.store.ListNodes(ctx, "")
if err != nil {
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
for _, n := range nodes {
cn := checkmk.Node{ID: n.ID, Status: string(n.Status), OfflineSince: n.LastSeen}
if n.Maintenance.Active(now) {
cn.MaintenanceActive, cn.MaintenanceUntil, cn.MaintenanceReason = true, n.Maintenance.Until, n.Maintenance.Reason
}
if l, err := s.store.GetLease(ctx, n.ID); err == nil {
cn.LeaseUntil = l.ValidUntil
}
state.Nodes = append(state.Nodes, cn)
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(checkmk.Render(state, now)))
}
+92
View File
@@ -0,0 +1,92 @@
package server
import (
"context"
"encoding/json"
"errors"
"time"
"sultan/pkg/license"
"sultan/pkg/store"
"sultan/pkg/stream"
)
// ensureLease stellt die Lease eines verbundenen Knotens aus bzw. erneuert
// sie ab renew_after und schickt sie per lease_renew. Mit always wird auch
// eine noch gültige Lease erneut zugestellt (beim Verbinden).
func (s *Server) ensureLease(nodeID string, always bool) {
ctx := context.Background()
now := time.Now()
current, err := s.store.GetLease(ctx, nodeID)
if err == nil && now.Before(current.RenewAfter) {
if always {
s.sendLease(nodeID, []byte(current.Document))
}
return
}
if err != nil && !errors.Is(err, store.ErrNotFound) {
s.log.Error("Lease lesen", "node", nodeID, "err", err)
return
}
node, err := s.store.GetNode(ctx, nodeID)
if err != nil {
return
}
tenant, err := s.store.GetTenant(ctx, node.TenantID)
if err != nil || tenant.Status == store.TenantQuarantined {
return
}
lic := licenseOf(tenant)
terms, ok := license.TermsFor(lic.Tier, node.Maintenance.Active(now), lic.ValidUntil, now, s.cfg.LeaseDuration, nil)
if !ok {
s.log.Warn("Keine Lease: Vertrag abgelaufen", "node", nodeID, "tenant", tenant.ID)
return
}
lease, err := (&license.Issuer{Key: s.master}).IssueLease(lic, nodeID, terms, now)
if err != nil {
s.log.Error("Lease ausstellen", "node", nodeID, "err", err)
return
}
doc, _ := json.Marshal(lease)
if err := s.store.PutLease(ctx, store.Lease{
NodeID: nodeID, IssuedAt: lease.IssuedAt, RenewAfter: lease.RenewAfter, ValidUntil: lease.ValidUntil, Document: string(doc),
}); err != nil {
s.log.Error("Lease speichern", "node", nodeID, "err", err)
return
}
s.log.Info("Lease ausgestellt", "node", nodeID, "valid_until", terms.ValidUntil, "renew_after", terms.RenewAfter)
s.sendLease(nodeID, doc)
}
func (s *Server) sendLease(nodeID string, doc []byte) {
if err := s.hub.Send(nodeID, stream.Event{Type: stream.EventLeaseRenew, Payload: doc}); err != nil {
s.log.Debug("Lease nicht zugestellt", "node", nodeID, "err", err)
}
}
// leaseValid meldet, ob der Knoten eine noch gültige Lease hat.
func (s *Server) leaseValid(nodeID string) bool {
l, err := s.store.GetLease(context.Background(), nodeID)
return err == nil && time.Now().Before(l.ValidUntil)
}
// dispatchCommands stellt Befehle aus der Warteschlange zu.
func (s *Server) dispatchCommands(nodeID string) {
ctx := context.Background()
cmds, err := s.store.PendingNodeCommands(ctx, nodeID)
if err != nil {
s.log.Error("Befehle lesen", "node", nodeID, "err", err)
return
}
for _, c := range cmds {
payload, _ := json.Marshal(stream.NodeCommand{Command: c.Command, Reason: c.Reason})
if err := s.hub.Send(nodeID, stream.Event{Type: stream.EventNodeCommand, Payload: payload}); err != nil {
return // später erneut (nächster Refresh oder nächste Verbindung)
}
if err := s.store.MarkNodeCommandSent(ctx, c.ID); err != nil {
s.log.Error("Befehl als gesendet markieren", "node", nodeID, "err", err)
return
}
s.log.Info("Befehl zugestellt", "node", nodeID, "command", c.Command, "reason", c.Reason)
}
}
+61
View File
@@ -0,0 +1,61 @@
package server
import (
"sync"
"time"
)
// rateLimiter begrenzt Versuche je Schlüssel (Client-IP) auf limit pro
// window (gleitendes Fenster). limit < 0 = unbegrenzt.
type rateLimiter struct {
limit int
window time.Duration
now func() time.Time
mu sync.Mutex
hits map[string][]time.Time
lastPurge time.Time
}
// allow verbucht einen Versuch; bei Ablehnung liefert es die Wartezeit.
func (l *rateLimiter) allow(key string) (bool, time.Duration) {
if l.limit < 0 {
return true, 0
}
now := time.Now()
if l.now != nil {
now = l.now()
}
cutoff := now.Add(-l.window)
l.mu.Lock()
defer l.mu.Unlock()
if l.hits == nil {
l.hits = map[string][]time.Time{}
}
if now.Sub(l.lastPurge) >= l.window { // vergessen, was nicht mehr zählt
for k, ts := range l.hits {
if !ts[len(ts)-1].After(cutoff) {
delete(l.hits, k)
}
}
l.lastPurge = now
}
ts := l.hits[key]
i := 0
for i < len(ts) && !ts[i].After(cutoff) {
i++
}
ts = ts[i:]
if len(ts) >= l.limit {
l.hits[key] = ts
return false, ts[0].Add(l.window).Sub(now)
}
l.hits[key] = append(ts, now)
return true, 0
}
func (l *rateLimiter) size() int {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.hits)
}
+56
View File
@@ -0,0 +1,56 @@
package server
import (
"testing"
"time"
)
func TestRateLimiter(t *testing.T) {
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
l := &rateLimiter{limit: 3, window: time.Minute, now: func() time.Time { return now }}
for i := range 3 {
if ok, _ := l.allow("1.2.3.4"); !ok {
t.Fatalf("Versuch %d abgelehnt", i+1)
}
}
ok, retry := l.allow("1.2.3.4")
if ok || retry != time.Minute {
t.Errorf("4. Versuch: ok=%v retry=%v, erwartet abgelehnt mit 1 min", ok, retry)
}
if ok, _ := l.allow("5.6.7.8"); !ok {
t.Error("andere IP mitbetroffen")
}
now = now.Add(40 * time.Second)
if _, retry := l.allow("1.2.3.4"); retry != 20*time.Second {
t.Errorf("Retry-After nach 40 s: %v, erwartet 20 s", retry)
}
now = now.Add(20 * time.Second)
if ok, _ := l.allow("1.2.3.4"); !ok {
t.Error("nach Ablauf des Fensters weiter gesperrt")
}
}
// Die Zählerliste darf bei vielen IPs nicht unbegrenzt wachsen.
func TestRateLimiter_ForgetsOldEntries(t *testing.T) {
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
l := &rateLimiter{limit: 1, window: time.Minute, now: func() time.Time { return now }}
for i := range 1000 {
l.allow(string(rune('a'+i%26)) + time.Duration(i).String())
}
now = now.Add(2 * time.Minute)
l.allow("neu")
if n := l.size(); n != 1 {
t.Errorf("%d Einträge nach Ablauf, erwartet 1", n)
}
}
func TestRateLimiter_Disabled(t *testing.T) {
l := &rateLimiter{limit: -1, window: time.Minute}
for range 100 {
if ok, _ := l.allow("x"); !ok {
t.Fatal("Limit -1 muss unbegrenzt sein")
}
}
}
+224 -10
View File
@@ -5,6 +5,8 @@
// 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.
// - Lander-Port (Default 127.0.0.1:8081, nur lokal): Hinweis-,
// Wartungs- und Einladungsseiten, zu denen die Policy umleitet.
//
// Persistenter Zustand liegt im Datenverzeichnis (Default ./suldata):
// sultan.db, server.key/server.crt (TLS) und master.key (Lizenzen).
@@ -13,6 +15,7 @@ package server
import (
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -25,6 +28,8 @@ import (
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/lander"
"sultan/pkg/notify"
"sultan/pkg/policy"
"sultan/pkg/registry"
"sultan/pkg/store"
@@ -36,9 +41,32 @@ 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
// PublicURLs: Adressen, unter denen Agents den Server erreichen (z. B.
// öffentlich und NetBird). Werden gemerkt; die erste steckt in
// Einladungen, die ganze Liste bekommen Agents per config-Befehl.
PublicURLs []string
LanderAddr string // Default 127.0.0.1:8081; "none" = kein eingebauter Lander
InterceptorURL string // Default: Adresse des eingebauten Landers
HosterName string // Lander: Name des Hosting-Anbieters
HosterURL string // Lander: Angebotsseite
ProURL string // Lander: Pro-Hinweis auf Community-Wartungsseiten
RefreshInterval time.Duration // Default 1m: Neubewertung aller Knoten, Lease-Erneuerung, Befehle
LeaseDuration time.Duration // 0 = Laufzeiten je Tier (Spec 4.1a); > 0 feste Laufzeit (Tests, Betrieb)
Logger *slog.Logger // Default: Text auf stderr
// Benachrichtigungen: Slack-kompatible Webhooks und ntfy-Topics (je
// mehrfach); Notify ersetzt beides (Tests, eigene Ziele).
SlackWebhooks []string
NtfyURLs []string
NtfyToken string
Notify notify.Target
OfflineAlertAfter time.Duration // Default 15m: Alarm für getrennte Knoten
// EnrollLimit: Enrollment-Versuche pro Minute und Client-IP (Default 10,
// -1 = unbegrenzt) – gegen das Durchprobieren von Einladungen.
EnrollLimit int
interceptorDerived bool // InterceptorURL folgt der gebundenen Lander-Adresse
}
func (c Config) withDefaults() Config {
@@ -51,18 +79,55 @@ func (c Config) withDefaults() Config {
if c.ProviderAddr == "" {
c.ProviderAddr = "127.0.0.1:8080"
}
if c.LanderAddr == "" {
c.LanderAddr = "127.0.0.1:8081"
}
if c.InterceptorURL == "" {
c.InterceptorURL = "http://127.0.0.1:8081"
if c.LanderAddr != "none" {
c.InterceptorURL = "http://" + loopbackAddr(c.LanderAddr)
c.interceptorDerived = true
}
}
if c.RefreshInterval <= 0 {
c.RefreshInterval = time.Minute
}
if c.OfflineAlertAfter <= 0 {
c.OfflineAlertAfter = 15 * time.Minute
}
if c.EnrollLimit == 0 {
c.EnrollLimit = 10
}
if c.Logger == nil {
c.Logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
}
return c
}
// SettingPublicURLs ist der Store-Schlüssel der gemerkten Adressen (JSON-Liste).
const SettingPublicURLs = "public_urls"
// LoadPublicURLs liest die gemerkten Adressen (leer, wenn keine gesetzt sind).
func LoadPublicURLs(ctx context.Context, st *store.Store) ([]string, error) {
raw, err := st.Setting(ctx, SettingPublicURLs)
if errors.Is(err, store.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
var urls []string
if err := json.Unmarshal([]byte(raw), &urls); err != nil {
return nil, fmt.Errorf("server: %s beschädigt: %w", SettingPublicURLs, err)
}
return urls, nil
}
// PublicURLs liefert die gemerkten Adressen.
func (s *Server) PublicURLs(ctx context.Context) ([]string, error) {
return LoadPublicURLs(ctx, s.store)
}
// Server ist ein laufender bzw. startbereiter Sultan-Server.
type Server struct {
cfg Config
@@ -73,12 +138,20 @@ type Server struct {
hub *stream.Hub
verifier *auth.Verifier
registry *registry.Registry
lander *lander.Lander
notifier notify.Target // nil = keine Benachrichtigungen
enrollRL *rateLimiter
vmu sync.Mutex
violations []time.Time // Zeitpunkte der Policy-Verstöße (für Checkmk)
mu sync.Mutex
agentAddr string
providerAddr string
landerAddr string
agentSrv *http.Server
providerSrv *http.Server
landerSrv *http.Server
serveErr chan error
stopRefresh chan struct{}
shutdownOnce sync.Once
@@ -89,6 +162,13 @@ type Server struct {
// TLS-Zertifikat und Master-Key. Lauschen beginnt erst mit Start.
func New(ctx context.Context, cfg Config) (*Server, error) {
cfg = cfg.withDefaults()
for i, raw := range cfg.PublicURLs {
u, err := crypto.ValidateServerURL(raw)
if err != nil {
return nil, fmt.Errorf("server: --public-url: %w", err)
}
cfg.PublicURLs[i] = u
}
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
return nil, fmt.Errorf("server: Datenverzeichnis: %w", err)
}
@@ -96,6 +176,13 @@ func New(ctx context.Context, cfg Config) (*Server, error) {
if err != nil {
return nil, err
}
if len(cfg.PublicURLs) > 0 {
raw, _ := json.Marshal(cfg.PublicURLs)
if err := st.SetSetting(ctx, SettingPublicURLs, string(raw)); err != nil {
st.Close()
return nil, err
}
}
cert, err := crypto.LoadOrCreateServerCert(cfg.DataDir)
if err != nil {
st.Close()
@@ -114,10 +201,31 @@ func New(ctx context.Context, cfg Config) (*Server, error) {
cert: cert,
master: master,
verifier: &auth.Verifier{Directory: st},
serveErr: make(chan error, 2),
serveErr: make(chan error, 3),
stopRefresh: make(chan struct{}),
}
s.hub = &stream.Hub{OnConnect: s.nodeOnline, OnDisconnect: s.nodeOffline}
s.hub = &stream.Hub{OnConnect: s.nodeOnline, OnDisconnect: s.nodeOffline, SigningKey: master}
s.enrollRL = &rateLimiter{limit: cfg.EnrollLimit, window: time.Minute}
s.notifier = cfg.Notify
if s.notifier == nil {
var targets notify.Multi
for _, u := range cfg.SlackWebhooks {
targets = append(targets, notify.Slack{URL: u})
}
for _, u := range cfg.NtfyURLs {
targets = append(targets, notify.Ntfy{URL: u, Token: cfg.NtfyToken})
}
if len(targets) > 0 {
s.notifier = targets
}
}
s.lander = &lander.Lander{
Dir: filepath.Join(cfg.DataDir, "landing"),
HosterName: cfg.HosterName,
HosterURL: cfg.HosterURL,
ProURL: cfg.ProURL,
Logger: cfg.Logger,
}
s.registry = &registry.Registry{
Directory: &directory{store: st},
Policy: &policy.Engine{InterceptorURL: cfg.InterceptorURL},
@@ -157,6 +265,22 @@ func (s *Server) AgentAddr() string { s.mu.Lock(); defer s.mu.Unlock(); return s
// 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 }
// LanderAddr ist die gebundene Adresse des Landers ("" wenn abgeschaltet).
func (s *Server) LanderAddr() string { s.mu.Lock(); defer s.mu.Unlock(); return s.landerAddr }
// loopbackAddr macht aus ":8081" bzw. "0.0.0.0:8081" eine lokal erreichbare
// Adresse (127.0.0.1:8081) für die Interceptor-URL.
func loopbackAddr(addr string) string {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) {
host = "127.0.0.1"
}
return net.JoinHostPort(host, port)
}
// 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)
@@ -168,6 +292,18 @@ func (s *Server) Start() error {
agentLn.Close()
return fmt.Errorf("server: Provider-Port %s: %w", s.cfg.ProviderAddr, err)
}
var landerLn net.Listener
if s.cfg.LanderAddr != "none" {
if landerLn, err = net.Listen("tcp", s.cfg.LanderAddr); err != nil {
agentLn.Close()
providerLn.Close()
return fmt.Errorf("server: Lander-Port %s: %w", s.cfg.LanderAddr, err)
}
if s.cfg.interceptorDerived {
// Noch läuft nichts nebenläufig: die Policy darf angepasst werden.
s.registry.Policy.InterceptorURL = "http://" + loopbackAddr(landerLn.Addr().String())
}
}
agentSrv := &http.Server{
Handler: s.agentRoutes(),
@@ -183,14 +319,22 @@ func (s *Server) Start() error {
s.mu.Lock()
s.agentAddr, s.providerAddr = agentLn.Addr().String(), providerLn.Addr().String()
s.agentSrv, s.providerSrv = agentSrv, providerSrv
if landerLn != nil {
s.landerAddr = landerLn.Addr().String()
s.landerSrv = &http.Server{Handler: s.lander, ReadHeaderTimeout: 10 * time.Second}
}
landerSrv := s.landerSrv
s.mu.Unlock()
go s.serve(func() error { return agentSrv.ServeTLS(agentLn, "", "") })
go s.serve(func() error { return providerSrv.Serve(providerLn) })
if landerSrv != nil {
go s.serve(func() error { return landerSrv.Serve(landerLn) })
}
go s.refreshLoop()
s.log.Info("Sultan-Server gestartet",
"agent", s.AgentAddr(), "provider", s.ProviderAddr(),
"agent", s.AgentAddr(), "provider", s.ProviderAddr(), "lander", s.LanderAddr(),
"Fingerprint", string(s.cert.Fingerprint), "data_dir", s.cfg.DataDir)
return nil
}
@@ -209,9 +353,15 @@ func (s *Server) refreshLoop() {
case <-s.stopRefresh:
return
case <-t.C:
s.pruneDisconnected()
for _, id := range s.hub.Nodes() {
s.ensureLease(id, false)
s.dispatchCommands(id)
}
if err := s.registry.Refresh(context.Background()); err != nil {
s.log.Error("Neubewertung fehlgeschlagen", "err", err)
}
s.runJobs(context.Background())
}
}
}
@@ -241,10 +391,10 @@ func (s *Server) Shutdown(ctx context.Context) error {
close(s.stopRefresh)
s.hub.Close()
s.mu.Lock()
agentSrv, providerSrv := s.agentSrv, s.providerSrv
agentSrv, providerSrv, landerSrv := s.agentSrv, s.providerSrv, s.landerSrv
s.mu.Unlock()
var errs []error
for _, srv := range []*http.Server{agentSrv, providerSrv} {
for _, srv := range []*http.Server{agentSrv, providerSrv, landerSrv} {
if srv != nil {
errs = append(errs, srv.Shutdown(ctx))
}
@@ -257,16 +407,62 @@ func (s *Server) Shutdown(ctx context.Context) error {
}
// nodeOnline/nodeOffline halten nodes.status aktuell; ein gedrainter
// Knoten bleibt gedraint. Beim Trennen verschwinden seine Routen.
// Knoten bleibt gedraint. Beim Trennen bleiben die Routen als Wartungsseite
// stehen, solange eine Wartung angemeldet ist oder die Lease gilt; erst
// danach (pruneDisconnected) verschwinden sie. drain entfernt sie sofort.
func (s *Server) nodeOnline(nodeID string) {
s.setNodeStatus(nodeID, store.NodeOnline)
s.log.Info("Knoten verbunden", "node", nodeID)
s.ensureLease(nodeID, true)
s.dispatchCommands(nodeID)
s.sendServerURLs(nodeID)
if s.notifier != nil {
go s.notifyBackOnline(nodeID) // nicht den Stream-Aufbau aufhalten
}
}
// sendServerURLs schickt dem Agenten die Adressliste (Ausweichweg).
func (s *Server) sendServerURLs(nodeID string) {
urls, err := s.PublicURLs(context.Background())
if err != nil || len(urls) == 0 {
return
}
payload, _ := json.Marshal(stream.NodeCommand{Command: stream.CmdConfig, ServerURLs: urls})
if err := s.hub.Send(nodeID, stream.Event{Type: stream.EventNodeCommand, Payload: payload}); err != nil {
s.log.Debug("Adressliste nicht zugestellt", "node", nodeID, "err", err)
}
}
func (s *Server) nodeOffline(nodeID string) {
s.registry.Remove(nodeID)
s.setNodeStatus(nodeID, store.NodeOffline)
s.log.Info("Knoten getrennt", "node", nodeID)
if err := s.store.MarkNodeSeen(context.Background(), nodeID); err != nil {
s.log.Debug("offline-seit setzen", "node", nodeID, "err", err)
}
if s.inMaintenance(nodeID) || s.leaseValid(nodeID) {
s.log.Info("Knoten getrennt – Routen bleiben als Wartungsseite (Wartung bzw. Lease)", "node", nodeID)
if err := s.registry.Refresh(context.Background()); err != nil {
s.log.Error("Neubewertung nach Trennung", "err", err)
}
return
}
s.registry.Remove(nodeID)
s.log.Info("Knoten getrennt – Routen entfernt", "node", nodeID)
}
func (s *Server) inMaintenance(nodeID string) bool {
n, err := s.store.GetNode(context.Background(), nodeID)
return err == nil && n.Maintenance.Active(time.Now())
}
// pruneDisconnected entfernt Routen getrennter Knoten, deren Wartung und
// Lease inzwischen abgelaufen sind.
func (s *Server) pruneDisconnected() {
for _, id := range s.registry.Nodes() {
if !s.hub.Connected(id) && !s.inMaintenance(id) && !s.leaseValid(id) {
s.registry.Remove(id)
s.log.Info("Getrennter Knoten: Wartung und Lease abgelaufen – Routen entfernt", "node", id)
}
}
}
func (s *Server) setNodeStatus(nodeID string, status store.NodeStatus) {
@@ -284,4 +480,22 @@ 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)
s.recordViolation(time.Now())
if s.notifier == nil {
return
}
// Asynchron: OnViolation läuft unter der Sperre der Registry. Die
// Registry meldet jeden Verstoß nur beim ersten Auftreten.
msg := notify.Message{
Severity: notify.Warning,
Title: "Policy-Verstoß auf Knoten " + v.NodeID,
Body: fmt.Sprintf("Knoten *%s*, Router `%s`, Art `%s`: %s", v.NodeID, v.Router, v.Kind, v.Detail),
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := s.notifier.Send(ctx, msg); err != nil {
s.log.Warn("Benachrichtigung über Policy-Verstoß nicht zugestellt", "err", err)
}
}()
}
+725 -9
View File
@@ -3,6 +3,7 @@ package server
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"io"
@@ -10,6 +11,7 @@ import (
"net/http"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
@@ -17,6 +19,8 @@ import (
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/license"
"sultan/pkg/notify"
"sultan/pkg/policy"
"sultan/pkg/registry"
"sultan/pkg/store"
@@ -58,6 +62,7 @@ func startServer(t *testing.T, mutate ...func(*Config)) (*Server, *syncBuffer) {
DataDir: filepath.Join(t.TempDir(), "suldata"),
AgentAddr: "127.0.0.1:0",
ProviderAddr: "127.0.0.1:0",
LanderAddr: "127.0.0.1:0",
RefreshInterval: time.Hour,
Logger: slog.New(slog.NewTextHandler(logs, nil)),
}
@@ -92,7 +97,7 @@ func createTenant(t *testing.T, s *Server, id string, domains ...string) {
// 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())
tok, err := crypto.NewEnrollmentToken(s.Fingerprint(), "")
if err != nil {
t.Fatal(err)
}
@@ -116,11 +121,10 @@ func newAgent(t *testing.T, s *Server, token string) *testAgent {
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)}},
hc: &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(tok.Fingerprint)}},
signer: &auth.Signer{Key: priv},
secret: tok.Secret,
}
@@ -151,13 +155,46 @@ func (a *testAgent) enroll(t *testing.T, nodeID string) *http.Response {
// connect hält den Stream im Hintergrund offen, bis cancel aufgerufen wird.
func (a *testAgent) connect(t *testing.T) (cancel func()) {
t.Helper()
cancel, _ = a.connectEvents(t)
return cancel
}
// connectEvents hält den Stream offen und liefert die empfangenen Events.
func (a *testAgent) connectEvents(t *testing.T) (cancel func(), events <-chan stream.Event) {
t.Helper()
sctx, cancel := context.WithCancel(ctx)
ch := make(chan stream.Event, 64)
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) }()
go func() {
c.Run(sctx, func(ev stream.Event) error {
select {
case ch <- ev:
default:
}
return nil
})
close(done)
}()
t.Cleanup(func() { cancel(); <-done })
return cancel
return cancel, ch
}
// nextEvent wartet auf das nächste Event des Typs typ.
func nextEvent(t *testing.T, events <-chan stream.Event, typ stream.EventType) stream.Event {
t.Helper()
deadline := time.After(3 * time.Second)
for {
select {
case ev := <-events:
if ev.Type == typ {
return ev
}
case <-deadline:
t.Fatalf("kein Event %s empfangen", typ)
}
}
}
func wpState(domain string) registry.NodeState {
@@ -187,7 +224,7 @@ func TestConfigDefaults(t *testing.T) {
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 {
if c.InterceptorURL != "http://127.0.0.1:8081" || c.LanderAddr != "127.0.0.1:8081" || c.RefreshInterval != time.Minute || c.Logger == nil {
t.Errorf("Defaults: %+v", c)
}
}
@@ -299,8 +336,11 @@ func TestEndToEnd(t *testing.T) {
t.Errorf("Traefik-Konfiguration: %s", cfg)
}
// Ungeplant getrennt: Routen bleiben als Wartungsseite, solange die Lease gilt.
stop()
eventually(t, "Route nach Trennung entfernt", func() bool { return providerConfig(t, s) == "{}" })
eventually(t, "Wartungsseite nach Trennung", func() bool {
return strings.Contains(providerConfig(t, s), policy.InterceptorService)
})
eventually(t, "Knoten offline", func() bool {
n, _ := s.store.GetNode(ctx, "node-1")
return n.Status == store.NodeOffline
@@ -442,7 +482,7 @@ func TestDirectory(t *testing.T) {
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),
LanderAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler),
}
s, err := New(ctx, cfg)
if err != nil {
@@ -474,7 +514,7 @@ 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),
LanderAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatal(err)
@@ -484,3 +524,679 @@ func TestStart_PortInUse(t *testing.T) {
t.Error("belegter Port: erwartet Fehler")
}
}
// Wie Traefik: Konfiguration lesen, Router zum Interceptor finden, dessen
// Header-Middleware anwenden und den Lander aufrufen.
func TestLander_EndToEndMaintenance(t *testing.T) {
s, _ := startServer(t, func(c *Config) { c.ProURL = "https://hoster.example/pro" })
if s.LanderAddr() == "" || strings.HasSuffix(s.LanderAddr(), ":0") {
t.Fatalf("Lander-Adresse: %q", s.LanderAddr())
}
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") })
st := wpState("kunde.de")
st.DownServices = []string{"wp"}
if r := agent.do(t, http.MethodPost, "/api/v1/state", st); r.StatusCode != http.StatusNoContent {
t.Fatalf("State: %d", r.StatusCode)
}
var cfg struct {
HTTP struct {
Routers map[string]struct {
Service string
Middlewares []string
}
Services map[string]struct {
LoadBalancer struct{ Servers []struct{ URL string } }
}
Middlewares map[string]struct {
Headers struct{ CustomRequestHeaders map[string]string }
}
}
}
if err := json.Unmarshal([]byte(providerConfig(t, s)), &cfg); err != nil {
t.Fatal(err)
}
router := cfg.HTTP.Routers["kunde__n1__wp"]
interceptor := cfg.HTTP.Services[router.Service].LoadBalancer.Servers[0].URL
if interceptor != "http://"+s.LanderAddr() {
t.Fatalf("Interceptor-URL %q, erwartet den gebundenen Lander %q", interceptor, s.LanderAddr())
}
req, _ := http.NewRequest(http.MethodGet, interceptor+"/shop", nil)
for _, mw := range router.Middlewares {
for k, v := range cfg.HTTP.Middlewares[mw].Headers.CustomRequestHeaders {
req.Header.Set(k, v)
}
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusServiceUnavailable || resp.Header.Get("Retry-After") != "60" ||
!strings.Contains(string(body), "Gleich wieder da") {
t.Errorf("Lander: %d %q\n%s", resp.StatusCode, resp.Header.Get("Retry-After"), body)
}
}
func TestLander_CustomTemplatesFromDataDir(t *testing.T) {
s, _ := startServer(t)
dir := filepath.Join(s.cfg.DataDir, "landing")
os.MkdirAll(dir, 0o755)
os.WriteFile(filepath.Join(dir, "maintenance.html"), []byte("Eigene Seite {{.Tenant}}"), 0o644)
req, _ := http.NewRequest(http.MethodGet, "http://"+s.LanderAddr()+"/", nil)
req.Header.Set("X-Sultan-Stage", "maintenance")
req.Header.Set("X-Sultan-Tenant", "kunde")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if body, _ := io.ReadAll(resp.Body); string(body) != "Eigene Seite kunde" {
t.Errorf("body = %q", body)
}
}
func TestLander_Disabled(t *testing.T) {
s, _ := startServer(t, func(c *Config) { c.LanderAddr = "none"; c.InterceptorURL = "http://lander.intern:8081" })
if s.LanderAddr() != "" {
t.Errorf("Lander läuft trotz none: %q", s.LanderAddr())
}
if s.registry.Policy.InterceptorURL != "http://lander.intern:8081" {
t.Errorf("explizite Interceptor-URL überschrieben: %q", s.registry.Policy.InterceptorURL)
}
}
// Die öffentliche Adresse wird im Store gemerkt, damit `sultan token invite`
// (eigener Prozess) sie in Einladungen einbetten kann.
func TestPublicURLs(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
discard := slog.New(slog.DiscardHandler)
if _, err := New(ctx, Config{DataDir: dir, PublicURLs: []string{"https://ok.example:8443", "http://ingress:8443"}, Logger: discard}); err == nil {
t.Error("http-URL akzeptiert")
}
s, err := New(ctx, Config{DataDir: dir, PublicURLs: []string{"https://Ingress.example:8443/", "https://100.64.0.1:8443"}, Logger: discard})
if err != nil {
t.Fatal(err)
}
want := []string{"https://ingress.example:8443", "https://100.64.0.1:8443"}
if got, err := s.PublicURLs(ctx); err != nil || !reflect.DeepEqual(got, want) {
t.Errorf("gespeichert: %v %v", got, err)
}
s.Shutdown(ctx)
// Neustart ohne --public-url: gemerkte Adressen bleiben.
s, err = New(ctx, Config{DataDir: dir, Logger: discard})
if err != nil {
t.Fatal(err)
}
defer s.Shutdown(ctx)
if got, _ := s.PublicURLs(ctx); !reflect.DeepEqual(got, want) {
t.Errorf("nach Neustart: %v", got)
}
}
// Beim Verbinden bekommt der Agent die vollständige Adressliste (signiert).
func TestConfigCommandOnConnect(t *testing.T) {
urls := []string{"https://ingress.example:8443", "https://100.64.0.1:8443"}
s, _ := startServer(t, func(c *Config) { c.PublicURLs = urls })
createTenant(t, s, "kunde", "kunde.de")
agent := newAgent(t, s, issueToken(t, s, "kunde"))
agent.enroll(t, "n1")
_, events := agent.connectEvents(t)
ev := nextEvent(t, events, stream.EventNodeCommand)
if err := stream.VerifyEvent(ev, s.MasterPublicKey()); err != nil {
t.Fatal(err)
}
cmd, err := stream.ParseNodeCommand(ev)
if err != nil || cmd.Command != stream.CmdConfig || !reflect.DeepEqual(cmd.ServerURLs, urls) {
t.Errorf("config-Befehl: %+v %v", cmd, err)
}
}
func TestNoConfigCommandWithoutPublicURLs(t *testing.T) {
s, _ := startServer(t)
createTenant(t, s, "kunde", "kunde.de")
agent := newAgent(t, s, issueToken(t, s, "kunde"))
agent.enroll(t, "n1")
_, events := agent.connectEvents(t)
nextEvent(t, events, stream.EventLeaseRenew)
select {
case ev := <-events:
if ev.Type == stream.EventNodeCommand {
t.Errorf("config ohne Adressen gesendet: %s", ev.Payload)
}
case <-time.After(200 * time.Millisecond):
}
}
// connectedAgent: enrollt, verbindet und meldet wpState(kunde.de).
func connectedAgent(t *testing.T, s *Server, node string) (*testAgent, func()) {
t.Helper()
if _, err := s.store.GetTenant(ctx, "kunde"); err != nil {
createTenant(t, s, "kunde", "kunde.de")
}
agent := newAgent(t, s, issueToken(t, s, "kunde"))
if r := agent.enroll(t, node); r.StatusCode != http.StatusCreated {
t.Fatalf("Enrollment: %d", r.StatusCode)
}
stop := agent.connect(t)
eventually(t, "Stream", func() bool { return s.hub.Connected(node) })
if r := agent.do(t, http.MethodPost, "/api/v1/state", wpState("kunde.de")); r.StatusCode != http.StatusNoContent {
t.Fatalf("State: %d", r.StatusCode)
}
return agent, stop
}
func TestMaintenance_AgentEndpoint(t *testing.T) {
s, logs := startServer(t)
agent, _ := connectedAgent(t, s, "n1")
resp := agent.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{
Enabled: true, Duration: "2h", Reason: "Kernel 6.12", PublicReason: "Sicherheitsupdate",
})
if resp.StatusCode != http.StatusNoContent {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("Wartung an: %d %s", resp.StatusCode, b)
}
n, _ := s.store.GetNode(ctx, "n1")
if m := n.Maintenance; m == nil || m.Reason != "Kernel 6.12" || m.PublicReason != "Sicherheitsupdate" ||
time.Until(m.Until) < 119*time.Minute || time.Until(m.Until) > 121*time.Minute {
t.Errorf("gespeichert: %+v", m)
}
// Sofort wirksam, nicht erst beim nächsten Refresh.
if cfg := providerConfig(t, s); !strings.Contains(cfg, policy.InterceptorService) {
t.Errorf("Wartung nicht sofort wirksam: %s", cfg)
}
if !strings.Contains(logs.String(), "Kernel 6.12") {
t.Error("interner Grund nicht protokolliert")
}
if r := agent.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{Enabled: false}); r.StatusCode != http.StatusNoContent {
t.Fatalf("Wartung aus: %d", r.StatusCode)
}
if n, _ := s.store.GetNode(ctx, "n1"); n.Maintenance != nil {
t.Error("Wartung nicht beendet")
}
if cfg := providerConfig(t, s); strings.Contains(cfg, policy.InterceptorService) {
t.Errorf("nach Wartungsende noch umgeleitet: %s", cfg)
}
}
func TestMaintenance_DefaultAndUnlimited(t *testing.T) {
s, _ := startServer(t)
agent, _ := connectedAgent(t, s, "n1")
agent.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{Enabled: true})
n, _ := s.store.GetNode(ctx, "n1")
if d := time.Until(n.Maintenance.Until); d < 239*time.Minute || d > 241*time.Minute {
t.Errorf("Default-Dauer %v, erwartet 4h", d)
}
agent.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{Enabled: true, Duration: "0"})
if n, _ := s.store.GetNode(ctx, "n1"); n.Maintenance == nil || !n.Maintenance.Until.IsZero() {
t.Errorf("Dauer 0 = unbefristet: %+v", n.Maintenance)
}
}
func TestMaintenance_InvalidRequests(t *testing.T) {
s, _ := startServer(t)
agent, _ := connectedAgent(t, s, "n1")
for name, req := range map[string]any{
"Dauer unlesbar": MaintenanceRequest{Enabled: true, Duration: "bald"},
"Dauer negativ": MaintenanceRequest{Enabled: true, Duration: "-1h"},
"Grund zu lang": MaintenanceRequest{Enabled: true, PublicReason: strings.Repeat("x", 301)},
"Grund mehrzeilig": MaintenanceRequest{Enabled: true, PublicReason: "Zeile 1\nZeile 2"},
"kein JSON-Objekt": "an",
} {
if r := agent.do(t, http.MethodPost, "/api/v1/maintenance", req); r.StatusCode != http.StatusBadRequest {
t.Errorf("%s: Status %d", name, r.StatusCode)
}
}
plain, _ := http.NewRequest(http.MethodPost, agent.base+"/api/v1/maintenance", strings.NewReader(`{"enabled":true}`))
if resp, err := agent.hc.Do(plain); err != nil || resp.StatusCode != http.StatusUnauthorized {
t.Errorf("unsigniert: %v %v", resp, err)
}
}
// Reboot während der Wartung: Routen bleiben als Wartungsseite stehen, bis
// die Wartung endet – erst dann verschwinden sie.
func TestMaintenance_DisconnectKeepsRoutesUntilEnd(t *testing.T) {
// Kurze Lease, damit nach Wartungsende nicht die Lease die Routen hält.
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond; c.LeaseDuration = 150 * time.Millisecond })
agent, stop := connectedAgent(t, s, "n1")
agent.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{Enabled: true, Duration: "400ms"})
stop()
eventually(t, "Knoten getrennt", func() bool { return !s.hub.Connected("n1") })
time.Sleep(100 * time.Millisecond) // einige Refresh-Runden
if cfg := providerConfig(t, s); !strings.Contains(cfg, policy.InterceptorService) {
t.Fatalf("Routen während der Wartung entfernt: %s", cfg)
}
eventually(t, "Routen nach Wartungsende entfernt", func() bool { return providerConfig(t, s) == "{}" })
}
// Per CLI (anderer Prozess, direkt im Store) gesetzte Wartung wirkt beim nächsten Refresh.
func TestMaintenance_FromStoreAppliedOnRefresh(t *testing.T) {
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond })
connectedAgent(t, s, "n1")
if err := s.store.SetNodeMaintenance(ctx, "n1", store.Maintenance{Until: time.Now().Add(time.Hour)}); err != nil {
t.Fatal(err)
}
eventually(t, "Umleitung", func() bool { return strings.Contains(providerConfig(t, s), policy.InterceptorService) })
}
func TestDirectory_Maintenance(t *testing.T) {
s, _ := startServer(t)
createTenant(t, s, "kunde", "kunde.de")
s.store.CreateNode(ctx, store.Node{ID: "n1", TenantID: "kunde", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"})
until := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
s.store.SetNodeMaintenance(ctx, "n1", store.Maintenance{Until: until, Reason: "intern", PublicReason: "öffentlich"})
nc, err := (&directory{store: s.store}).NodeContext(ctx, "n1")
if err != nil {
t.Fatal(err)
}
if nc.Maintenance == nil || !nc.Maintenance.Until.Equal(until) || nc.Maintenance.PublicReason != "öffentlich" {
t.Errorf("Maintenance: %+v", nc.Maintenance)
}
}
func TestLease_IssuedOnConnectAndSigned(t *testing.T) {
s, _ := startServer(t)
createTenant(t, s, "kunde", "kunde.de")
agent := newAgent(t, s, issueToken(t, s, "kunde"))
agent.enroll(t, "n1")
_, events := agent.connectEvents(t)
ev := nextEvent(t, events, stream.EventLeaseRenew)
if err := stream.VerifyEvent(ev, s.MasterPublicKey()); err != nil {
t.Fatalf("Event nicht vom Server signiert: %v", err)
}
l, err := license.ParseLease(ev.Payload, s.MasterPublicKey())
if err != nil {
t.Fatalf("Lease ungültig: %v", err)
}
if l.NodeID != "n1" || l.TenantID != "kunde" {
t.Errorf("Lease: %+v", l)
}
if d := time.Until(l.ValidUntil); d < 7*24*time.Hour-time.Minute || d > 7*24*time.Hour {
t.Errorf("Pro-Lease läuft %v, erwartet 7 Tage", d)
}
stored, err := s.store.GetLease(ctx, "n1")
if err != nil || !stored.ValidUntil.Equal(l.ValidUntil) {
t.Errorf("gespeicherte Lease: %+v %v", stored, err)
}
}
func TestLease_Renewal(t *testing.T) {
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond; c.LeaseDuration = 300 * time.Millisecond })
createTenant(t, s, "kunde", "kunde.de")
agent := newAgent(t, s, issueToken(t, s, "kunde"))
agent.enroll(t, "n1")
_, events := agent.connectEvents(t)
first, _ := license.ParseLease(nextEvent(t, events, stream.EventLeaseRenew).Payload, s.MasterPublicKey())
second, _ := license.ParseLease(nextEvent(t, events, stream.EventLeaseRenew).Payload, s.MasterPublicKey())
if !second.ValidUntil.After(first.ValidUntil) {
t.Errorf("nicht verlängert: %v → %v", first.ValidUntil, second.ValidUntil)
}
}
// Ungeplantes Trennen: Wartungsseite bis zum Ablauf der Lease, danach weg.
func TestDisconnect_RoutesUntilLeaseExpiry(t *testing.T) {
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond; c.LeaseDuration = 600 * time.Millisecond })
_, stop := connectedAgent(t, s, "n1")
stop()
eventually(t, "Wartungsseite", func() bool { return strings.Contains(providerConfig(t, s), policy.InterceptorService) })
eventually(t, "Routen nach Lease-Ablauf entfernt", func() bool { return providerConfig(t, s) == "{}" })
}
// drain entfernt die Routen eines getrennten Knotens sofort – trotz gültiger Lease.
func TestDisconnect_DrainRemovesImmediately(t *testing.T) {
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond })
_, stop := connectedAgent(t, s, "n1")
stop()
eventually(t, "Wartungsseite", func() bool { return strings.Contains(providerConfig(t, s), policy.InterceptorService) })
if err := s.store.SetNodeStatus(ctx, "n1", store.NodeDrained); err != nil {
t.Fatal(err)
}
eventually(t, "drain entfernt Routen", func() bool { return providerConfig(t, s) == "{}" })
}
// Befehle aus der Warteschlange (die CLI ist ein eigener Prozess) werden
// zugestellt – auch solche, die vor dem Verbinden eingereiht wurden.
func TestNodeCommands_Delivered(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")
s.store.EnqueueNodeCommand(ctx, "n1", stream.CmdResync, "")
_, events := agent.connectEvents(t)
ev := nextEvent(t, events, stream.EventNodeCommand)
if err := stream.VerifyEvent(ev, s.MasterPublicKey()); err != nil {
t.Fatalf("Befehl nicht signiert: %v", err)
}
if cmd, err := stream.ParseNodeCommand(ev); err != nil || cmd.Command != stream.CmdResync {
t.Errorf("Befehl: %+v %v", cmd, err)
}
s.store.EnqueueNodeCommand(ctx, "n1", stream.CmdDrain, "Umzug")
ev = nextEvent(t, events, stream.EventNodeCommand)
if cmd, _ := stream.ParseNodeCommand(ev); cmd.Command != stream.CmdDrain || cmd.Reason != "Umzug" {
t.Errorf("zweiter Befehl: %+v", cmd)
}
eventually(t, "als gesendet markiert", func() bool {
cmds, _ := s.store.PendingNodeCommands(ctx, "n1")
return len(cmds) == 0
})
}
func TestRotateKey(t *testing.T) {
s, _ := startServer(t)
agent, _ := connectedAgent(t, s, "n1")
other, _ := connectedAgent(t, s, "n2")
newKey, _ := auth.GenerateKey()
newPub := auth.EncodePublicKey(newKey.Public().(ed25519.PublicKey))
req := RotateKeyRequest{PublicKey: newPub, Proof: auth.RotationProof("n1", newKey)}
if r := agent.do(t, http.MethodPost, "/api/v1/rotate-key", RotateKeyRequest{PublicKey: newPub, Proof: "AAAA"}); r.StatusCode != http.StatusBadRequest {
t.Errorf("falscher Besitznachweis: %d", r.StatusCode)
}
if r := agent.do(t, http.MethodPost, "/api/v1/rotate-key", req); r.StatusCode != http.StatusNoContent {
t.Fatalf("Rotation: %d", r.StatusCode)
}
if n, _ := s.store.GetNode(ctx, "n1"); n.PublicKey != newPub {
t.Error("Schlüssel nicht getauscht")
}
if r := agent.do(t, http.MethodPost, "/api/v1/rotate-key", req); r.StatusCode != http.StatusUnauthorized {
t.Errorf("alter Schlüssel noch gültig: %d", r.StatusCode)
}
oldSigner := agent.signer
agent.signer = &auth.Signer{Key: newKey}
if r := agent.do(t, http.MethodPost, "/api/v1/rotate-key", req); r.StatusCode != http.StatusNoContent {
t.Errorf("Wiederholung mit neuem Schlüssel (Wiederaufnahme nach Absturz): %d", r.StatusCode)
}
agent.signer = oldSigner
// Der Besitznachweis ist an die Knoten-ID gebunden: Ein Nachweis für n2
// gilt nicht für n1.
_ = other
another, _ := auth.GenerateKey()
anotherPub := auth.EncodePublicKey(another.Public().(ed25519.PublicKey))
agent.signer = &auth.Signer{Key: newKey}
foreign := RotateKeyRequest{PublicKey: anotherPub, Proof: auth.RotationProof("n2", another)}
if r := agent.do(t, http.MethodPost, "/api/v1/rotate-key", foreign); r.StatusCode != http.StatusBadRequest {
t.Errorf("Nachweis für anderen Knoten: %d", r.StatusCode)
}
}
func TestDirectory_FeaturesPaymentDisconnected(t *testing.T) {
s, _ := startServer(t)
if _, err := s.store.CreateTenant(ctx, store.Tenant{
ID: "kunde", Name: "k", Email: "k@x.de", Tier: store.TierPro, MaxRoutes: 5,
AllowedDomains: []string{"kunde.de"}, ValidUntil: time.Now().AddDate(1, 0, 0),
Features: []string{"custom_middleware"},
}); err != nil {
t.Fatal(err)
}
s.store.CreateNode(ctx, store.Node{ID: "n1", TenantID: "kunde", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4", Status: store.NodeOffline})
s.store.SetTenantPayment(ctx, "kunde", time.Now().Add(-10*24*time.Hour))
nc, err := (&directory{store: s.store}).NodeContext(ctx, "n1")
if err != nil {
t.Fatal(err)
}
if !nc.License.HasFeature(license.FeatureCustomMiddleware) {
t.Error("Features nicht in der Lizenz")
}
if nc.Tenant.DunningLevel != 1 {
t.Errorf("Mahnstufe %d, erwartet 1 (10 Tage überfällig)", nc.Tenant.DunningLevel)
}
if !nc.Disconnected {
t.Error("getrennter Knoten nicht erkannt")
}
}
// recorder ist ein Benachrichtigungsziel für Tests; failFirst lässt die
// ersten n Zustellungen scheitern.
type recorder struct {
mu sync.Mutex
msgs []notify.Message
failFirst int
}
func (r *recorder) Send(_ context.Context, m notify.Message) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.failFirst > 0 {
r.failFirst--
return errors.New("Ziel nicht erreichbar")
}
r.msgs = append(r.msgs, m)
return nil
}
func (r *recorder) matching(substr string) []notify.Message {
r.mu.Lock()
defer r.mu.Unlock()
var out []notify.Message
for _, m := range r.msgs {
if strings.Contains(m.Title+" "+m.Body, substr) {
out = append(out, m)
}
}
return out
}
func fastJobs(rec *recorder) func(*Config) {
return func(c *Config) {
c.RefreshInterval = 20 * time.Millisecond
c.Notify = rec
}
}
func commercialTenant(t *testing.T, s *Server, id string, validFor time.Duration) {
t.Helper()
if _, err := s.store.CreateTenant(ctx, store.Tenant{
ID: id, Name: id, Email: id + "@example.com", Tier: store.TierPro, MaxRoutes: 10,
AllowedDomains: []string{"kunde.de"}, ValidUntil: time.Now().Add(validFor),
}); err != nil {
t.Fatal(err)
}
}
func TestCheckmkEndpoint(t *testing.T) {
s, _ := startServer(t)
commercialTenant(t, s, "kunde", 20*24*time.Hour)
connectedAgent(t, s, "n1")
resp, err := http.Get("http://" + s.ProviderAddr() + "/metrics/checkmk")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") {
t.Fatalf("Status %d, Content-Type %q", resp.StatusCode, resp.Header.Get("Content-Type"))
}
out := string(body)
for _, want := range []string{`1 "Sultan_Tenant_kunde" days_left=19;30;14`, `0 "Sultan_Payment_kunde"`, `0 "Sultan_Node_n1"`, `0 "Sultan_Policy_Violations"`} {
if !strings.Contains(out, want) {
t.Errorf("fehlt %q in:\n%s", want, out)
}
}
}
func TestJob_ExpiresCommercialTenantOnly(t *testing.T) {
rec := &recorder{}
s, _ := startServer(t, fastJobs(rec))
commercialTenant(t, s, "kunde", 150*time.Millisecond)
if _, err := s.store.CreateTenant(ctx, store.Tenant{
ID: "homelab", Name: "h", Email: "h@x.de", Tier: store.TierCommunity, MaxRoutes: 5,
AllowedDomains: []string{"x.de"}, ValidUntil: time.Now().Add(150 * time.Millisecond),
}); err != nil {
t.Fatal(err)
}
eventually(t, "kommerzieller Vertrag auf expired", func() bool {
tn, _ := s.store.GetTenant(ctx, "kunde")
return tn.Status == store.TenantExpired
})
time.Sleep(100 * time.Millisecond)
if tn, _ := s.store.GetTenant(ctx, "homelab"); tn.Status != store.TenantActive {
t.Errorf("Community abgelaufen: %s", tn.Status)
}
eventually(t, "Benachrichtigung Vertragsende", func() bool { return len(rec.matching("abgelaufen")) == 1 })
if m := rec.matching("abgelaufen")[0]; m.Severity != notify.Critical || !strings.Contains(m.Title, "kunde") {
t.Errorf("Nachricht: %+v", m)
}
}
func TestNotifications_ContractOnce(t *testing.T) {
rec := &recorder{}
s, _ := startServer(t, fastJobs(rec))
commercialTenant(t, s, "kunde", 10*24*time.Hour) // innerhalb der 14-Tage-Schwelle
eventually(t, "Benachrichtigung", func() bool { return len(rec.matching("kunde")) > 0 })
time.Sleep(150 * time.Millisecond) // mehrere Job-Runden
if got := rec.matching("kunde"); len(got) != 1 || got[0].Severity != notify.Critical {
t.Errorf("erwartet genau eine kritische Nachricht, bekommen %+v", got)
}
}
func TestNotifications_Dunning(t *testing.T) {
rec := &recorder{}
s, _ := startServer(t, fastJobs(rec))
commercialTenant(t, s, "kunde", 365*24*time.Hour)
s.store.SetTenantPayment(ctx, "kunde", time.Now().Add(-9*24*time.Hour))
eventually(t, "Mahnstufe 1", func() bool { return len(rec.matching("Mahnstufe 1")) == 1 })
if m := rec.matching("Mahnstufe 1")[0]; m.Severity != notify.Warning {
t.Errorf("Schweregrad: %v", m.Severity)
}
}
func TestNotifications_NodeOffline(t *testing.T) {
rec := &recorder{}
s, _ := startServer(t, fastJobs(rec), func(c *Config) { c.OfflineAlertAfter = 100 * time.Millisecond })
_, stop := connectedAgent(t, s, "n1")
_, stop2 := connectedAgent(t, s, "n2")
agent2, _ := connectedAgent(t, s, "n3")
agent2.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{Enabled: true})
_ = stop2
stop()
eventually(t, "Offline-Alarm", func() bool { return len(rec.matching("n1")) == 1 })
time.Sleep(150 * time.Millisecond)
if got := rec.matching("n1"); len(got) != 1 || got[0].Severity != notify.Critical {
t.Errorf("erwartet genau einen Alarm: %+v", got)
}
if len(rec.matching("n2")) != 0 || len(rec.matching("n3")) != 0 {
t.Error("Alarm für verbundenen Knoten")
}
}
func TestNotifications_NoAlarmDuringMaintenance(t *testing.T) {
rec := &recorder{}
s, _ := startServer(t, fastJobs(rec), func(c *Config) { c.OfflineAlertAfter = 50 * time.Millisecond })
agent, stop := connectedAgent(t, s, "n1")
agent.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{Enabled: true, Duration: "1h"})
stop()
time.Sleep(300 * time.Millisecond)
if got := rec.matching("n1"); len(got) != 0 {
t.Errorf("Alarm trotz angemeldeter Wartung: %+v", got)
}
}
func TestNotifications_Violation(t *testing.T) {
rec := &recorder{}
s, _ := startServer(t, fastJobs(rec))
agent, _ := connectedAgent(t, s, "n1")
agent.do(t, http.MethodPost, "/api/v1/state", wpState("bank.de"))
eventually(t, "Verstoß gemeldet", func() bool { return len(rec.matching("bank.de")) == 1 })
if m := rec.matching("bank.de")[0]; m.Severity != notify.Warning || !strings.Contains(m.Body, "n1") {
t.Errorf("Nachricht: %+v", m)
}
}
// Scheitert die Zustellung, wird es beim nächsten Durchlauf erneut versucht.
func TestNotifications_RetryOnFailure(t *testing.T) {
rec := &recorder{failFirst: 2}
s, _ := startServer(t, fastJobs(rec))
commercialTenant(t, s, "kunde", 10*24*time.Hour)
eventually(t, "Zustellung nach Wiederholung", func() bool { return len(rec.matching("kunde")) == 1 })
time.Sleep(100 * time.Millisecond)
if n := len(rec.matching("kunde")); n != 1 {
t.Errorf("%d Nachrichten, erwartet 1", n)
}
}
// Massenhaftes Durchprobieren von Einladungen wird gebremst – vor der
// Signaturprüfung, pro Client-IP.
func TestEnroll_RateLimited(t *testing.T) {
s, _ := startServer(t, func(c *Config) { c.EnrollLimit = 3 })
createTenant(t, s, "kunde", "kunde.de")
agent := newAgent(t, s, issueToken(t, s, "kunde"))
agent.secret = crypto.TokenPrefix + strings.Repeat("A", 43) // falsch geraten
for i := range 3 {
if r := agent.enroll(t, "n1"); r.StatusCode != http.StatusForbidden {
t.Fatalf("Versuch %d: %d", i+1, r.StatusCode)
}
}
r := agent.enroll(t, "n1")
if r.StatusCode != http.StatusTooManyRequests || r.Header.Get("Retry-After") == "" {
t.Errorf("4. Versuch: %d, Retry-After %q", r.StatusCode, r.Header.Get("Retry-After"))
}
// Auch unsignierte Fluten zählen.
req, _ := http.NewRequest(http.MethodPost, agent.base+"/api/v1/enroll", strings.NewReader(`{}`))
if resp, err := agent.hc.Do(req); err != nil || resp.StatusCode != http.StatusTooManyRequests {
t.Errorf("unsigniert: %v %v", resp, err)
}
}
func TestConfigDefaults_EnrollLimit(t *testing.T) {
if c := (Config{}).withDefaults(); c.EnrollLimit != 10 {
t.Errorf("EnrollLimit-Default %d, erwartet 10 pro Minute", c.EnrollLimit)
}
}
// „Wieder online“ nur nach einem Offline-Alarm und genau einmal pro Ausfall.
func TestNotifications_BackOnline(t *testing.T) {
rec := &recorder{}
s, _ := startServer(t, fastJobs(rec), func(c *Config) { c.OfflineAlertAfter = 100 * time.Millisecond })
agent, stop := connectedAgent(t, s, "n1")
stop()
eventually(t, "Offline-Alarm", func() bool { return len(rec.matching("offline")) == 1 })
agent.connect(t)
eventually(t, "wieder online", func() bool { return len(rec.matching("wieder online")) == 1 })
m := rec.matching("wieder online")[0]
if m.Severity != notify.Info || !strings.Contains(m.Body, "n1") {
t.Errorf("Nachricht: %+v", m)
}
time.Sleep(150 * time.Millisecond)
if n := len(rec.matching("wieder online")); n != 1 {
t.Errorf("%d Wieder-online-Nachrichten, erwartet 1", n)
}
}
func TestNotifications_NoBackOnlineWithoutAlarm(t *testing.T) {
rec := &recorder{}
s, _ := startServer(t, fastJobs(rec), func(c *Config) { c.OfflineAlertAfter = time.Hour })
agent, stop := connectedAgent(t, s, "n1")
stop()
eventually(t, "offline", func() bool { n, _ := s.store.GetNode(ctx, "n1"); return n.Status == store.NodeOffline })
agent.connect(t)
eventually(t, "online", func() bool { return s.hub.Connected("n1") })
time.Sleep(150 * time.Millisecond)
if got := rec.matching("wieder online"); len(got) != 0 {
t.Errorf("Meldung ohne vorherigen Alarm: %+v", got)
}
}
func TestConfigDefaults_EnrollLimitDisabled(t *testing.T) {
if c := (Config{EnrollLimit: -1}).withDefaults(); c.EnrollLimit != -1 {
t.Errorf("-1 (unbegrenzt) überschrieben: %d", c.EnrollLimit)
}
}
+63
View File
@@ -0,0 +1,63 @@
package store
import (
"context"
"time"
)
// PurgeLeases löscht Leases, die vor mehr als olderThan abgelaufen sind.
func (s *Store) PurgeLeases(ctx context.Context, olderThan time.Duration) (int64, error) {
res, err := s.db.ExecContext(ctx, `DELETE FROM leases WHERE valid_until <= ?`, formatTime(s.now().Add(-olderThan)))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// PurgeNodeCommands löscht zugestellte Befehle, die älter als olderThan sind.
func (s *Store) PurgeNodeCommands(ctx context.Context, olderThan time.Duration) (int64, error) {
res, err := s.db.ExecContext(ctx, `DELETE FROM node_commands WHERE sent_at IS NOT NULL AND sent_at <= ?`,
formatTime(s.now().Add(-olderThan)))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// Notified meldet, ob zum Ereignis key schon benachrichtigt wurde.
func (s *Store) Notified(ctx context.Context, key string) bool {
var one int
return s.db.QueryRowContext(ctx, `SELECT 1 FROM notifications WHERE key = ?`, key).Scan(&one) == nil
}
// RecordNotification vermerkt den Versand zu key (mehrfach unschädlich).
func (s *Store) RecordNotification(ctx context.Context, key string) error {
_, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO notifications (key, sent_at) VALUES (?, ?)`,
key, formatTime(s.now()))
return err
}
// MarkNodeSeen setzt last_seen auf jetzt (z. B. beim Trennen: "offline seit").
func (s *Store) MarkNodeSeen(ctx context.Context, nodeID string) error {
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET last_seen = ? WHERE id = ?`, formatTime(s.now()), nodeID)
return requireAffected(res, err, "Knoten "+nodeID)
}
// NotificationKeys liefert die vermerkten Ereignisse mit dem Präfix prefix.
func (s *Store) NotificationKeys(ctx context.Context, prefix string) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT key FROM notifications WHERE substr(key, 1, length(?)) = ? ORDER BY key`,
prefix, prefix)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
return nil, err
}
keys = append(keys, k)
}
return keys, rows.Err()
}
+101
View File
@@ -0,0 +1,101 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// Lease ist die aktuelle Lease eines Knotens. Document ist das signierte
// JSON (license.Lease); die Zeiten liegen zusätzlich als Spalten vor, damit
// der Server ohne Parsen entscheiden kann.
type Lease struct {
NodeID string
IssuedAt time.Time
RenewAfter time.Time
ValidUntil time.Time
Document string
}
// PutLease speichert bzw. ersetzt die Lease eines Knotens.
func (s *Store) PutLease(ctx context.Context, l Lease) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO leases (node_id, issued_at, renew_after, valid_until, document)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(node_id) DO UPDATE SET issued_at = excluded.issued_at, renew_after = excluded.renew_after,
valid_until = excluded.valid_until, document = excluded.document`,
l.NodeID, formatTime(l.IssuedAt), formatTime(l.RenewAfter), formatTime(l.ValidUntil), l.Document)
if sqliteCode(err) != 0 {
return fmt.Errorf("Lease für %s: Knoten: %w", l.NodeID, ErrNotFound)
}
return err
}
// GetLease liest die Lease eines Knotens.
func (s *Store) GetLease(ctx context.Context, nodeID string) (Lease, error) {
l := Lease{NodeID: nodeID}
err := s.db.QueryRowContext(ctx, `SELECT issued_at, renew_after, valid_until, document FROM leases WHERE node_id = ?`, nodeID).
Scan(dbTime{&l.IssuedAt}, dbTime{&l.RenewAfter}, dbTime{&l.ValidUntil}, &l.Document)
if errors.Is(err, sql.ErrNoRows) {
return Lease{}, fmt.Errorf("Lease für %s: %w", nodeID, ErrNotFound)
}
return l, err
}
// NodeCommand ist ein Befehl in der Warteschlange für einen Knoten. Die CLI
// (eigener Prozess) reiht ein, der Server stellt zu.
type NodeCommand struct {
ID int64
NodeID string
Command string
Reason string
CreatedAt time.Time
}
// EnqueueNodeCommand reiht einen Befehl für nodeID ein.
func (s *Store) EnqueueNodeCommand(ctx context.Context, nodeID, command, reason string) error {
if blank(command) {
return invalid("Befehl fehlt")
}
_, err := s.db.ExecContext(ctx, `INSERT INTO node_commands (node_id, command, reason, created_at) VALUES (?, ?, ?, ?)`,
nodeID, command, reason, formatTime(s.now()))
return mapConstraint(err, "Befehl für "+nodeID)
}
// PendingNodeCommands liefert die noch nicht zugestellten Befehle in Reihenfolge.
func (s *Store) PendingNodeCommands(ctx context.Context, nodeID string) ([]NodeCommand, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, command, reason, created_at FROM node_commands
WHERE node_id = ? AND sent_at IS NULL ORDER BY id`, nodeID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NodeCommand
for rows.Next() {
c := NodeCommand{NodeID: nodeID}
if err := rows.Scan(&c.ID, &c.Command, &c.Reason, dbTime{&c.CreatedAt}); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// MarkNodeCommandSent vermerkt die Zustellung.
func (s *Store) MarkNodeCommandSent(ctx context.Context, id int64) error {
res, err := s.db.ExecContext(ctx, `UPDATE node_commands SET sent_at = ? WHERE id = ?`, formatTime(s.now()), id)
return requireAffected(res, err, fmt.Sprintf("Befehl %d", id))
}
// UpdateNodePublicKey tauscht den Schlüssel eines Knotens (rotate_key).
func (s *Store) UpdateNodePublicKey(ctx context.Context, nodeID, publicKey string) error {
if blank(publicKey) {
return invalid("Public Key fehlt")
}
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET public_key = ? WHERE id = ?`, publicKey, nodeID)
if err != nil {
return mapConstraint(err, "Knoten "+nodeID)
}
return requireAffected(res, nil, "Knoten "+nodeID)
}
+61
View File
@@ -0,0 +1,61 @@
package store
import (
"context"
"strings"
"time"
"unicode"
"unicode/utf8"
)
// Maintenance ist eine angemeldete Wartung eines Knotens.
type Maintenance struct {
Since time.Time
Until time.Time // Nullwert = unbefristet
Reason string // intern (Log, Monitoring) – nie öffentlich
PublicReason string // optional, ersetzt den Standardtext der Wartungsseite
}
// Active meldet, ob die Wartung zum Zeitpunkt now gilt (nil = keine).
func (m *Maintenance) Active(now time.Time) bool {
return m != nil && (m.Until.IsZero() || now.Before(m.Until))
}
const (
maxPublicReason = 300
maxReason = 500
)
// validText: einzeilig, ohne Steuerzeichen, höchstens max Zeichen.
func validText(s string, max int) bool {
return utf8.RuneCountInString(s) <= max && !strings.ContainsFunc(s, unicode.IsControl)
}
// SetNodeMaintenance meldet eine Wartung an (Since = jetzt). m.Until muss
// in der Zukunft liegen oder Nullwert (unbefristet) sein.
func (s *Store) SetNodeMaintenance(ctx context.Context, nodeID string, m Maintenance) error {
now := s.now()
switch {
case !m.Until.IsZero() && !m.Until.After(now):
return invalid("Wartungsende liegt nicht in der Zukunft")
case !validText(m.PublicReason, maxPublicReason):
return invalid("öffentlicher Grund: einzeilig, höchstens %d Zeichen", maxPublicReason)
case !validText(m.Reason, maxReason):
return invalid("Grund: einzeilig, höchstens %d Zeichen", maxReason)
}
var until any
if !m.Until.IsZero() {
until = formatTime(m.Until)
}
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET maintenance_since = ?, maintenance_until = ?,
maintenance_reason = ?, maintenance_public_reason = ? WHERE id = ?`,
formatTime(now), until, m.Reason, m.PublicReason, nodeID)
return requireAffected(res, err, "Knoten "+nodeID)
}
// ClearNodeMaintenance beendet die Wartung eines Knotens.
func (s *Store) ClearNodeMaintenance(ctx context.Context, nodeID string) error {
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET maintenance_since = NULL, maintenance_until = NULL,
maintenance_reason = '', maintenance_public_reason = '' WHERE id = ?`, nodeID)
return requireAffected(res, err, "Knoten "+nodeID)
}
+79
View File
@@ -0,0 +1,79 @@
package store
import (
"errors"
"testing"
"time"
)
func TestNodeMaintenance(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
if n, _ := s.GetNode(ctx, "n1"); n.Maintenance != nil {
t.Fatalf("neuer Knoten mit Wartung: %+v", n.Maintenance)
}
until := clock.now().Add(4 * time.Hour)
err := s.SetNodeMaintenance(ctx, "n1", Maintenance{
Until: until, Reason: "Kernel-Update", PublicReason: "Wir spielen ein Sicherheitsupdate ein.",
})
if err != nil {
t.Fatalf("SetNodeMaintenance: %v", err)
}
n, _ := s.GetNode(ctx, "n1")
m := n.Maintenance
if m == nil || !m.Since.Equal(clock.now()) || !m.Until.Equal(until) ||
m.Reason != "Kernel-Update" || m.PublicReason != "Wir spielen ein Sicherheitsupdate ein." {
t.Errorf("Wartung: %+v", m)
}
if n.Status != NodeOnline {
t.Errorf("Wartung darf den Verbindungsstatus nicht ändern: %s", n.Status)
}
if nodes, _ := s.ListNodes(ctx, ""); nodes[0].Maintenance == nil {
t.Error("ListNodes ohne Wartungsdaten")
}
// Unbefristet: Until bleibt Nullwert.
if err := s.SetNodeMaintenance(ctx, "n1", Maintenance{Reason: "Umbau"}); err != nil {
t.Fatal(err)
}
if n, _ := s.GetNode(ctx, "n1"); n.Maintenance == nil || !n.Maintenance.Until.IsZero() {
t.Errorf("unbefristet: %+v", n.Maintenance)
}
if err := s.ClearNodeMaintenance(ctx, "n1"); err != nil {
t.Fatal(err)
}
if n, _ := s.GetNode(ctx, "n1"); n.Maintenance != nil {
t.Errorf("nach Clear: %+v", n.Maintenance)
}
if err := s.SetNodeMaintenance(ctx, "fehlt", Maintenance{}); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Knoten: %v", err)
}
if err := s.ClearNodeMaintenance(ctx, "fehlt"); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Knoten: %v", err)
}
if err := s.SetNodeMaintenance(ctx, "n1", Maintenance{Until: clock.now().Add(-time.Minute)}); !errors.Is(err, ErrInvalid) {
t.Errorf("Ende in der Vergangenheit: %v", err)
}
}
func TestMaintenanceActive(t *testing.T) {
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
m *Maintenance
want bool
}{
{"keine", nil, false},
{"unbefristet", &Maintenance{Since: now.Add(-time.Hour)}, true},
{"läuft", &Maintenance{Since: now.Add(-time.Hour), Until: now.Add(time.Minute)}, true},
{"abgelaufen", &Maintenance{Since: now.Add(-time.Hour), Until: now}, false},
}
for _, tt := range tests {
if got := tt.m.Active(now); got != tt.want {
t.Errorf("%s: Active = %v", tt.name, got)
}
}
}
+11 -2
View File
@@ -30,6 +30,9 @@ type Node struct {
LastSeen time.Time
IPAddress string
Status NodeStatus
// Maintenance ist die angemeldete Wartung (nil = keine). Sie ist eine
// eigene Dimension: Ein Knoten in Wartung kann verbunden sein oder nicht.
Maintenance *Maintenance
}
func (n *Node) normalize(now time.Time) error {
@@ -79,11 +82,17 @@ func (s *Store) insertNode(ctx context.Context, db execer, n Node) (Node, error)
return n, nil
}
const nodeColumns = `id, tenant_id, hostname, public_key, last_seen, ip_address, status`
const nodeColumns = `id, tenant_id, hostname, public_key, last_seen, ip_address, status,
maintenance_since, maintenance_until, maintenance_reason, maintenance_public_reason`
func scanNode(row interface{ Scan(...any) error }) (Node, error) {
var n Node
err := row.Scan(&n.ID, &n.TenantID, &n.Hostname, &n.PublicKey, dbTime{&n.LastSeen}, &n.IPAddress, &n.Status)
var m Maintenance
err := row.Scan(&n.ID, &n.TenantID, &n.Hostname, &n.PublicKey, dbTime{&n.LastSeen}, &n.IPAddress, &n.Status,
dbTime{&m.Since}, dbTime{&m.Until}, &m.Reason, &m.PublicReason)
if err == nil && !m.Since.IsZero() {
n.Maintenance = &m
}
return n, err
}
+29
View File
@@ -0,0 +1,29 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
)
// Setting liest eine Server-Einstellung (ErrNotFound, wenn nicht gesetzt).
func (s *Store) Setting(ctx context.Context, key string) (string, error) {
var v string
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("Einstellung %s: %w", key, ErrNotFound)
}
return v, err
}
// SetSetting setzt bzw. überschreibt eine Server-Einstellung.
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
if blank(key) {
return invalid("Schlüssel fehlt")
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
key, value)
return err
}
+44
View File
@@ -0,0 +1,44 @@
package store
import (
"errors"
"path/filepath"
"testing"
)
func TestSettings(t *testing.T) {
s, _ := openTest(t)
if _, err := s.Setting(ctx, "public_url"); !errors.Is(err, ErrNotFound) {
t.Errorf("fehlende Einstellung: erwartet ErrNotFound, bekommen %v", err)
}
if err := s.SetSetting(ctx, "public_url", "https://a.example:8443"); err != nil {
t.Fatal(err)
}
if err := s.SetSetting(ctx, "public_url", "https://b.example:8443"); err != nil {
t.Fatal(err)
}
if v, err := s.Setting(ctx, "public_url"); err != nil || v != "https://b.example:8443" {
t.Errorf("Setting = %q, %v", v, err)
}
if err := s.SetSetting(ctx, "", "x"); !errors.Is(err, ErrInvalid) {
t.Errorf("leerer Schlüssel: %v", err)
}
}
func TestSettings_SurviveReopen(t *testing.T) {
path := filepath.Join(t.TempDir(), "sultan.db")
s, err := Open(ctx, path)
if err != nil {
t.Fatal(err)
}
s.SetSetting(ctx, "public_url", "https://a.example:8443")
s.Close()
s, _ = Open(ctx, path)
defer s.Close()
if v, _ := s.Setting(ctx, "public_url"); v != "https://a.example:8443" {
t.Errorf("nach Neustart: %q", v)
}
if v, _ := s.SchemaVersion(ctx); v < 2 {
t.Errorf("Schema-Version %d, erwartet mindestens 2", v)
}
}
+38
View File
@@ -64,6 +64,41 @@ var migrations = []string{
used INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX enrollment_tokens_tenant_id ON enrollment_tokens(tenant_id);`,
// v2: Server-Einstellungen, die auch `sultan token` (eigener Prozess) braucht.
`CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);`,
// v3: Wartung pro Knoten (eigene Dimension neben online/offline/drained).
`ALTER TABLE nodes ADD COLUMN maintenance_since DATETIME;
ALTER TABLE nodes ADD COLUMN maintenance_until DATETIME;
ALTER TABLE nodes ADD COLUMN maintenance_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE nodes ADD COLUMN maintenance_public_reason TEXT NOT NULL DEFAULT '';`,
// v4: Leases, Befehlswarteschlange, Features und Zahlungsstatus, Einlösezeitpunkt.
`CREATE TABLE leases (
node_id TEXT PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
issued_at DATETIME NOT NULL,
renew_after DATETIME NOT NULL,
valid_until DATETIME NOT NULL,
document TEXT NOT NULL
);
CREATE TABLE node_commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
command TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
sent_at DATETIME
);
CREATE INDEX node_commands_pending ON node_commands(node_id, sent_at);
ALTER TABLE tenants ADD COLUMN features TEXT NOT NULL DEFAULT '[]';
ALTER TABLE tenants ADD COLUMN payment_overdue_since DATETIME;
ALTER TABLE enrollment_tokens ADD COLUMN used_at DATETIME;`,
// v5: versandte Benachrichtigungen (jedes Ereignis genau einmal).
`CREATE TABLE notifications (
key TEXT PRIMARY KEY,
sent_at DATETIME NOT NULL
);`,
}
// Store ist der Zugriff auf die SQLite-Datenbank. Er ist für parallele
@@ -147,6 +182,9 @@ type dbTime struct{ t *time.Time }
func (d dbTime) Scan(src any) error {
switch v := src.(type) {
case nil: // NULL = nicht gesetzt
*d.t = time.Time{}
return nil
case time.Time:
*d.t = normTime(v)
return nil
+47 -17
View File
@@ -50,6 +50,10 @@ type Tenant struct {
AllowedDomains []string
ValidUntil time.Time
Status TenantStatus
Features []string // freigeschaltete Lizenz-Features (z. B. custom_middleware)
// PaymentOverdueSince: seit wann eine Zahlung offen ist (Nullwert = nichts
// offen); daraus berechnet die Policy die Mahnstufe.
PaymentOverdueSince time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -66,6 +70,9 @@ func (t *Tenant) normalize() error {
if t.AllowedDomains == nil {
t.AllowedDomains = []string{}
}
if t.Features == nil {
t.Features = []string{}
}
t.ValidUntil = normTime(t.ValidUntil)
switch {
@@ -89,6 +96,11 @@ func (t *Tenant) normalize() error {
return invalid("leerer Eintrag in allowed_domains")
}
}
for _, f := range t.Features {
if blank(f) {
return invalid("leerer Eintrag in features")
}
}
return nil
}
@@ -98,40 +110,47 @@ func (s *Store) CreateTenant(ctx context.Context, t Tenant) (Tenant, error) {
if err := t.normalize(); err != nil {
return Tenant{}, err
}
domains, err := json.Marshal(t.AllowedDomains)
if err != nil {
return Tenant{}, err
}
domains, _ := json.Marshal(t.AllowedDomains)
features, _ := json.Marshal(t.Features)
now := normTime(s.now())
t.CreatedAt, t.UpdatedAt = now, now
t.PaymentOverdueSince = time.Time{}
_, err = s.db.ExecContext(ctx, `INSERT INTO tenants
(id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
_, err := s.db.ExecContext(ctx, `INSERT INTO tenants
(id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at, features)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Name, t.Email, t.Tier, t.MaxRoutes, string(domains),
formatTime(t.ValidUntil), t.Status, formatTime(now), formatTime(now))
formatTime(t.ValidUntil), t.Status, formatTime(now), formatTime(now), string(features))
if err != nil {
return Tenant{}, mapConstraint(err, "Mandant "+t.ID)
}
return t, nil
}
const tenantColumns = `id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at`
const tenantColumns = `id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at,
features, payment_overdue_since`
func scanTenant(row interface{ Scan(...any) error }) (Tenant, error) {
var t Tenant
var domains string
var domains, features string
err := row.Scan(&t.ID, &t.Name, &t.Email, &t.Tier, &t.MaxRoutes, &domains,
dbTime{&t.ValidUntil}, &t.Status, dbTime{&t.CreatedAt}, dbTime{&t.UpdatedAt})
dbTime{&t.ValidUntil}, &t.Status, dbTime{&t.CreatedAt}, dbTime{&t.UpdatedAt},
&features, dbTime{&t.PaymentOverdueSince})
if err != nil {
return Tenant{}, err
}
if err := json.Unmarshal([]byte(domains), &t.AllowedDomains); err != nil {
return Tenant{}, fmt.Errorf("store: allowed_domains von %s: %w", t.ID, err)
}
if err := json.Unmarshal([]byte(features), &t.Features); err != nil {
return Tenant{}, fmt.Errorf("store: features von %s: %w", t.ID, err)
}
if t.AllowedDomains == nil {
t.AllowedDomains = []string{}
}
if t.Features == nil {
t.Features = []string{}
}
return t, nil
}
@@ -168,15 +187,14 @@ func (s *Store) UpdateTenant(ctx context.Context, t Tenant) (Tenant, error) {
if err := t.normalize(); err != nil {
return Tenant{}, err
}
domains, err := json.Marshal(t.AllowedDomains)
if err != nil {
return Tenant{}, err
}
domains, _ := json.Marshal(t.AllowedDomains)
features, _ := json.Marshal(t.Features)
res, err := s.db.ExecContext(ctx, `UPDATE tenants SET
name = ?, email = ?, tier = ?, max_routes = ?, allowed_domains = ?, valid_until = ?, status = ?, updated_at = ?
name = ?, email = ?, tier = ?, max_routes = ?, allowed_domains = ?, valid_until = ?, status = ?, updated_at = ?,
features = ?
WHERE id = ?`,
t.Name, t.Email, t.Tier, t.MaxRoutes, string(domains), formatTime(t.ValidUntil), t.Status,
formatTime(s.now()), t.ID)
formatTime(s.now()), string(features), t.ID)
if err := requireAffected(res, err, "Mandant "+t.ID); err != nil {
return Tenant{}, err
}
@@ -193,6 +211,18 @@ func (s *Store) SetTenantStatus(ctx context.Context, id string, status TenantSta
return requireAffected(res, err, "Mandant "+id)
}
// SetTenantPayment setzt, seit wann eine Zahlung offen ist (Nullwert =
// bezahlt). Grundlage der Mahnstufen.
func (s *Store) SetTenantPayment(ctx context.Context, id string, overdueSince time.Time) error {
var v any
if !overdueSince.IsZero() {
v = formatTime(overdueSince)
}
res, err := s.db.ExecContext(ctx, `UPDATE tenants SET payment_overdue_since = ?, updated_at = ? WHERE id = ?`,
v, formatTime(s.now()), id)
return requireAffected(res, err, "Mandant "+id)
}
// DeleteTenant löscht den Mandanten samt seiner Knoten und Tokens.
func (s *Store) DeleteTenant(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM tenants WHERE id = ?`, id)
+8 -6
View File
@@ -62,7 +62,8 @@ func (s *Store) Enroll(ctx context.Context, token string, n Node) (Node, error)
return Node{}, ErrTokenExpired
}
if _, err := tx.ExecContext(ctx, `UPDATE enrollment_tokens SET used = 1 WHERE token_hash = ?`, hash); err != nil {
if _, err := tx.ExecContext(ctx, `UPDATE enrollment_tokens SET used = 1, used_at = ? WHERE token_hash = ?`,
formatTime(s.now()), hash); err != nil {
return Node{}, err
}
n.TenantID = tenantID
@@ -76,11 +77,12 @@ func (s *Store) Enroll(ctx context.Context, token string, n Node) (Node, error)
return node, nil
}
// PurgeEnrollmentTokens löscht verbrauchte und abgelaufene Tokens und
// liefert ihre Anzahl.
func (s *Store) PurgeEnrollmentTokens(ctx context.Context) (int64, error) {
res, err := s.db.ExecContext(ctx,
`DELETE FROM enrollment_tokens WHERE used = 1 OR expires_at <= ?`, formatTime(s.now()))
// PurgeEnrollmentTokens löscht Einladungen, die vor mehr als olderThan
// eingelöst wurden oder abgelaufen sind (0 = sofort), und liefert ihre Anzahl.
func (s *Store) PurgeEnrollmentTokens(ctx context.Context, olderThan time.Duration) (int64, error) {
cutoff := formatTime(s.now().Add(-olderThan))
res, err := s.db.ExecContext(ctx, `DELETE FROM enrollment_tokens
WHERE (used = 1 AND (used_at IS NULL OR used_at <= ?)) OR expires_at <= ?`, cutoff, cutoff)
if err != nil {
return 0, err
}
+1 -1
View File
@@ -172,7 +172,7 @@ func TestPurgeEnrollmentTokens(t *testing.T) {
}
clock.advance(time.Minute)
n, err := s.PurgeEnrollmentTokens(ctx)
n, err := s.PurgeEnrollmentTokens(ctx, 0)
if err != nil {
t.Fatal(err)
}
+155
View File
@@ -0,0 +1,155 @@
package store
import (
"errors"
"reflect"
"testing"
"time"
)
func TestLeases(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
if _, err := s.GetLease(ctx, "n1"); !errors.Is(err, ErrNotFound) {
t.Errorf("ohne Lease: %v", err)
}
l := Lease{
NodeID: "n1",
IssuedAt: clock.now(),
RenewAfter: clock.now().Add(7 * 24 * time.Hour),
ValidUntil: clock.now().Add(14 * 24 * time.Hour),
Document: `{"node_id":"n1"}`,
}
if err := s.PutLease(ctx, l); err != nil {
t.Fatalf("PutLease: %v", err)
}
got, err := s.GetLease(ctx, "n1")
if err != nil || !reflect.DeepEqual(got, l) {
t.Errorf("GetLease = %+v, %v", got, err)
}
l.ValidUntil = l.ValidUntil.Add(time.Hour)
if err := s.PutLease(ctx, l); err != nil {
t.Fatal(err)
}
if got, _ := s.GetLease(ctx, "n1"); !got.ValidUntil.Equal(l.ValidUntil) {
t.Error("PutLease ersetzt nicht")
}
if err := s.PutLease(ctx, Lease{NodeID: "fehlt", Document: "{}"}); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Knoten: %v", err)
}
s.DeleteNode(ctx, "n1")
if _, err := s.GetLease(ctx, "n1"); !errors.Is(err, ErrNotFound) {
t.Error("Lease nach Löschen des Knotens noch da")
}
}
func TestTenantFeaturesAndPayment(t *testing.T) {
s, clock := openTest(t)
in := sampleTenant("t1")
in.Features = []string{"custom_middleware", "websockets"}
created, err := s.CreateTenant(ctx, in)
if err != nil {
t.Fatal(err)
}
got, _ := s.GetTenant(ctx, "t1")
if !reflect.DeepEqual(got.Features, []string{"custom_middleware", "websockets"}) || !reflect.DeepEqual(got, created) {
t.Errorf("Features: %+v", got.Features)
}
if !got.PaymentOverdueSince.IsZero() {
t.Error("neuer Mandant mit offener Zahlung")
}
if def, _ := s.CreateTenant(ctx, sampleTenant("t2")); def.Features == nil || len(def.Features) != 0 {
t.Errorf("Default-Features: %#v", def.Features)
}
due := clock.now().Add(-10 * 24 * time.Hour)
if err := s.SetTenantPayment(ctx, "t1", due); err != nil {
t.Fatal(err)
}
if got, _ := s.GetTenant(ctx, "t1"); !got.PaymentOverdueSince.Equal(due) {
t.Errorf("offen seit: %v", got.PaymentOverdueSince)
}
if err := s.SetTenantPayment(ctx, "t1", time.Time{}); err != nil {
t.Fatal(err)
}
if got, _ := s.GetTenant(ctx, "t1"); !got.PaymentOverdueSince.IsZero() {
t.Error("bezahlt: Fälligkeit nicht gelöscht")
}
if err := s.SetTenantPayment(ctx, "fehlt", due); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Mandant: %v", err)
}
bad := sampleTenant("t3")
bad.Features = []string{"ok", " "}
if _, err := s.CreateTenant(ctx, bad); !errors.Is(err, ErrInvalid) {
t.Errorf("leeres Feature: %v", err)
}
}
func TestEnroll_RecordsUsedAt(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
s.AddEnrollmentToken(ctx, "tok", "t1", clock.now().Add(time.Hour))
clock.advance(time.Minute)
if _, err := s.Enroll(ctx, "tok", sampleNode("n1", "k1")); err != nil {
t.Fatal(err)
}
var usedAt time.Time
if err := s.db.QueryRowContext(ctx, "SELECT used_at FROM enrollment_tokens").Scan(dbTime{&usedAt}); err != nil {
t.Fatal(err)
}
if !usedAt.Equal(clock.now()) {
t.Errorf("used_at = %v, erwartet %v", usedAt, clock.now())
}
}
func TestNodeCommands(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
if err := s.EnqueueNodeCommand(ctx, "n1", "resync", ""); err != nil {
t.Fatal(err)
}
clock.advance(time.Second)
s.EnqueueNodeCommand(ctx, "n1", "drain", "Umzug")
cmds, err := s.PendingNodeCommands(ctx, "n1")
if err != nil || len(cmds) != 2 || cmds[0].Command != "resync" || cmds[1].Command != "drain" || cmds[1].Reason != "Umzug" {
t.Fatalf("ausstehend: %+v %v", cmds, err)
}
if err := s.MarkNodeCommandSent(ctx, cmds[0].ID); err != nil {
t.Fatal(err)
}
if cmds, _ := s.PendingNodeCommands(ctx, "n1"); len(cmds) != 1 || cmds[0].Command != "drain" {
t.Errorf("nach Versand: %+v", cmds)
}
if err := s.EnqueueNodeCommand(ctx, "fehlt", "resync", ""); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Knoten: %v", err)
}
if err := s.EnqueueNodeCommand(ctx, "n1", "", ""); !errors.Is(err, ErrInvalid) {
t.Errorf("leerer Befehl: %v", err)
}
}
func TestUpdateNodePublicKey(t *testing.T) {
s, _ := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
mustNode(t, s, "n2", "t1", "k2")
if err := s.UpdateNodePublicKey(ctx, "n1", "k1-neu"); err != nil {
t.Fatal(err)
}
if n, _ := s.GetNodeByPublicKey(ctx, "k1-neu"); n.ID != "n1" {
t.Errorf("neuer Schlüssel nicht gefunden: %+v", n)
}
if _, err := s.GetNodeByPublicKey(ctx, "k1"); !errors.Is(err, ErrNotFound) {
t.Error("alter Schlüssel noch gültig")
}
if err := s.UpdateNodePublicKey(ctx, "n1", "k2"); !errors.Is(err, ErrExists) {
t.Errorf("fremder Schlüssel: %v", err)
}
if err := s.UpdateNodePublicKey(ctx, "fehlt", "x"); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Knoten: %v", err)
}
}
+97
View File
@@ -0,0 +1,97 @@
package store
import (
"testing"
"time"
)
// Aufräumen erst nach einer Frist (Job: 10 Tage), CLI-purge sofort (0).
func TestPurgeEnrollmentTokens_Retention(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
start := clock.now()
s.AddEnrollmentToken(ctx, "alt-benutzt", "t1", start.Add(time.Hour))
s.AddEnrollmentToken(ctx, "jung-benutzt", "t1", start.Add(30*24*time.Hour))
s.AddEnrollmentToken(ctx, "alt-abgelaufen", "t1", start.Add(time.Hour))
s.AddEnrollmentToken(ctx, "gueltig", "t1", start.Add(30*24*time.Hour))
s.Enroll(ctx, "alt-benutzt", sampleNode("n1", "k1"))
clock.advance(9 * 24 * time.Hour)
s.Enroll(ctx, "jung-benutzt", sampleNode("n2", "k2"))
clock.advance(2 * 24 * time.Hour) // jetzt: alt-* vor > 10 Tagen benutzt/abgelaufen
n, err := s.PurgeEnrollmentTokens(ctx, 10*24*time.Hour)
if err != nil || n != 2 {
t.Errorf("gelöscht %d (%v), erwartet 2 (alt-benutzt, alt-abgelaufen)", n, err)
}
if n, _ := s.PurgeEnrollmentTokens(ctx, 0); n != 1 {
t.Errorf("sofort: %d gelöscht, erwartet 1 (jung-benutzt)", n)
}
}
func TestPurgeLeasesAndCommands(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
mustNode(t, s, "n2", "t1", "k2")
now := clock.now()
s.PutLease(ctx, Lease{NodeID: "n1", IssuedAt: now, RenewAfter: now, ValidUntil: now.Add(time.Hour), Document: "{}"})
s.PutLease(ctx, Lease{NodeID: "n2", IssuedAt: now, RenewAfter: now, ValidUntil: now.Add(20 * 24 * time.Hour), Document: "{}"})
s.EnqueueNodeCommand(ctx, "n1", "resync", "")
s.EnqueueNodeCommand(ctx, "n1", "drain", "")
cmds, _ := s.PendingNodeCommands(ctx, "n1")
s.MarkNodeCommandSent(ctx, cmds[0].ID)
clock.advance(11 * 24 * time.Hour)
if n, err := s.PurgeLeases(ctx, 10*24*time.Hour); err != nil || n != 1 {
t.Errorf("Leases: %d %v", n, err)
}
if _, err := s.GetLease(ctx, "n2"); err != nil {
t.Error("gültige Lease gelöscht")
}
if n, err := s.PurgeNodeCommands(ctx, 10*24*time.Hour); err != nil || n != 1 {
t.Errorf("Befehle: %d %v", n, err)
}
if cmds, _ := s.PendingNodeCommands(ctx, "n1"); len(cmds) != 1 {
t.Error("nicht zugestellter Befehl gelöscht")
}
}
func TestNotificationsOnce(t *testing.T) {
s, _ := openTest(t)
if s.Notified(ctx, "contract:t1:2026-12-15:14") {
t.Error("neu: schon benachrichtigt")
}
if err := s.RecordNotification(ctx, "contract:t1:2026-12-15:14"); err != nil {
t.Fatal(err)
}
if !s.Notified(ctx, "contract:t1:2026-12-15:14") {
t.Error("nicht vermerkt")
}
if err := s.RecordNotification(ctx, "contract:t1:2026-12-15:14"); err != nil {
t.Errorf("doppelt vermerken: %v", err)
}
}
func TestMarkNodeSeen(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
clock.advance(time.Hour)
if err := s.MarkNodeSeen(ctx, "n1"); err != nil {
t.Fatal(err)
}
if n, _ := s.GetNode(ctx, "n1"); !n.LastSeen.Equal(clock.now()) {
t.Errorf("last_seen = %v", n.LastSeen)
}
}
func TestNotificationKeysWithPrefix(t *testing.T) {
s, _ := openTest(t)
for _, k := range []string{"offline:n1:a", "offline:n1:b", "offline:n10:c", "contract:x"} {
s.RecordNotification(ctx, k)
}
got, err := s.NotificationKeys(ctx, "offline:n1:")
if err != nil || len(got) != 2 || got[0] != "offline:n1:a" || got[1] != "offline:n1:b" {
t.Errorf("Schlüssel: %v %v", got, err)
}
}
+6
View File
@@ -38,6 +38,9 @@ type Client struct {
MinBackoff, MaxBackoff time.Duration
// OnError wird in Run vor jeder Wartezeit mit dem Grund aufgerufen.
OnError func(err error, retryIn time.Duration)
// OnConnect wird nach jedem erfolgreichen Verbindungsaufbau aufgerufen,
// bevor Events gelesen werden – z. B. um den eigenen Zustand neu zu senden.
OnConnect func()
sleep func(ctx context.Context, d time.Duration) error // für Tests ersetzbar
}
@@ -102,6 +105,9 @@ func (c *Client) connect(ctx context.Context, handle func(Event) error) (connect
return false, fmt.Errorf("%w: Content-Type %q", ErrUnexpectedResponse, resp.Header.Get("Content-Type"))
}
if c.OnConnect != nil {
c.OnConnect()
}
idle := c.idleTimeout()
timer := time.AfterFunc(idle, func() { cancel(ErrIdleTimeout) })
defer timer.Stop()
+33
View File
@@ -331,3 +331,36 @@ func TestSleepCtx(t *testing.T) {
t.Error("sleepCtx hat trotz Abbruch gewartet")
}
}
// OnConnect wird nach jedem erfolgreichen Verbindungsaufbau aufgerufen –
// nicht bei Fehlantworten.
func TestClient_OnConnect(t *testing.T) {
var requests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requests.Add(1)%2 == 1 {
http.Error(w, "später", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/event-stream")
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var connects, sleeps atomic.Int32
c := &Client{
URL: srv.URL,
OnConnect: func() { connects.Add(1) },
sleep: func(ctx context.Context, d time.Duration) error {
if sleeps.Add(1) == 4 {
cancel()
return ctx.Err()
}
return nil
},
}
c.Run(ctx, func(Event) error { return nil })
if n := connects.Load(); n != 2 {
t.Errorf("OnConnect %d-mal, erwartet 2 (bei 4 Versuchen, davon 2 erfolgreich)", n)
}
}
+3
View File
@@ -42,6 +42,9 @@ type Event struct {
Type EventType `json:"type"`
NodeID string `json:"node_id"`
Payload json.RawMessage `json:"payload,omitempty"`
// Signature: Ed25519-Signatur des Servers über alle übrigen Felder
// (SignEvent/VerifyEvent); der Agent verwirft Events ohne gültige Signatur.
Signature string `json:"signature,omitempty"`
}
// NewEvent erzeugt ein Event mit neuer ID, aktuellem Zeitstempel und der als
+16 -6
View File
@@ -1,6 +1,7 @@
package stream
import (
"crypto/ed25519"
"errors"
"fmt"
"net/http"
@@ -41,6 +42,9 @@ type Hub struct {
// nur ein weiteres OnConnect, kein OnDisconnect.
OnConnect func(nodeID string)
OnDisconnect func(nodeID string)
// SigningKey: Ist er gesetzt, signiert der Hub jedes Event, nachdem er ID,
// Zeitstempel und Knoten-ID ergänzt hat.
SigningKey ed25519.PrivateKey
mu sync.Mutex
conns map[string]*conn
@@ -86,6 +90,15 @@ func (h *Hub) Serve(w http.ResponseWriter, r *http.Request, nodeID string) error
return ErrHubClosed
}
// Erst registrieren, dann die Header senden: Sobald der Client die
// Antwort sieht, muss der Knoten als verbunden gelten (der Agent schickt
// direkt danach seinen Zustand).
c := &conn{frames: make(chan []byte, h.bufferSize()), done: make(chan struct{})}
if err := h.register(nodeID, c); err != nil {
return err
}
defer h.unregister(nodeID, c)
rc := http.NewResponseController(w)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
@@ -98,12 +111,6 @@ func (h *Hub) Serve(w http.ResponseWriter, r *http.Request, nodeID string) error
return err
}
c := &conn{frames: make(chan []byte, h.bufferSize()), done: make(chan struct{})}
if err := h.register(nodeID, c); err != nil {
return err
}
defer h.unregister(nodeID, c)
ticker := time.NewTicker(h.keepAlive())
defer ticker.Stop()
write := func(p []byte) error {
@@ -185,6 +192,9 @@ func (h *Hub) Send(nodeID string, ev Event) error {
ev.Timestamp = time.Now().Unix()
}
ev.NodeID = nodeID
if h.SigningKey != nil {
ev = SignEvent(ev, h.SigningKey)
}
frame, err := encode(ev)
if err != nil {
return err
+13
View File
@@ -404,3 +404,16 @@ func TestHub_Disconnect(t *testing.T) {
t.Errorf("zweites Disconnect: erwartet ErrNodeOffline, bekommen %v", err)
}
}
// Sobald der Client die Antwort-Header hat, muss der Knoten registriert sein –
// sonst scheitert ein direkt folgender Request ("Stream nicht verbunden").
func TestHub_ConnectedWhenHeadersArrive(t *testing.T) {
for range 50 {
h := &Hub{}
srv, _ := hubServer(t, h)
openStream(t, context.Background(), srv, "n")
if !h.Connected("n") {
t.Fatal("Header empfangen, Knoten aber noch nicht registriert")
}
}
}
+78
View File
@@ -0,0 +1,78 @@
package stream
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
)
var (
// ErrUnsigned: Das Event trägt keine Signatur.
ErrUnsigned = errors.New("stream: Event nicht signiert")
// ErrBadSignature: Die Signatur passt nicht (fremder Schlüssel oder verändertes Event).
ErrBadSignature = errors.New("stream: Event-Signatur ungültig")
)
const eventSignatureVersion = "SULTAN-EVENT-V1\n"
func eventMessage(ev Event) []byte {
ev.Signature = ""
b, _ := json.Marshal(ev)
return append([]byte(eventSignatureVersion), b...)
}
// SignEvent signiert ev (alle Felder außer der Signatur) mit dem Master-Key.
func SignEvent(ev Event, key ed25519.PrivateKey) Event {
ev.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(key, eventMessage(ev)))
return ev
}
// VerifyEvent prüft die Signatur mit dem Public Key des Master-Keys.
func VerifyEvent(ev Event, pub ed25519.PublicKey) error {
if ev.Signature == "" {
return ErrUnsigned
}
sig, err := base64.StdEncoding.DecodeString(ev.Signature)
if err != nil || len(sig) != ed25519.SignatureSize || len(pub) != ed25519.PublicKeySize {
return ErrBadSignature
}
if !ed25519.Verify(pub, eventMessage(ev), sig) {
return ErrBadSignature
}
return nil
}
// Befehle in node_command – eine feste Liste, keine beliebigen Kommandos.
const (
CmdResync = "resync" // Agent sendet sofort seinen Zustand
CmdRotateKey = "rotate_key" // Agent erzeugt einen neuen node.key
CmdDrain = "drain" // Hinweis: Routen entfernt
CmdMaintenance = "maintenance" // Hinweis: Wartung angemeldet
CmdResume = "resume" // Hinweis: Wartung/drain beendet
CmdConfig = "config" // Server-Adressen (ServerURLs) für den Ausweichweg
)
// Commands sind alle bekannten Befehle.
var Commands = []string{CmdResync, CmdRotateKey, CmdDrain, CmdMaintenance, CmdResume, CmdConfig}
// NodeCommand ist die Payload eines node_command-Events.
type NodeCommand struct {
Command string `json:"command"`
Reason string `json:"reason,omitempty"`
// ServerURLs: bei config die Adressen, unter denen der Server erreichbar ist.
ServerURLs []string `json:"server_urls,omitempty"`
}
// ParseNodeCommand liest die Payload eines node_command-Events.
func ParseNodeCommand(ev Event) (NodeCommand, error) {
if ev.Type != EventNodeCommand {
return NodeCommand{}, fmt.Errorf("%w: kein node_command", ErrInvalidEvent)
}
var c NodeCommand
if err := json.Unmarshal(ev.Payload, &c); err != nil || c.Command == "" {
return NodeCommand{}, fmt.Errorf("%w: Befehl fehlt", ErrInvalidEvent)
}
return c, nil
}
+86
View File
@@ -0,0 +1,86 @@
package stream
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"errors"
"testing"
)
func TestSignAndVerifyEvent(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
ev := Event{ID: "evt_1", Timestamp: 42, Type: EventNodeCommand, NodeID: "n1", Payload: json.RawMessage(`{"command":"resync"}`)}
signed := SignEvent(ev, priv)
if signed.Signature == "" {
t.Fatal("keine Signatur")
}
if err := VerifyEvent(signed, pub); err != nil {
t.Errorf("Verify: %v", err)
}
otherPub, _, _ := ed25519.GenerateKey(rand.Reader)
for name, tc := range map[string]struct {
ev Event
pub ed25519.PublicKey
err error
}{
"unsigniert": {ev, pub, ErrUnsigned},
"fremder Schlüssel": {signed, otherPub, ErrBadSignature},
"Payload geändert": {func() Event { e := signed; e.Payload = json.RawMessage(`{"command":"rotate_key"}`); return e }(), pub, ErrBadSignature},
"Knoten geändert": {func() Event { e := signed; e.NodeID = "n2"; return e }(), pub, ErrBadSignature},
"Signatur Müll": {func() Event { e := signed; e.Signature = "!!!"; return e }(), pub, ErrBadSignature},
} {
if err := VerifyEvent(tc.ev, tc.pub); !errors.Is(err, tc.err) {
t.Errorf("%s: erwartet %v, bekommen %v", name, tc.err, err)
}
}
}
// Mit SigningKey signiert der Hub jedes Event – nachdem er ID, Zeitstempel
// und Knoten-ID ergänzt hat, damit auch diese geschützt sind.
func TestHub_SignsEvents(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
h := &Hub{SigningKey: priv}
srv, _ := hubServer(t, h)
resp := openStream(t, context.Background(), srv, "n1")
if err := h.Send("n1", Event{Type: EventNodeCommand, Payload: json.RawMessage(`{"command":"resync"}`)}); err != nil {
t.Fatal(err)
}
ev, err := NewDecoder(resp.Body).Next()
if err != nil {
t.Fatal(err)
}
if ev.NodeID != "n1" || ev.ID == "" {
t.Errorf("Event: %+v", ev)
}
if err := VerifyEvent(ev, pub); err != nil {
t.Errorf("Signatur nach Übertragung ungültig: %v", err)
}
}
func TestNodeCommandPayload(t *testing.T) {
ev, err := NewEvent(EventNodeCommand, NodeCommand{Command: CmdRotateKey, Reason: "Routine"})
if err != nil {
t.Fatal(err)
}
cmd, err := ParseNodeCommand(ev)
if err != nil || cmd.Command != CmdRotateKey || cmd.Reason != "Routine" {
t.Errorf("ParseNodeCommand: %+v %v", cmd, err)
}
if _, err := ParseNodeCommand(Event{Type: EventLeaseRenew}); err == nil {
t.Error("falscher Event-Typ akzeptiert")
}
if _, err := ParseNodeCommand(Event{Type: EventNodeCommand, Payload: json.RawMessage(`{}`)}); err == nil {
t.Error("Befehl ohne Namen akzeptiert")
}
}
func TestNodeCommandServerURLs(t *testing.T) {
ev, _ := NewEvent(EventNodeCommand, NodeCommand{Command: CmdConfig, ServerURLs: []string{"https://a:8443", "https://b:8443"}})
cmd, err := ParseNodeCommand(ev)
if err != nil || cmd.Command != CmdConfig || len(cmd.ServerURLs) != 2 || cmd.ServerURLs[1] != "https://b:8443" {
t.Errorf("config: %+v %v", cmd, err)
}
}