From ca6cdcca63c8c8e3c9f8bb7ba978264ab7955211 Mon Sep 17 00:00:00 2001
From: Bjoern Runden
Date: Fri, 2 Oct 2026 13:25:34 +0200
Subject: [PATCH] software steht so weit. jetzt build + docker build und testen
---
.ai/sultan-system-architecture.md | 379 +++++++++---
.dockerignore | 12 +
.gitignore | 3 +
CLAUDE.md | 29 +-
Dockerfile | 33 +
README.md | 124 +++-
deploy/build.sh | 26 +
deploy/compose/agent/.env.example | 9 +
deploy/compose/agent/compose.yaml | 30 +
deploy/compose/server/.env.example | 6 +
deploy/compose/server/compose.yaml | 27 +
deploy/publish-static.sh | 42 ++
deploy/push-image.sh | 43 ++
pkg/agent/agent.go | 411 +++++++++++++
pkg/agent/agent_test.go | 837 ++++++++++++++++++++++++++
pkg/agent/events.go | 175 ++++++
pkg/agent/health.go | 137 +++++
pkg/agent/health_test.go | 98 +++
pkg/agent/host.go | 94 +++
pkg/agent/host_test.go | 44 ++
pkg/agent/identity.go | 286 +++++++++
pkg/agent/sultand.go | 303 ++++++++++
pkg/agent/sultand_test.go | 236 ++++++++
pkg/agent/workloads.go | 147 +++++
pkg/agent/workloads_test.go | 150 +++++
pkg/auth/integration_test.go | 16 +-
pkg/auth/keys.go | 21 +
pkg/checkmk/checkmk.go | 200 ++++++
pkg/checkmk/checkmk_test.go | 153 +++++
pkg/cli/agent.go | 115 ++++
pkg/cli/agent_test.go | 97 +++
pkg/cli/cli.go | 49 +-
pkg/cli/cli_test.go | 116 +++-
pkg/cli/dump.go | 52 ++
pkg/cli/env.go | 51 ++
pkg/cli/token.go | 639 ++++++++++++++++++++
pkg/cli/token_test.go | 506 ++++++++++++++++
pkg/crypto/cert.go | 12 +-
pkg/crypto/pin.go | 139 +----
pkg/crypto/pin_test.go | 167 +----
pkg/crypto/token.go | 101 +++-
pkg/crypto/token_test.go | 82 ++-
pkg/docker/docker.go | 140 +++++
pkg/docker/docker_test.go | 154 +++++
pkg/lander/lander.go | 332 ++++++++++
pkg/lander/lander_test.go | 374 ++++++++++++
pkg/lander/templates/README.txt | 28 +
pkg/lander/templates/firemark.html | 26 +
pkg/lander/templates/landing.html | 25 +
pkg/lander/templates/maintenance.html | 27 +
pkg/lander/templates/paywall.html | 25 +
pkg/lander/templates/watermark.html | 26 +
pkg/license/domain.go | 4 +
pkg/license/domain_test.go | 9 +
pkg/license/lease.go | 153 +++++
pkg/license/lease_test.go | 132 ++++
pkg/notify/notify.go | 111 ++++
pkg/notify/notify_test.go | 133 ++++
pkg/policy/policy.go | 59 +-
pkg/policy/policy_test.go | 144 ++++-
pkg/registry/registry.go | 56 +-
pkg/registry/registry_test.go | 43 ++
pkg/server/directory.go | 45 +-
pkg/server/handlers.go | 118 ++++
pkg/server/jobs.go | 229 +++++++
pkg/server/leases.go | 92 +++
pkg/server/ratelimit.go | 61 ++
pkg/server/ratelimit_test.go | 56 ++
pkg/server/server.go | 240 +++++++-
pkg/server/server_test.go | 734 +++++++++++++++++++++-
pkg/store/housekeeping.go | 63 ++
pkg/store/leases.go | 101 ++++
pkg/store/maintenance.go | 61 ++
pkg/store/maintenance_test.go | 79 +++
pkg/store/nodes.go | 13 +-
pkg/store/settings.go | 29 +
pkg/store/settings_test.go | 44 ++
pkg/store/store.go | 38 ++
pkg/store/tenants.go | 68 ++-
pkg/store/tokens.go | 14 +-
pkg/store/tokens_test.go | 2 +-
pkg/store/v4_test.go | 155 +++++
pkg/store/v5_test.go | 97 +++
pkg/stream/client.go | 6 +
pkg/stream/client_test.go | 33 +
pkg/stream/event.go | 3 +
pkg/stream/hub.go | 22 +-
pkg/stream/hub_test.go | 13 +
pkg/stream/sign.go | 78 +++
pkg/stream/sign_test.go | 86 +++
90 files changed, 10298 insertions(+), 480 deletions(-)
create mode 100644 .dockerignore
create mode 100644 Dockerfile
create mode 100755 deploy/build.sh
create mode 100644 deploy/compose/agent/.env.example
create mode 100644 deploy/compose/agent/compose.yaml
create mode 100644 deploy/compose/server/.env.example
create mode 100644 deploy/compose/server/compose.yaml
create mode 100755 deploy/publish-static.sh
create mode 100755 deploy/push-image.sh
create mode 100644 pkg/agent/agent.go
create mode 100644 pkg/agent/agent_test.go
create mode 100644 pkg/agent/events.go
create mode 100644 pkg/agent/health.go
create mode 100644 pkg/agent/health_test.go
create mode 100644 pkg/agent/host.go
create mode 100644 pkg/agent/host_test.go
create mode 100644 pkg/agent/identity.go
create mode 100644 pkg/agent/sultand.go
create mode 100644 pkg/agent/sultand_test.go
create mode 100644 pkg/agent/workloads.go
create mode 100644 pkg/agent/workloads_test.go
create mode 100644 pkg/checkmk/checkmk.go
create mode 100644 pkg/checkmk/checkmk_test.go
create mode 100644 pkg/cli/agent.go
create mode 100644 pkg/cli/agent_test.go
create mode 100644 pkg/cli/dump.go
create mode 100644 pkg/cli/env.go
create mode 100644 pkg/cli/token.go
create mode 100644 pkg/cli/token_test.go
create mode 100644 pkg/docker/docker.go
create mode 100644 pkg/docker/docker_test.go
create mode 100644 pkg/lander/lander.go
create mode 100644 pkg/lander/lander_test.go
create mode 100644 pkg/lander/templates/README.txt
create mode 100644 pkg/lander/templates/firemark.html
create mode 100644 pkg/lander/templates/landing.html
create mode 100644 pkg/lander/templates/maintenance.html
create mode 100644 pkg/lander/templates/paywall.html
create mode 100644 pkg/lander/templates/watermark.html
create mode 100644 pkg/license/lease.go
create mode 100644 pkg/license/lease_test.go
create mode 100644 pkg/notify/notify.go
create mode 100644 pkg/notify/notify_test.go
create mode 100644 pkg/server/jobs.go
create mode 100644 pkg/server/leases.go
create mode 100644 pkg/server/ratelimit.go
create mode 100644 pkg/server/ratelimit_test.go
create mode 100644 pkg/store/housekeeping.go
create mode 100644 pkg/store/leases.go
create mode 100644 pkg/store/maintenance.go
create mode 100644 pkg/store/maintenance_test.go
create mode 100644 pkg/store/settings.go
create mode 100644 pkg/store/settings_test.go
create mode 100644 pkg/store/v4_test.go
create mode 100644 pkg/store/v5_test.go
create mode 100644 pkg/stream/sign.go
create mode 100644 pkg/stream/sign_test.go
diff --git a/.ai/sultan-system-architecture.md b/.ai/sultan-system-architecture.md
index 599cb47..bf12689 100644
--- a/.ai/sultan-system-architecture.md
+++ b/.ai/sultan-system-architecture.md
@@ -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 `/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`: ` `, 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`: 256 Bit Zufall (Base64url). Der Server speichert nur den SHA-256 von `sltn_enroll_`, 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_.[.]`. 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_`, 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 `/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 (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 (``, 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/`; Router und Service heißen ``.
+ * Backend: `:`; `https`-URL → `loadbalancer.server.scheme=https`. Ohne Port 80/443.
+ * `redirect_www`:
+ * Middleware `-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 `-http` auf `web` mit Middleware `-https` (redirectScheme `https`, permanent).
+* **Healthcheck → Wartungsseite:**
+ * Der Agent prüft regelmäßig Status, optional `` 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 (`` 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\n` (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_.[.]`; 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=` (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:** `/landing/.html` (Go `html/template`), optional `.css` und `.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_` (nur kommerziell) | `days_left=N;30;14` | OK; WARN ≤ 30 Tage; CRIT ≤ 14 Tage, abgelaufen oder Quarantäne |
+| `Sultan_Payment_` (nur kommerziell) | `dunning_level=N;1;2` | OK ohne offene Zahlung; Mahnstufe 1 WARN; 2 und 3 CRIT (3 = „Kunde offline“) |
+| `Sultan_Node_` | `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_." # 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_.."
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 `/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/`) 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//`, `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_` 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, ``, 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/`) 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.
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..db1a7ca
--- /dev/null
+++ b/.dockerignore
@@ -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/
diff --git a/.gitignore b/.gitignore
index 899f360..10e7a93 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,6 @@ go.work.sum
/suldata/
suldata/
/sultan
+
+# Build-Ausgaben (deploy/build.sh)
+/dist/
diff --git a/CLAUDE.md b/CLAUDE.md
index c23234e..a6fd126 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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_.` (`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_.[.]` (`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; `*.` 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 `____`, 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______` per Cookie `sultan_ack=`. `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 `____`, 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______` per Cookie `sultan_ack=`. `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 `/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//`), `push-image.sh` (buildx → Gitea-Registry `git.everdc.eu/chmln-app-dev/sultan`), `publish-static.sh` (rsync nach `sultan/releases//`), `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_` 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.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..4fa25cd
--- /dev/null
+++ b/Dockerfile
@@ -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 --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"]
diff --git a/README.md b/README.md
index 9fd6a2a..d76ee54 100644
--- a/README.md
+++ b/README.md
@@ -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//sultan_linux_amd64, sultan_linux_arm64, SHA256SUMS
+sudo install -m 0755 dist//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:
+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//` (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=
+SULTAN_TOKEN= sultan agent # Adresse steckt in der Einladung (sultan server --public-url=…)
+sultan agent --server=https://ingress.example.com:8443 --token= # 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).
diff --git a/deploy/build.sh b/deploy/build.sh
new file mode 100755
index 0000000..e13ffcf
--- /dev/null
+++ b/deploy/build.sh
@@ -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//sultan_linux_{amd64,arm64} und dist//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"
diff --git a/deploy/compose/agent/.env.example b/deploy/compose/agent/.env.example
new file mode 100644
index 0000000..0130925
--- /dev/null
+++ b/deploy/compose/agent/.env.example
@@ -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=
diff --git a/deploy/compose/agent/compose.yaml b/deploy/compose/agent/compose.yaml
new file mode 100644
index 0000000..105faed
--- /dev/null
+++ b/deploy/compose/agent/compose.yaml
@@ -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!
diff --git a/deploy/compose/server/.env.example b/deploy/compose/server/.env.example
new file mode 100644
index 0000000..88e87aa
--- /dev/null
+++ b/deploy/compose/server/.env.example
@@ -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
diff --git a/deploy/compose/server/compose.yaml b/deploy/compose/server/compose.yaml
new file mode 100644
index 0000000..0d92bd2
--- /dev/null
+++ b/deploy/compose/server/compose.yaml
@@ -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!
diff --git a/deploy/publish-static.sh b/deploy/publish-static.sh
new file mode 100755
index 0000000..2fd8709
--- /dev/null
+++ b/deploy/publish-static.sh
@@ -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//sultan_linux_{amd64,arm64}
+# releases//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}"
diff --git a/deploy/push-image.sh b/deploy/push-image.sh
new file mode 100755
index 0000000..29893a1
--- /dev/null
+++ b/deploy/push-image.sh
@@ -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: (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
diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go
new file mode 100644
index 0000000..7397f61
--- /dev/null
+++ b/pkg/agent/agent.go
@@ -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)
+ }
+}
diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go
new file mode 100644
index 0000000..4a788bc
--- /dev/null
+++ b/pkg/agent/agent_test.go
@@ -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("ShopImpressum
"))
+ }))
+ 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)
+ }
+}
diff --git a/pkg/agent/events.go b/pkg/agent/events.go
new file mode 100644
index 0000000..5212123
--- /dev/null
+++ b/pkg/agent/events.go
@@ -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
+}
diff --git a/pkg/agent/health.go b/pkg/agent/health.go
new file mode 100644
index 0000000..d87144c
--- /dev/null
+++ b/pkg/agent/health.go
@@ -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 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(" %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 -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)
+ }
+ }
+}
diff --git a/pkg/agent/health_test.go b/pkg/agent/health_test.go
new file mode 100644
index 0000000..2eda8fe
--- /dev/null
+++ b/pkg/agent/health_test.go
@@ -0,0 +1,98 @@
+package agent
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "testing"
+ "time"
+)
+
+const page = ` Shop - Willkommen
+Hallo
`
+
+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)
+ }
+}
diff --git a/pkg/agent/host.go b/pkg/agent/host.go
new file mode 100644
index 0000000..bec647a
--- /dev/null
+++ b/pkg/agent/host.go
@@ -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
+}
diff --git a/pkg/agent/host_test.go b/pkg/agent/host_test.go
new file mode 100644
index 0000000..1b4de8b
--- /dev/null
+++ b/pkg/agent/host_test.go
@@ -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)
+ }
+ }
+}
diff --git a/pkg/agent/identity.go b/pkg/agent/identity.go
new file mode 100644
index 0000000..d8cc023
--- /dev/null
+++ b/pkg/agent/identity.go
@@ -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
+}
diff --git a/pkg/agent/sultand.go b/pkg/agent/sultand.go
new file mode 100644
index 0000000..320fc35
--- /dev/null
+++ b/pkg/agent/sultand.go
@@ -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 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
+}
diff --git a/pkg/agent/sultand_test.go b/pkg/agent/sultand_test.go
new file mode 100644
index 0000000..dcad395
--- /dev/null
+++ b/pkg/agent/sultand_test.go
@@ -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")
+ }
+}
diff --git a/pkg/agent/workloads.go b/pkg/agent/workloads.go
new file mode 100644
index 0000000..b1756c2
--- /dev/null
+++ b/pkg/agent/workloads.go
@@ -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, ""
+}
diff --git a/pkg/agent/workloads_test.go b/pkg/agent/workloads_test.go
new file mode 100644
index 0000000..82c6d8b
--- /dev/null
+++ b/pkg/agent/workloads_test.go
@@ -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)
+ }
+}
diff --git a/pkg/auth/integration_test.go b/pkg/auth/integration_test.go
index 56984a8..7de4fc2 100644
--- a/pkg/auth/integration_test.go
+++ b/pkg/auth/integration_test.go
@@ -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)
diff --git a/pkg/auth/keys.go b/pkg/auth/keys.go
index 2065412..57bdecf 100644
--- a/pkg/auth/keys.go
+++ b/pkg/auth/keys.go
@@ -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)
+}
diff --git a/pkg/checkmk/checkmk.go b/pkg/checkmk/checkmk.go
new file mode 100644
index 0000000..08ce78b
--- /dev/null
+++ b/pkg/checkmk/checkmk.go
@@ -0,0 +1,200 @@
+// Package checkmk erzeugt die Ausgabe für Checkmk im Local-Check-Format
+// (Spec 6.1): ` "" ` 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()
+}
diff --git a/pkg/checkmk/checkmk_test.go b/pkg/checkmk/checkmk_test.go
new file mode 100644
index 0000000..b692255
--- /dev/null
+++ b/pkg/checkmk/checkmk_test.go
@@ -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)
+ }
+}
diff --git a/pkg/cli/agent.go b/pkg/cli/agent.go
new file mode 100644
index 0000000..f1a5408
--- /dev/null
+++ b/pkg/cli/agent.go
@@ -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
+}
diff --git a/pkg/cli/agent_test.go b/pkg/cli/agent_test.go
new file mode 100644
index 0000000..0a39f19
--- /dev/null
+++ b/pkg/cli/agent_test.go
@@ -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)
+ }
+}
diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go
index ba3328c..6a2d3ff 100644
--- a/pkg/cli/cli.go
+++ b/pkg/cli/cli.go
@@ -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_ 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 }
diff --git a/pkg/cli/cli_test.go b/pkg/cli/cli_test.go
index f978513..ee522b1 100644
--- a/pkg/cli/cli_test.go
+++ b/pkg/cli/cli_test.go
@@ -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_ 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)
+ }
+}
diff --git a/pkg/cli/dump.go b/pkg/cli/dump.go
new file mode 100644
index 0000000..d9f80af
--- /dev/null
+++ b/pkg/cli/dump.go
@@ -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 /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
+}
diff --git a/pkg/cli/env.go b/pkg/cli/env.go
new file mode 100644
index 0000000..43e1d49
--- /dev/null
+++ b/pkg/cli/env.go
@@ -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_. 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
+}
diff --git a/pkg/cli/token.go b/pkg/cli/token.go
new file mode 100644
index 0000000..9d6117a
--- /dev/null
+++ b/pkg/cli/token.go
@@ -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= sultan agent\n", tok.ServerURL)
+ } else {
+ fmt.Fprint(env.stderr, "Auf dem Agent-Host: sultan agent --server=https://:8443 --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
+}
diff --git a/pkg/cli/token_test.go b/pkg/cli/token_test.go
new file mode 100644
index 0000000..71596d1
--- /dev/null
+++ b/pkg/cli/token_test.go
@@ -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)
+ }
+}
diff --git a/pkg/crypto/cert.go b/pkg/crypto/cert.go
index c6c9325..608e3b8 100644
--- a/pkg/crypto/cert.go
+++ b/pkg/crypto/cert.go
@@ -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_.. 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_.[.].
+// Der Server speichert nur das Secret (gehasht), der Agent nutzt
+// Fingerprint und Adresse für den Erstkontakt.
package crypto
import (
diff --git a/pkg/crypto/pin.go b/pkg/crypto/pin.go
index ea06edc..0fd7f86 100644
--- a/pkg/crypto/pin.go
+++ b/pkg/crypto/pin.go
@@ -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 " ", # 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 got := FingerprintOf(cs.PeerCertificates[0]); got != want {
+ return fmt.Errorf("%w: Server präsentiert %s, erwartet %s", ErrFingerprintMismatch, got, want)
}
- if ok {
- if actual != pinned {
- return fmt.Errorf("%w: %s präsentiert %s, gepinnt ist %s", ErrFingerprintMismatch, server, actual, pinned)
- }
- 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)
+ return nil
},
}
}
diff --git a/pkg/crypto/pin_test.go b/pkg/crypto/pin_test.go
index 8c3ee90..53af246 100644
--- a/pkg/crypto/pin_test.go
+++ b/pkg/crypto/pin_test.go
@@ -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)
}
}
diff --git a/pkg/crypto/token.go b/pkg/crypto/token.go
index e7c0e4c..7d6cf72 100644
--- a/pkg/crypto/token.go
+++ b/pkg/crypto/token.go
@@ -5,49 +5,80 @@ import (
"encoding/base64"
"errors"
"fmt"
+ "net"
+ "net/url"
+ "strconv"
"strings"
)
// TokenPrefix kennzeichnet Enrollment-Tokens.
const TokenPrefix = "sltn_enroll_"
-// ErrInvalidToken: Der Token-String hat nicht das Format
-// sltn_enroll_..
-var ErrInvalidToken = errors.New("crypto: ungültiger Enrollment-Token")
+var (
+ // ErrInvalidToken: Der Token-String hat nicht das Format
+ // sltn_enroll_.[.].
+ 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.", ErrInvalidToken, TokenPrefix)
+ if len(parts) != 2 && len(parts) != 3 {
+ return EnrollmentToken{}, fmt.Errorf("%w: erwartet %s.[.]", 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
}
diff --git a/pkg/crypto/token_test.go b/pkg/crypto/token_test.go
index 1b8f341..2cc9554 100644
--- a/pkg/crypto/token_test.go
+++ b/pkg/crypto/token_test.go
@@ -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)
+ }
+ }
+}
diff --git a/pkg/docker/docker.go b/pkg/docker/docker.go
new file mode 100644
index 0000000..34d7bba
--- /dev/null
+++ b/pkg/docker/docker.go
@@ -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
+}
diff --git a/pkg/docker/docker_test.go b/pkg/docker/docker_test.go
new file mode 100644
index 0000000..76cffb8
--- /dev/null
+++ b/pkg/docker/docker_test.go
@@ -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")
+ }
+}
diff --git a/pkg/lander/lander.go b/pkg/lander/lander.go
new file mode 100644
index 0000000..b2964be
--- /dev/null
+++ b/pkg/lander/lander.go
@@ -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("" + stage + "")
+ }
+ 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
+}
diff --git a/pkg/lander/lander_test.go b/pkg/lander/lander_test.go
new file mode 100644
index 0000000..26207d8
--- /dev/null
+++ b/pkg/lander/lander_test.go
@@ -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">`,
+ "X-Sultan-Tenant", "")
+ body := rec.Body.String()
+ if strings.Contains(body, ""+strings.Repeat("a", 400)))
+ body := rec.Body.String()
+ if strings.Contains(body, "