From c8df9d765132a0cd1d697bf3a43ec3c388b3966f Mon Sep 17 00:00:00 2001
From: Bjoern Runden
Date: Thu, 1 Oct 2026 22:22:13 +0200
Subject: [PATCH] erste module drin. zwischenstand: Architektur aktualisiert,
TOFU implementiert,
---
.ai/sultan-system-architecture.md | 121 +++++++--
CLAUDE.md | 24 +-
go.mod | 20 ++
go.sum | 54 ++++
pkg/auth/integration_test.go | 296 ++++++++++++++++++++++
pkg/auth/keys.go | 106 ++++++++
pkg/auth/keys_test.go | 110 ++++++++
pkg/auth/nonce.go | 44 ++++
pkg/auth/nonce_test.go | 29 +++
pkg/auth/sign.go | 109 ++++++++
pkg/auth/sign_test.go | 87 +++++++
pkg/auth/verify.go | 179 +++++++++++++
pkg/auth/verify_test.go | 385 ++++++++++++++++++++++++++++
pkg/crypto/cert.go | 191 ++++++++++++++
pkg/crypto/cert_test.go | 139 ++++++++++
pkg/crypto/pin.go | 142 +++++++++++
pkg/crypto/pin_test.go | 193 ++++++++++++++
pkg/crypto/token.go | 65 +++++
pkg/crypto/token_test.go | 92 +++++++
pkg/store/nodes.go | 155 ++++++++++++
pkg/store/nodes_test.go | 226 +++++++++++++++++
pkg/store/store.go | 214 ++++++++++++++++
pkg/store/store_test.go | 143 +++++++++++
pkg/store/tenants.go | 200 +++++++++++++++
pkg/store/tenants_test.go | 253 +++++++++++++++++++
pkg/store/tokens.go | 88 +++++++
pkg/store/tokens_test.go | 188 ++++++++++++++
pkg/stream/client.go | 197 +++++++++++++++
pkg/stream/client_test.go | 333 ++++++++++++++++++++++++
pkg/stream/decoder.go | 80 ++++++
pkg/stream/decoder_test.go | 110 ++++++++
pkg/stream/event.go | 78 ++++++
pkg/stream/event_test.go | 84 +++++++
pkg/stream/hub.go | 271 ++++++++++++++++++++
pkg/stream/hub_test.go | 406 ++++++++++++++++++++++++++++++
pkg/traefik/config.go | 125 +++++++++
pkg/traefik/config_test.go | 146 +++++++++++
pkg/traefik/labels.go | 303 ++++++++++++++++++++++
pkg/traefik/labels_test.go | 360 ++++++++++++++++++++++++++
pkg/traefik/merge.go | 55 ++++
pkg/traefik/merge_test.go | 90 +++++++
41 files changed, 6455 insertions(+), 36 deletions(-)
create mode 100644 go.mod
create mode 100644 go.sum
create mode 100644 pkg/auth/integration_test.go
create mode 100644 pkg/auth/keys.go
create mode 100644 pkg/auth/keys_test.go
create mode 100644 pkg/auth/nonce.go
create mode 100644 pkg/auth/nonce_test.go
create mode 100644 pkg/auth/sign.go
create mode 100644 pkg/auth/sign_test.go
create mode 100644 pkg/auth/verify.go
create mode 100644 pkg/auth/verify_test.go
create mode 100644 pkg/crypto/cert.go
create mode 100644 pkg/crypto/cert_test.go
create mode 100644 pkg/crypto/pin.go
create mode 100644 pkg/crypto/pin_test.go
create mode 100644 pkg/crypto/token.go
create mode 100644 pkg/crypto/token_test.go
create mode 100644 pkg/store/nodes.go
create mode 100644 pkg/store/nodes_test.go
create mode 100644 pkg/store/store.go
create mode 100644 pkg/store/store_test.go
create mode 100644 pkg/store/tenants.go
create mode 100644 pkg/store/tenants_test.go
create mode 100644 pkg/store/tokens.go
create mode 100644 pkg/store/tokens_test.go
create mode 100644 pkg/stream/client.go
create mode 100644 pkg/stream/client_test.go
create mode 100644 pkg/stream/decoder.go
create mode 100644 pkg/stream/decoder_test.go
create mode 100644 pkg/stream/event.go
create mode 100644 pkg/stream/event_test.go
create mode 100644 pkg/stream/hub.go
create mode 100644 pkg/stream/hub_test.go
create mode 100644 pkg/traefik/config.go
create mode 100644 pkg/traefik/config_test.go
create mode 100644 pkg/traefik/labels.go
create mode 100644 pkg/traefik/labels_test.go
create mode 100644 pkg/traefik/merge.go
create mode 100644 pkg/traefik/merge_test.go
diff --git a/.ai/sultan-system-architecture.md b/.ai/sultan-system-architecture.md
index 6c3cf9f..b77785c 100644
--- a/.ai/sultan-system-architecture.md
+++ b/.ai/sultan-system-architecture.md
@@ -1,6 +1,3 @@
-# CLAUDE.md
-
-
# Sultan: High-Performance Distributed Ingress & ZeroTrust Service Discovery
Dieses Dokument beschreibt das vollständige Architektur-, Daten-, Netzwerk- und Anwendungsmodell des Projekts **Sultan**. Es dient als Kontext und Referenz für Software-Architekten und AI-gestützte Coding-Agenten (z. B. via Continue in VSCodium).
@@ -74,7 +71,8 @@ Klassische Ingress-Lösungen wie Nginx Proxy Manager (NPM) erfordern eine manuel
1. **Control Plane (Agent -> Server):**
* **Transport:** Ausgehende Verbindung vom Agenten zum Server über TCP `:8443` (TLS).
* **Firewall-Freundlichkeit:** Reine Outbound-Verbindung; funktioniert hinter DS-Lite, Mobilfunk, Carrier-Grade-NAT und Unternehmens-Firewalls ohne Portweiterleitung.
-* **Keepalive-Mechanik:** Persistenter HTTP/2- oder SSE-Stream. Alle 45 Sekunden wird ein minimales Keepalive-Byte (`:\n\n`) ausgetauscht. Verhindert das Schließen von State-Tables in NAT-Routern (Conntrack Timeout).
+* **Keepalive-Mechanik:** Persistenter **SSE-Stream** (Server-Sent Events, `text/event-stream`) vom Server zum Agenten. Alle 45 Sekunden ohne Event wird die SSE-Kommentarzeile `:\n\n` gesendet. Verhindert das Schließen von State-Tables in NAT-Routern (Conntrack Timeout). Der Agent gilt als getrennt, wenn 90 s lang nichts ankommt (auch kein Keepalive), und verbindet sich mit exponentiellem Backoff (1 s … 60 s) neu.
+* **Rückkanal (Agent -> Server):** Nicht über den Stream, sondern als eigene, signierte HTTPS-Requests (z. B. `service_state`).
* **Event-Driven Push:** Änderungen an Containern (Start/Stop) oder Notfall-Sperren werden verzögerungsfrei (Latenz < 5 ms) über den bestehenden Stream übertragen.
@@ -86,6 +84,8 @@ Klassische Ingress-Lösungen wie Nginx Proxy Manager (NPM) erfordern eine manuel
3. **Ingress zu Traefik:**
* Traefik pollt den lokalen Sultan-Server via HTTP-Provider: `GET http://127.0.0.1:8080/v1/traefik-config`.
* Intervall: 2–5 Sekunden. Traefik verarbeitet die In-Memory-Konfiguration ohne Neustart und ohne Verbindungsabbrüche.
+* **Pull, nicht Push:** Traefik holt die Konfiguration ab; Sultan schreibt nie in Traefik und emuliert keinen Docker-Socket. Freigeschaltet wird das einmalig in Traefiks statischer Konfiguration (`--providers.http.endpoint`). Router aus Sultan heißen in Traefik `@http`; Mischbetrieb mit dem Docker-Provider für lokale Container ist möglich (Querverweise mit Provider-Suffix, z. B. `auth@docker`).
+* Port 8080 ist Klartext und deshalb nur an Loopback bzw. ein internes Docker-Netz zu binden. Achtung: Traefiks eigenes API/Dashboard nutzt per Default ebenfalls 8080.
@@ -95,18 +95,51 @@ Klassische Ingress-Lösungen wie Nginx Proxy Manager (NPM) erfordern eine manuel
### 3.1 Transport-Verschlüsselung (TLS ohne CA-Zirkus)
-* **Server-Zertifikat:** Generiert sich beim ersten Start selbst (Self-Signed / Snakeoil ECDSA P-256).
-* **Vertrauensmodell:** Trust-on-First-Use (TOFU) analog zu SSH. Der Agent verifiziert beim ersten Connect den SHA256-Fingerprint des Servers und speichert ihn in `known_servers`.
-* **Optional:** Unterstützung für explizit gemountete Root-CA-Zertifikate.
+* **Grundsatz: Kein Agent-Verkehr im Klartext.** Durchgesetzt auf mehreren Ebenen:
+ * Der Agent-Port `:8443` wird ausschließlich per TLS bedient, **nur TLS 1.3**.
+ * Der Agent signiert nur Requests an `https`-URLs; an eine `http`-URL geht weder Signatur noch Body.
+ * 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.
+* **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.
+* 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:**
-1. Admin erzeugt kurzlebigen Einladungs-Token auf dem Server (`sltn_token_...`).
-2. Agent generiert lokalen Key (`node.key`) und sendet seinen Public Key (`node.pub`) zusammen mit dem Einladungs-Token an den Server (`POST /api/v1/enroll`).
-3. Server speichert den Public Key des Agenten, weist ihm eine Mandanten-ID zu und signiert das initiale Berechtigungszertifikat (Capability Lease).
-4. Der Einladungs-Token wird entwertet; alle künftigen Heartbeats werden kryptografisch vom Agenten mit `node.key` signiert.
+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.
+3. Agent generiert lokalen Key (`node.key`) und sendet `sltn_enroll_` an den Server (`POST /api/v1/enroll`). Der Request ist bereits mit dem neuen Schlüssel signiert (Besitznachweis); der Public Key steht im Header `X-Sultan-Key`.
+4. Server prüft die Signatur, löst den Token ein und legt den Knoten **in einer Transaktion** an. Der Mandant ergibt sich ausschließlich aus dem Token. Schlägt das Anlegen fehl, bleibt der Token gültig. Paralleles Einlösen desselben Tokens gelingt genau einmal.
+5. *(Geplant)* Server signiert das initiale Berechtigungszertifikat (Capability Lease).
+6. Alle künftigen Requests werden vom Agenten mit `node.key` signiert.
+
+* **Request-Signatur** (jeder Agent-Request inkl. Stream-Aufbau):
+ * Header: `X-Sultan-Key` (Public Key, Base64), `X-Sultan-Timestamp` (Unix-Sekunden), `X-Sultan-Nonce` (16–64 Zeichen, pro Request neu), `X-Sultan-Signature` (Base64).
+ * Signiert wird (zeilengetrennt): `SULTAN-ED25519-V1`, Methode, Pfad+Query, Timestamp, Nonce, SHA-256-Hex des Bodys.
+ * **Server-Prüfreihenfolge:**
+ 1. TLS
+ 2. Signatur (ohne Datenbankzugriff)
+ 3. Zeitfenster ±5 Minuten
+ 4. Nonce (Replay-Schutz)
+ 5. erst dann Nachschlagen des Knotens per Public Key
+
+ Gefälschte Requests kosten so keinen DB-Zugriff und können keine Nonces verbrauchen.
+ * Die **Knoten-ID** wird ausschließlich aus dem geprüften Schlüssel abgeleitet, nie aus einer Angabe des Clients.
+ * Body-Limit 1 MiB.
+ * Fehler-Mapping: 401 (nicht authentifiziert, Replay, Zeitabweichung), 403 (gesperrt), 413 (Body zu groß), 426 (kein TLS); Infrastrukturfehler 500.
+* **Sperren:** Die Authentifizierung sperrt nur Mandanten im Status `quarantined`. Zahlungsverzug, Ablauf und gedrainte Knoten regelt die Policy-Engine über das Routing (5.2). Der Agent behält den Kanal, z. B. für `lease_renew` oder eine Reaktivierung. Für Entzug zur Laufzeit trennt der Server bestehende Streams aktiv (`Hub.Disconnect`).
@@ -117,6 +150,16 @@ Jeder Payload vom Agenten an den Server muss vom Server gegen die zugewiesene Po
* **Domain Spoofing Protection:** Beansprucht ein Agent ein Label `Host('kunde.de')`, prüft der Server, ob `kunde.de` im Feld `allowed_domains` der Mandanten-Lizenz hinterlegt ist. Falls nicht: Ablehnung mit Sicherheits-Audit-Log.
* **Route Limits:** Übersteigt die Anzahl der deklarierten Routen das Limit (`max_routes`), werden überzählige Routen verworfen.
+### 3.4 Bedrohungsmodell: Erstkontakt (TOFU) und Server-Events
+
+* **Schwäche von reinem TOFU:** Beim ersten Kontakt wird ungeprüft vertraut. Ein Angreifer im Netzwerkweg (ARP-Spoofing im LAN, DNS-Manipulation, kompromittierter Router, offenes WLAN) würde dauerhaft gepinnt. Er könnte damit:
+ * den Enrollment-Token abgreifen und mit eigenem Schlüssel zuerst einlösen,
+ * sich dauerhaft dazwischenschalten (der echte Server gilt danach als „Fälschung“),
+ * 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.
+
---
## 4. Datenmodell (Data Specifications)
@@ -125,7 +168,7 @@ Jeder Payload vom Agenten an den Server muss vom Server gegen die zugewiesene Po
```json
{
- "$schema": "[http://json-schema.org/draft-07/schema#](http://json-schema.org/draft-07/schema#)",
+ "$schema": "http://json-schema.org/draft-07/schema#",
"title": "SultanCapabilities",
"type": "object",
"properties": {
@@ -150,7 +193,7 @@ Jeder Payload vom Agenten an den Server muss vom Server gegen die zugewiesene Po
### 4.2 Stream Event Protocol (Agent <-> Server)
-Das bidirektionale Streaming-Format nutzt standardisierte JSON-Events:
+Beide Richtungen nutzen dasselbe JSON-Event-Format. Server→Agent läuft über den SSE-Stream; jedes Event ist ein SSE-Rahmen `id: `, `event: `, `data: `, gefolgt von einer Leerzeile (max. 1 MiB). Agent→Server läuft über signierte HTTPS-Requests. `node_id` setzt der Server immer selbst aus der geprüften Identität.
```json
{
@@ -172,7 +215,15 @@ Liefert ein verlängertes Krypto-Lease an den Agenten zurück.
### 4.3 SQLite Datenbankschema (Server State)
-Der Server persistiert Mandanten, Tokens und Richtlinien lokal in SQLite (`/var/lib/sultan/sultan.db`):
+Der Server persistiert Mandanten, Tokens und Richtlinien lokal in SQLite (`/var/lib/sultan/sultan.db`).
+
+* **Treiber:** `modernc.org/sqlite` (reines Go, kein cgo → statisches Single Binary).
+* **Pragmas:** `foreign_keys=ON` (sonst greift `ON DELETE CASCADE` nicht), WAL, `busy_timeout`, Schreib-Transaktionen `IMMEDIATE`.
+* **Migrationen:** Versioniert über `PRAGMA user_version`, nur anhängen. Eine Datenbank mit neuerer Version als das Programm wird abgelehnt.
+* **Zeitstempel:** Die Anwendung setzt sie selbst (keine `CURRENT_TIMESTAMP`-Defaults), als UTC-Text fester Breite (`2006-01-02T15:04:05.000000000Z`). Dadurch funktionieren Vergleiche direkt in SQL.
+* **Tokens:** Gespeichert wird nur der SHA-256-Hash.
+
+Schema (Stand Migration v1):
```sql
-- Mandanten & Abonnements
@@ -184,9 +235,9 @@ CREATE TABLE tenants (
max_routes INTEGER NOT NULL DEFAULT 5,
allowed_domains TEXT NOT NULL, -- JSON-Array: ["*.firma.de", "shop.de"]
valid_until DATETIME NOT NULL, -- Ablaufdatum des Vertrags / Abos
- status TEXT NOT NULL DEFAULT 'active', -- active, suspended_payment, quarantined
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+ status TEXT NOT NULL DEFAULT 'active', -- active, suspended_payment, quarantined, expired
+ created_at DATETIME NOT NULL, -- von der Anwendung gesetzt (UTC-Text)
+ updated_at DATETIME NOT NULL
);
-- Registrierte Agenten / Knoten
@@ -197,9 +248,9 @@ CREATE TABLE nodes (
public_key TEXT NOT NULL UNIQUE, -- Ed25519 Public Key des Agenten
last_seen DATETIME NOT NULL,
ip_address TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'online', -- online, offline, drained
- FOREIGN KEY(tenant_id) REFERENCES tenants(id)
+ status TEXT NOT NULL DEFAULT 'online' -- online, offline, drained
);
+CREATE INDEX nodes_tenant_id ON nodes(tenant_id);
-- Einmalige Enrollment-Tokens
CREATE TABLE enrollment_tokens (
@@ -208,6 +259,7 @@ CREATE TABLE enrollment_tokens (
expires_at DATETIME NOT NULL,
used INTEGER NOT NULL DEFAULT 0
);
+CREATE INDEX enrollment_tokens_tenant_id ON enrollment_tokens(tenant_id);
```
@@ -309,7 +361,7 @@ services:
restart: unless-stopped
network_mode: host
command:
- - "--providers.http.endpoint=[http://127.0.0.1:8080/v1/traefik-config](http://127.0.0.1:8080/v1/traefik-config)"
+ - "--providers.http.endpoint=http://127.0.0.1:8080/v1/traefik-config"
- "--providers.http.pollInterval=3s"
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
@@ -329,7 +381,7 @@ services:
- "8443:8443" # Agent Inbound (TLS Stream)
- "127.0.0.1:8080:8080" # Traefik Config Provider & Checkmk Metrics
volumes:
- - ./sultan-data:/var/lib/sultan
+ - ./sultan-data:/var/lib/sultan # sultan.db, server.key (0600), server.crt – server.key sichern!
```
@@ -344,11 +396,11 @@ services:
command:
- "sultan"
- "agent"
- - "--server=[https://ingress.dein-netz.de:8443](https://ingress.dein-netz.de:8443)"
- - "--token=sltn_enroll_01J8Z9..."
+ - "--server=https://ingress.dein-netz.de:8443"
+ - "--token=sltn_enroll_." # Ausgabe von `sultan token`
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- - ./agent-data:/var/lib/sultan
+ - ./agent-data:/var/lib/sultan # node.key (0600) und known_servers – persistent halten!
kunde-wordpress:
image: wordpress:latest
@@ -360,9 +412,26 @@ services:
- "traefik.http.routers.wp.tls=true"
- "traefik.http.routers.wp.tls.certresolver=dns-resolver"
- "traefik.http.middlewares.wp-www.redirectregex.regex=^https?://kunde\\.de/(.*)"
- - "traefik.http.middlewares.wp-www.redirectregex.replacement=[https://www.kunde.de/$$](https://www.kunde.de/$$){1}"
+ - "traefik.http.middlewares.wp-www.redirectregex.replacement=https://www.kunde.de/$${1}"
- "traefik.http.middlewares.wp-www.redirectregex.permanent=true"
- "traefik.http.routers.wp.middlewares=wp-www"
- "traefik.http.services.wp.loadbalancer.server.port=80"
```
+
+---
+
+## 8. Architekturentscheidungen (Entscheidungslog)
+
+| Datum | Entscheidung | Begründung |
+|---|---|---|
+| 2026-09-30 | Traefik bezieht die Konfiguration per **HTTP-Provider (Pull)** von `/v1/traefik-config`; kein Docker-Socket-Nachbau. | Gleichwertiger Traefik-Provider; ein Socket-Nachbau wäre fragil. Remote-Docker-Provider scheidet aus (Root-Zugriff auf Kunden-Hosts, eingehende Ports, nur ein Endpunkt). |
+| 2026-09-30 | Labels → Traefik-Konfiguration: Router/Services typisiert, **Middleware-Optionen als unveränderte Label-Strings** durchgereicht; nicht unterstützte Labels (TCP/UDP, unbekannte Attribute) werden abgelehnt statt ignoriert. Nur mit `traefik.enable=true`. | Traefik parst Middleware-Werte beim Einlesen wie Labels; Sultan muss nicht jeden Middleware-Typ kennen. |
+| 2026-09-30 | Server→Agent-Stream als **SSE**; Agent→Server über eigene signierte Requests. | Das Keepalive `:\n\n` ist SSE-Syntax; SSE ist proxy- und HTTP/1.1-tauglich. |
+| 2026-10-01 | Tenant-Status **`expired`** als vierter gültiger Wert. | Abschnitt 6.2 setzt ihn bei Ablauf. |
+| 2026-10-01 | SQLite über **`modernc.org/sqlite`**. | Kein cgo, statisches Single Binary. |
+| 2026-10-01 | **Ed25519-Request-Signatur in jedem Tier**, auch `community`. | Kein ungeschützter Homelab-Modus; einheitliches Sicherheitsniveau. |
+| 2026-10-01 | Authentifizierung sperrt nur `quarantined`; übrige Sperren über Routing-Policy. | Agent braucht den Kanal weiterhin (Lease, Reaktivierung). |
+| 2026-10-01 | **Kein Klartext für Agent-Verkehr**: TLS 1.3 only, Signer nur für `https`, Verifier nur mit TLS. | Signaturen und Labels dürfen nie unverschlüsselt übertragen werden. |
+| 2026-10-01 | **Token-Format `sltn_enroll_.`**, Pinning mit geprüftem Erstkontakt statt blindem TOFU. | Schließt die Man-in-the-Middle-Lücke beim Erstkontakt (3.4). Entscheidet zugleich den früheren Widerspruch `sltn_token_` vs. `sltn_enroll_`. |
+| geplant | Signierte Server-Events; geordneter Zertifikatswechsel; optionale Root-CA. | Zweite Sicherheitsschicht neben dem TLS-Pin; Betrieb ohne Aussperren. |
diff --git a/CLAUDE.md b/CLAUDE.md
index ce45d2a..1fb1b4e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -5,11 +5,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Instruktionen
Lies die `.ai/*.md` Files. Diese liefern den Context für die App, die hier entstehen soll.
-Maßgeblich ist aktuell [.ai/sultan-system-architecture.md](.ai/sultan-system-architecture.md) (Netzwerk-, Sicherheits-, Daten- und Anwendungsmodell inkl. SQL-Schema, Event-Format und Referenz-Compose-Files). Bei Widersprüchen zwischen diesem File und der Spec gilt die Spec.
+Maßgeblich ist aktuell [.ai/sultan-system-architecture.md](.ai/sultan-system-architecture.md) (Netzwerk-, Sicherheits-, Daten- und Anwendungsmodell inkl. SQL-Schema, Event-Format und Referenz-Compose-Files). Bei Widersprüchen zwischen diesem File und der Spec gilt die Spec. **Architekturentscheidungen werden in der Spec nachgepflegt** (betroffene Abschnitte + Entscheidungslog in Abschnitt 8) – das gehört zu jeder Änderung, die Architektur festlegt.
## Projektstatus
-Frühe Phase: Go-Modul `sultan` (Go 1.25), bisher nur [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`. Noch kein `main`/Subcommand.
+Frühe Phase: Go-Modul `sultan` (Go 1.25), noch kein `main`/Subcommand. 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.
+
+Tests werden zuerst als Vorgaben geschrieben, dann implementiert. Zeitabhängige Tests nutzen kurze Intervalle über die Konfigurationsfelder bzw. das injizierbare `Client.sleep`; mit `-race -count=30` auf Stabilität prüfen.
```bash
go vet ./...
@@ -30,11 +38,11 @@ Sultan ersetzt manuell gepflegtes Ingress-Routing (z. B. Nginx Proxy Manager): B
- `sultan token` – CLI für Schlüssel, Enrollment-Tokens, Lizenzausstellung
**Ports / Endpunkte des Servers:**
-- `:8443` (TLS) – Control Plane, ausschließlich *ausgehend* vom Agent (NAT/CGNAT/DS-Lite-tauglich). Persistenter Stream (HTTP/2 oder SSE), 45s-Keepalive, Events werden sofort gepusht. Enrollment: `POST /api/v1/enroll`.
+- `:8443` (TLS) – Control Plane, ausschließlich *ausgehend* vom Agent (NAT/CGNAT/DS-Lite-tauglich). Persistenter SSE-Stream (entschieden, siehe `pkg/stream`), 45s-Keepalive, Events werden sofort gepusht. Enrollment: `POST /api/v1/enroll`.
- `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), Agent pinnt den SHA256-Fingerprint per TOFU in `known_servers`.
+- 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.
- 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.
@@ -45,10 +53,6 @@ Sultan ersetzt manuell gepflegtes Ingress-Routing (z. B. Nginx Proxy Manager): B
**Policy/Lizenzablauf:** Mandanten-Status steuert das generierte Routing – Zahlungsverzug → Router auf Bezahlseite (402), Backend down/`service_down` → Statusseite 503 mit `Retry-After: 60`. Checkmk-Schwellen: WARN bei 30, CRIT bei 14 Tagen Restlaufzeit, bei Ablauf Umleitung auf Landingpage.
-## Bekannte Unstimmigkeiten in der Spec
+## Unstimmigkeiten in der Spec
-Vor der Implementierung mit dem User klären statt raten:
-- Token-Präfix: `sltn_token_...` (Abschnitt 3.2) vs. `sltn_enroll_...` (Compose-Beispiel 7.2).
-- Tenant-Status: Schema kennt `active`/`suspended_payment`/`quarantined`, Abschnitt 6.2 setzt `expired`.
-- Stream-Transport ist offen: HTTP/2 *oder* SSE (SSE wäre nur Server→Agent; bidirektionale Events brauchen dann einen Rückkanal).
-- Einige URLs in JSON/YAML-Blöcken der Spec sind durch Copy-Paste als Markdown-Links verstümmelt (`"[http://...](http://...)"`) – die nackte URL ist gemeint.
+Bisherige Widersprüche sind aufgelöst und im Entscheidungslog der Spec (Abschnitt 8) dokumentiert. Neue Unklarheiten vor der Implementierung mit dem User klären statt raten.
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..0d662a7
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,20 @@
+module sultan
+
+go 1.26.0
+
+require (
+ gopkg.in/yaml.v3 v3.0.1
+ modernc.org/sqlite v1.60.1
+)
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/mattn/go-isatty v0.0.24 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.48.0 // indirect
+ modernc.org/libc v1.77.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.12.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..7db44bb
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,54 @@
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
+golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
+golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
+golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
+golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
+modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
+modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
+modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
+modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
+modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
+modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/pkg/auth/integration_test.go b/pkg/auth/integration_test.go
new file mode 100644
index 0000000..56984a8
--- /dev/null
+++ b/pkg/auth/integration_test.go
@@ -0,0 +1,296 @@
+package auth
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "log"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "sultan/pkg/crypto"
+ "sultan/pkg/store"
+ "sultan/pkg/stream"
+)
+
+// Ende-zu-Ende über TLS: Enrollment gegen den echten SQLite-Store, danach
+// verbindet sich der Agent per signiertem SSE-Stream; die Knoten-ID für den
+// Hub stammt ausschließlich aus der geprüften Identität.
+func TestIntegration_EnrollAndStream(t *testing.T) {
+ db, err := store.Open(ctx, filepath.Join(t.TempDir(), "sultan.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ if _, err := db.CreateTenant(ctx, store.Tenant{
+ ID: "homelab", Name: "Homelab", Email: "ich@example.com",
+ Tier: store.TierCommunity, ValidUntil: time.Now().AddDate(1, 0, 0),
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.AddEnrollmentToken(ctx, "sltn_einladung", "homelab", time.Now().Add(time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+
+ ver := &Verifier{Directory: db}
+ hub := &stream.Hub{}
+ defer hub.Close()
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /api/v1/enroll", func(w http.ResponseWriter, r *http.Request) {
+ pub, err := ver.VerifyUnregistered(r)
+ if err != nil {
+ http.Error(w, err.Error(), StatusCode(err))
+ return
+ }
+ _, err = db.Enroll(r.Context(), r.Header.Get("X-Test-Token"), store.Node{
+ ID: "homelab-pi", Hostname: "pi", PublicKey: EncodePublicKey(pub), IPAddress: "192.168.1.20",
+ })
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusForbidden)
+ return
+ }
+ w.WriteHeader(http.StatusCreated)
+ })
+ mux.HandleFunc("GET /api/v1/stream", func(w http.ResponseWriter, r *http.Request) {
+ id, err := ver.Verify(r)
+ if err != nil {
+ http.Error(w, err.Error(), StatusCode(err))
+ return
+ }
+ hub.Serve(w, r, id.Node.ID)
+ })
+ srv := httptest.NewTLSServer(mux)
+ defer srv.Close()
+
+ priv, _ := GenerateKey()
+ signer := &Signer{Key: priv}
+
+ // Vor dem Enrollment: Stream wird abgelehnt.
+ client := &stream.Client{URL: srv.URL + "/api/v1/stream", HTTPClient: srv.Client(), Prepare: signer.Sign}
+ if err := client.Connect(ctx, nil); err == nil {
+ t.Fatal("Stream ohne Enrollment akzeptiert")
+ }
+
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+"/api/v1/enroll", nil)
+ req.Header.Set("X-Test-Token", "sltn_einladung")
+ if err := signer.Sign(req); err != nil {
+ t.Fatal(err)
+ }
+ resp, err := srv.Client().Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("Enrollment: Status %d", resp.StatusCode)
+ }
+
+ got := make(chan stream.Event, 1)
+ runCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ go client.Run(runCtx, func(ev stream.Event) error { got <- ev; return nil })
+
+ deadline := time.Now().Add(2 * time.Second)
+ for !hub.Connected("homelab-pi") {
+ if time.Now().After(deadline) {
+ t.Fatal("Agent hat sich nicht verbunden")
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ if err := hub.Send("homelab-pi", stream.Event{Type: stream.EventLeaseRenew}); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case ev := <-got:
+ if ev.NodeID != "homelab-pi" {
+ t.Errorf("NodeID = %q", ev.NodeID)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("kein Event empfangen")
+ }
+}
+
+// Gegenprobe: Ein Klartext-Server bekommt vom Signer gar nichts.
+func TestIntegration_PlainHTTPNeverSigned(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Error("Request hat einen Klartext-Server erreicht")
+ }))
+ defer srv.Close()
+ priv, _ := GenerateKey()
+ client := &stream.Client{URL: srv.URL, Prepare: (&Signer{Key: priv}).Sign}
+ if err := client.Connect(ctx, nil); err == nil {
+ t.Error("erwartet Fehler")
+ }
+}
+
+// pinnedServer startet den Sultan-Server-Teil (Enrollment + Stream) mit
+// eigenem Zertifikat aus crypto.LoadOrCreateServerCert.
+func pinnedServer(t *testing.T, db *store.Store, hub *stream.Hub) (*httptest.Server, crypto.ServerCert, *atomic.Int32) {
+ t.Helper()
+ sc, err := crypto.LoadOrCreateServerCert(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var hits atomic.Int32
+ ver := &Verifier{Directory: db}
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /api/v1/enroll", func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ pub, err := ver.VerifyUnregistered(r)
+ if err != nil {
+ http.Error(w, err.Error(), StatusCode(err))
+ return
+ }
+ var req struct{ Token string }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if _, err := db.Enroll(r.Context(), req.Token, store.Node{
+ ID: "homelab-pi", Hostname: "pi", PublicKey: EncodePublicKey(pub), IPAddress: "192.168.1.20",
+ }); err != nil {
+ http.Error(w, err.Error(), http.StatusForbidden)
+ return
+ }
+ w.WriteHeader(http.StatusCreated)
+ })
+ mux.HandleFunc("GET /api/v1/stream", func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ id, err := ver.Verify(r)
+ if err != nil {
+ http.Error(w, err.Error(), StatusCode(err))
+ return
+ }
+ hub.Serve(w, r, id.Node.ID)
+ })
+ srv := httptest.NewUnstartedServer(mux)
+ srv.TLS = crypto.ServerTLSConfig(sc)
+ srv.Config.ErrorLog = log.New(io.Discard, "", 0) // abgewiesene Handshakes sind hier gewollt
+ srv.StartTLS()
+ t.Cleanup(srv.Close)
+ return srv, sc, &hits
+}
+
+func homelabStore(t *testing.T) *store.Store {
+ t.Helper()
+ db, err := store.Open(ctx, filepath.Join(t.TempDir(), "sultan.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { db.Close() })
+ if _, err := db.CreateTenant(ctx, store.Tenant{
+ ID: "homelab", Name: "Homelab", Email: "ich@example.com",
+ Tier: store.TierCommunity, ValidUntil: time.Now().AddDate(1, 0, 0),
+ }); err != nil {
+ t.Fatal(err)
+ }
+ return db
+}
+
+func enroll(signer *Signer, hc *http.Client, url, secret string) (*http.Response, error) {
+ body, _ := json.Marshal(map[string]string{"token": secret})
+ req, _ := http.NewRequest(http.MethodPost, url+"/api/v1/enroll", bytes.NewReader(body))
+ if err := signer.Sign(req); err != nil {
+ return nil, err
+ }
+ return hc.Do(req)
+}
+
+// Kompletter Ablauf wie im Betrieb: Server erzeugt Token mit eingebettetem
+// Fingerprint, Agent prüft beim Erstkontakt dagegen, pinnt, enrollt und
+// verbindet danach den Stream allein über den Pin.
+func TestIntegration_TokenWithPinnedTLS(t *testing.T) {
+ db := homelabStore(t)
+ hub := &stream.Hub{}
+ defer hub.Close()
+ srv, sc, _ := pinnedServer(t, db, hub)
+
+ // Server-Seite (sultan token): Token erzeugen, nur das Secret speichern.
+ tok, err := crypto.NewEnrollmentToken(sc.Fingerprint)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.AddEnrollmentToken(ctx, tok.Secret, "homelab", time.Now().Add(time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+
+ // Agent-Seite: bekommt nur den Token-String (z. B. per --token).
+ parsed, err := crypto.ParseEnrollmentToken(tok.String())
+ 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)}}
+ priv, _ := GenerateKey()
+ signer := &Signer{Key: priv}
+
+ resp, err := enroll(signer, firstContact, srv.URL, parsed.Secret)
+ if err != nil {
+ t.Fatalf("Enrollment: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusCreated {
+ 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, "")}}
+ 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)
+ defer cancel()
+ go client.Run(runCtx, func(ev stream.Event) error { got <- ev; return nil })
+
+ deadline := time.Now().Add(2 * time.Second)
+ for !hub.Connected("homelab-pi") {
+ if time.Now().After(deadline) {
+ t.Fatal("Agent hat sich nicht verbunden")
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ if err := hub.Send("homelab-pi", stream.Event{Type: stream.EventNodeCommand}); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case <-got:
+ case <-time.After(2 * time.Second):
+ t.Fatal("kein Event empfangen")
+ }
+}
+
+// Angreifer sitzt beim Erstkontakt dazwischen: Der Agent bricht im
+// TLS-Handshake ab; Token und Signatur erreichen den Angreifer nie, der
+// Token bleibt beim echten Server einlösbar.
+func TestIntegration_MITMNeverSeesToken(t *testing.T) {
+ db := homelabStore(t)
+ hub := &stream.Hub{}
+ defer hub.Close()
+ _, realCert, _ := pinnedServer(t, db, hub)
+ mitm, _, mitmHits := pinnedServer(t, homelabStore(t), &stream.Hub{})
+
+ 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)}}
+ priv, _ := GenerateKey()
+
+ _, err := enroll(&Signer{Key: priv}, hc, mitm.URL, tok.Secret)
+ if !errors.Is(err, crypto.ErrFingerprintMismatch) {
+ t.Errorf("erwartet ErrFingerprintMismatch, bekommen %v", err)
+ }
+ if mitmHits.Load() != 0 {
+ t.Error("Request hat den Angreifer erreicht")
+ }
+ if _, err := db.Enroll(ctx, tok.Secret, store.Node{ID: "n", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"}); err != nil {
+ t.Errorf("Token beim echten Server nicht mehr einlösbar: %v", err)
+ }
+}
diff --git a/pkg/auth/keys.go b/pkg/auth/keys.go
new file mode 100644
index 0000000..2065412
--- /dev/null
+++ b/pkg/auth/keys.go
@@ -0,0 +1,106 @@
+// Package auth authentifiziert Agent-Requests per Ed25519-Signatur (Spec
+// Abschnitt 3.2) – in jedem Tier, auch im Community-/Homelab-Betrieb.
+//
+// Der Agent signiert jeden Request mit seinem lokal erzeugten node.key
+// (Signer). Der Server prüft Signatur, Zeitfenster und Nonce, schlägt den
+// Knoten über den Public Key nach und liefert dessen Identität (Verifier).
+// Die Knoten-ID stammt damit immer aus dem geprüften Schlüssel, nie aus einer
+// Angabe des Clients.
+//
+// Beide Seiten verweigern Klartext: Der Signer signiert nur https-Requests,
+// der Verifier akzeptiert nur Requests, die über TLS angekommen sind.
+package auth
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "os"
+)
+
+var (
+ // ErrInvalidKey: Schlüssel fehlt, ist kaputt oder kein Ed25519-Schlüssel.
+ ErrInvalidKey = errors.New("auth: ungültiger Schlüssel")
+ // ErrInsecureKeyFile: Die Schlüsseldatei ist für Gruppe oder andere lesbar.
+ ErrInsecureKeyFile = errors.New("auth: Schlüsseldatei hat zu offene Rechte (erwartet 0600)")
+)
+
+const pemTypePrivateKey = "PRIVATE KEY"
+
+// GenerateKey erzeugt ein neues Ed25519-Schlüsselpaar.
+func GenerateKey() (ed25519.PrivateKey, error) {
+ _, priv, err := ed25519.GenerateKey(rand.Reader)
+ return priv, err
+}
+
+// WritePrivateKey speichert key als PKCS#8-PEM mit Rechten 0600. Eine
+// bestehende Datei wird nie überschrieben (os.ErrExist) – ein versehentlich
+// ersetzter Schlüssel würde den Knoten aussperren.
+func WritePrivateKey(path string, key ed25519.PrivateKey) error {
+ der, err := x509.MarshalPKCS8PrivateKey(key)
+ if err != nil {
+ return fmt.Errorf("%w: %v", ErrInvalidKey, err)
+ }
+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if err != nil {
+ return err
+ }
+ if err := pem.Encode(f, &pem.Block{Type: pemTypePrivateKey, Bytes: der}); err != nil {
+ f.Close()
+ os.Remove(path)
+ return err
+ }
+ if err := f.Close(); err != nil {
+ os.Remove(path)
+ return err
+ }
+ return nil
+}
+
+// ReadPrivateKey liest einen mit WritePrivateKey gespeicherten Schlüssel.
+// Dateien, die Gruppe oder andere lesen dürfen, werden abgelehnt.
+func ReadPrivateKey(path string) (ed25519.PrivateKey, error) {
+ info, err := os.Stat(path)
+ if err != nil {
+ return nil, err
+ }
+ if info.Mode().Perm()&0o077 != 0 {
+ return nil, fmt.Errorf("%w: %s hat %o", ErrInsecureKeyFile, path, info.Mode().Perm())
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ block, _ := pem.Decode(data)
+ if block == nil || block.Type != pemTypePrivateKey {
+ return nil, fmt.Errorf("%w: kein %q-PEM-Block in %s", ErrInvalidKey, pemTypePrivateKey, path)
+ }
+ parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalidKey, err)
+ }
+ key, ok := parsed.(ed25519.PrivateKey)
+ if !ok {
+ return nil, fmt.Errorf("%w: %T statt Ed25519", ErrInvalidKey, parsed)
+ }
+ return key, nil
+}
+
+// EncodePublicKey kodiert einen Public Key so, wie er in Headern und im
+// Store (nodes.public_key) steht: Base64 (Standard-Alphabet).
+func EncodePublicKey(pub ed25519.PublicKey) string {
+ return base64.StdEncoding.EncodeToString(pub)
+}
+
+// DecodePublicKey ist die Umkehrung von EncodePublicKey.
+func DecodePublicKey(s string) (ed25519.PublicKey, error) {
+ b, err := base64.StdEncoding.DecodeString(s)
+ if err != nil || len(b) != ed25519.PublicKeySize {
+ return nil, fmt.Errorf("%w: Public Key nicht lesbar", ErrInvalidKey)
+ }
+ return ed25519.PublicKey(b), nil
+}
diff --git a/pkg/auth/keys_test.go b/pkg/auth/keys_test.go
new file mode 100644
index 0000000..aa2a9dc
--- /dev/null
+++ b/pkg/auth/keys_test.go
@@ -0,0 +1,110 @@
+package auth
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/x509"
+ "encoding/pem"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestKeyFile_RoundTrip(t *testing.T) {
+ priv, err := GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(t.TempDir(), "node.key")
+ if err := WritePrivateKey(path, priv); err != nil {
+ t.Fatalf("WritePrivateKey: %v", err)
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if perm := info.Mode().Perm(); perm != 0o600 {
+ t.Errorf("Dateirechte %o, erwartet 600", perm)
+ }
+ got, err := ReadPrivateKey(path)
+ if err != nil {
+ t.Fatalf("ReadPrivateKey: %v", err)
+ }
+ if !got.Equal(priv) {
+ t.Error("gelesener Schlüssel weicht ab")
+ }
+}
+
+func TestWritePrivateKey_NeverOverwrites(t *testing.T) {
+ priv, _ := GenerateKey()
+ path := filepath.Join(t.TempDir(), "node.key")
+ if err := WritePrivateKey(path, priv); err != nil {
+ t.Fatal(err)
+ }
+ other, _ := GenerateKey()
+ if err := WritePrivateKey(path, other); !errors.Is(err, os.ErrExist) {
+ t.Errorf("erwartet os.ErrExist, bekommen %v", err)
+ }
+ if got, _ := ReadPrivateKey(path); !got.Equal(priv) {
+ t.Error("bestehender Schlüssel wurde überschrieben")
+ }
+}
+
+func TestReadPrivateKey_RejectsInsecurePermissions(t *testing.T) {
+ priv, _ := GenerateKey()
+ path := filepath.Join(t.TempDir(), "node.key")
+ if err := WritePrivateKey(path, priv); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(path, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ReadPrivateKey(path); !errors.Is(err, ErrInsecureKeyFile) {
+ t.Errorf("erwartet ErrInsecureKeyFile, bekommen %v", err)
+ }
+}
+
+func TestReadPrivateKey_InvalidContent(t *testing.T) {
+ ecKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ ecDER, _ := x509.MarshalPKCS8PrivateKey(ecKey)
+ tests := map[string][]byte{
+ "kein PEM": []byte("hallo"),
+ "falscher Typ": pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte{1, 2, 3}}),
+ "kaputtes PKCS8": pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: []byte{1, 2, 3}}),
+ "kein Ed25519": pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: ecDER}),
+ }
+ for name, content := range tests {
+ t.Run(name, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "node.key")
+ if err := os.WriteFile(path, content, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ReadPrivateKey(path); !errors.Is(err, ErrInvalidKey) {
+ t.Errorf("erwartet ErrInvalidKey, bekommen %v", err)
+ }
+ })
+ }
+ if _, err := ReadPrivateKey(filepath.Join(t.TempDir(), "fehlt.key")); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("fehlende Datei: erwartet os.ErrNotExist, bekommen %v", err)
+ }
+}
+
+func TestPublicKeyEncoding(t *testing.T) {
+ priv, _ := GenerateKey()
+ pub := priv.Public().(ed25519PublicKey)
+ enc := EncodePublicKey(pub)
+ got, err := DecodePublicKey(enc)
+ if err != nil {
+ t.Fatalf("DecodePublicKey: %v", err)
+ }
+ if !got.Equal(pub) {
+ t.Error("Round-Trip weicht ab")
+ }
+ for _, bad := range []string{"", "!!!", "AAAA"} {
+ if _, err := DecodePublicKey(bad); !errors.Is(err, ErrInvalidKey) {
+ t.Errorf("DecodePublicKey(%q): erwartet ErrInvalidKey, bekommen %v", bad, err)
+ }
+ }
+}
diff --git a/pkg/auth/nonce.go b/pkg/auth/nonce.go
new file mode 100644
index 0000000..2246d9c
--- /dev/null
+++ b/pkg/auth/nonce.go
@@ -0,0 +1,44 @@
+package auth
+
+import (
+ "sync"
+ "time"
+)
+
+// nonceCache merkt sich verwendete Nonces, solange ihr Zeitstempel noch
+// akzeptiert würde. Danach verhindert schon die Zeitprüfung jede
+// Wiederverwendung, der Eintrag kann weg.
+type nonceCache struct {
+ mu sync.Mutex
+ seen map[string]time.Time // Schlüssel+Nonce -> Ablauf
+ lastPurge time.Time
+}
+
+// add vermerkt nonce für key und meldet false, wenn sie schon bekannt ist.
+func (c *nonceCache) add(key, nonce string, expires, now time.Time) bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.seen == nil {
+ c.seen = map[string]time.Time{}
+ }
+ if now.Sub(c.lastPurge) > time.Minute {
+ for k, exp := range c.seen {
+ if !now.Before(exp) {
+ delete(c.seen, k)
+ }
+ }
+ c.lastPurge = now
+ }
+ id := key + "\x00" + nonce
+ if _, dup := c.seen[id]; dup {
+ return false
+ }
+ c.seen[id] = expires
+ return true
+}
+
+func (c *nonceCache) len() int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return len(c.seen)
+}
diff --git a/pkg/auth/nonce_test.go b/pkg/auth/nonce_test.go
new file mode 100644
index 0000000..2a768fa
--- /dev/null
+++ b/pkg/auth/nonce_test.go
@@ -0,0 +1,29 @@
+package auth
+
+import (
+ "testing"
+ "time"
+)
+
+func TestNonceCache(t *testing.T) {
+ now := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
+ var c nonceCache
+
+ if !c.add("keyA", "n1", now.Add(time.Minute), now) {
+ t.Fatal("erste Verwendung abgelehnt")
+ }
+ if c.add("keyA", "n1", now.Add(time.Minute), now) {
+ t.Error("Wiederholung akzeptiert")
+ }
+ if !c.add("keyB", "n1", now.Add(time.Minute), now) {
+ t.Error("gleiche Nonce eines anderen Schlüssels abgelehnt")
+ }
+
+ // Nach Ablauf wird der Eintrag aufgeräumt; der Zeitstempel-Check
+ // verhindert dann ohnehin jede Wiederverwendung.
+ later := now.Add(2 * time.Minute)
+ c.add("keyA", "n2", later.Add(time.Minute), later)
+ if n := c.len(); n != 1 {
+ t.Errorf("%d Einträge nach Ablauf, erwartet 1", n)
+ }
+}
diff --git a/pkg/auth/sign.go b/pkg/auth/sign.go
new file mode 100644
index 0000000..f6c65a3
--- /dev/null
+++ b/pkg/auth/sign.go
@@ -0,0 +1,109 @@
+package auth
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Header, in denen die Signatur übertragen wird.
+const (
+ HeaderKey = "X-Sultan-Key"
+ HeaderTimestamp = "X-Sultan-Timestamp"
+ HeaderNonce = "X-Sultan-Nonce"
+ HeaderSignature = "X-Sultan-Signature"
+)
+
+// ErrInsecureTransport: Request ohne TLS. Weder wird er signiert noch
+// akzeptiert.
+var ErrInsecureTransport = errors.New("auth: unverschlüsselte Verbindung abgelehnt")
+
+// signatureVersion trennt die signierten Daten von anderen Ed25519-Nutzungen
+// desselben Schlüssels und erlaubt spätere Formatänderungen.
+const signatureVersion = "SULTAN-ED25519-V1"
+
+// canonical baut die signierten Daten. Jede Zeile ist eindeutig abgegrenzt;
+// der Body geht als SHA-256 ein.
+func canonical(method, requestURI, timestamp, nonce string, body []byte) []byte {
+ sum := sha256.Sum256(body)
+ return []byte(strings.Join([]string{
+ signatureVersion, method, requestURI, timestamp, nonce, hex.EncodeToString(sum[:]),
+ }, "\n"))
+}
+
+// Signer signiert Requests des Agenten. Sign passt als stream.Client.Prepare.
+type Signer struct {
+ Key ed25519.PrivateKey
+ now func() time.Time
+}
+
+// Sign signiert r (Methode, Pfad+Query, Zeitstempel, frische Nonce, Body).
+// Der Body wird dafür gelesen und wiederhergestellt. Nur https-URLs werden
+// signiert.
+func (s *Signer) Sign(r *http.Request) error {
+ if len(s.Key) != ed25519.PrivateKeySize {
+ return ErrInvalidKey
+ }
+ if r.URL == nil || r.URL.Scheme != "https" {
+ return ErrInsecureTransport
+ }
+ body, err := readBody(r, -1)
+ if err != nil {
+ return err
+ }
+ if r.Body != nil {
+ r.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }
+ r.ContentLength = int64(len(body))
+ }
+
+ now := time.Now
+ if s.now != nil {
+ now = s.now
+ }
+ ts := strconv.FormatInt(now().Unix(), 10)
+ nonce := newNonce()
+ sig := ed25519.Sign(s.Key, canonical(r.Method, r.URL.RequestURI(), ts, nonce, body))
+
+ r.Header.Set(HeaderKey, EncodePublicKey(s.Key.Public().(ed25519.PublicKey)))
+ r.Header.Set(HeaderTimestamp, ts)
+ r.Header.Set(HeaderNonce, nonce)
+ r.Header.Set(HeaderSignature, base64.StdEncoding.EncodeToString(sig))
+ return nil
+}
+
+func newNonce() string {
+ var b [16]byte
+ rand.Read(b[:])
+ return base64.RawURLEncoding.EncodeToString(b[:])
+}
+
+// readBody liest den Body vollständig (limit < 0: unbegrenzt) und ersetzt
+// ihn durch eine Kopie, damit nachfolgende Handler ihn erneut lesen können.
+func readBody(r *http.Request, limit int64) ([]byte, error) {
+ if r.Body == nil || r.Body == http.NoBody {
+ return nil, nil
+ }
+ rd := io.Reader(r.Body)
+ if limit >= 0 {
+ rd = io.LimitReader(r.Body, limit+1)
+ }
+ body, err := io.ReadAll(rd)
+ r.Body.Close()
+ if err != nil {
+ return nil, err
+ }
+ if limit >= 0 && int64(len(body)) > limit {
+ return nil, ErrBodyTooLarge
+ }
+ r.Body = io.NopCloser(bytes.NewReader(body))
+ return body, nil
+}
diff --git a/pkg/auth/sign_test.go b/pkg/auth/sign_test.go
new file mode 100644
index 0000000..b6d62ba
--- /dev/null
+++ b/pkg/auth/sign_test.go
@@ -0,0 +1,87 @@
+package auth
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+type ed25519PublicKey = ed25519.PublicKey
+
+func TestSign_SetsHeaders(t *testing.T) {
+ priv, _ := GenerateKey()
+ s := &Signer{Key: priv}
+ r, _ := http.NewRequest(http.MethodGet, "https://ingress.example:8443/api/v1/stream", nil)
+ if err := s.Sign(r); err != nil {
+ t.Fatalf("Sign: %v", err)
+ }
+ if got := r.Header.Get(HeaderKey); got != EncodePublicKey(priv.Public().(ed25519.PublicKey)) {
+ t.Errorf("%s = %q", HeaderKey, got)
+ }
+ for _, h := range []string{HeaderTimestamp, HeaderNonce, HeaderSignature} {
+ if r.Header.Get(h) == "" {
+ t.Errorf("Header %s fehlt", h)
+ }
+ }
+}
+
+func TestSign_FreshNonceEachTime(t *testing.T) {
+ priv, _ := GenerateKey()
+ s := &Signer{Key: priv}
+ seen := map[string]bool{}
+ for range 100 {
+ r, _ := http.NewRequest(http.MethodGet, "https://h/x", nil)
+ if err := s.Sign(r); err != nil {
+ t.Fatal(err)
+ }
+ n := r.Header.Get(HeaderNonce)
+ if seen[n] {
+ t.Fatalf("Nonce %q doppelt", n)
+ }
+ seen[n] = true
+ }
+}
+
+func TestSign_PreservesBody(t *testing.T) {
+ priv, _ := GenerateKey()
+ r, _ := http.NewRequest(http.MethodPost, "https://h/api/v1/state", strings.NewReader(`{"a":1}`))
+ if err := (&Signer{Key: priv}).Sign(r); err != nil {
+ t.Fatal(err)
+ }
+ body, _ := io.ReadAll(r.Body)
+ if string(body) != `{"a":1}` {
+ t.Errorf("Body nach Sign = %q", body)
+ }
+ if r.GetBody == nil {
+ t.Fatal("GetBody fehlt – Redirects/Retries könnten den Body nicht erneut senden")
+ }
+ again, _ := r.GetBody()
+ body, _ = io.ReadAll(again)
+ if !bytes.Equal(body, []byte(`{"a":1}`)) {
+ t.Errorf("GetBody liefert %q", body)
+ }
+}
+
+// Kein Signieren für Klartext-Verbindungen: Signatur und Body dürfen nie
+// unverschlüsselt über die Leitung gehen.
+func TestSign_RefusesPlainHTTP(t *testing.T) {
+ priv, _ := GenerateKey()
+ r, _ := http.NewRequest(http.MethodGet, "http://ingress.example:8080/api/v1/stream", nil)
+ if err := (&Signer{Key: priv}).Sign(r); !errors.Is(err, ErrInsecureTransport) {
+ t.Errorf("erwartet ErrInsecureTransport, bekommen %v", err)
+ }
+ if r.Header.Get(HeaderSignature) != "" {
+ t.Error("Signatur trotz Klartext gesetzt")
+ }
+}
+
+func TestSign_NoKey(t *testing.T) {
+ r, _ := http.NewRequest(http.MethodGet, "https://h/x", nil)
+ if err := (&Signer{}).Sign(r); !errors.Is(err, ErrInvalidKey) {
+ t.Errorf("erwartet ErrInvalidKey, bekommen %v", err)
+ }
+}
diff --git a/pkg/auth/verify.go b/pkg/auth/verify.go
new file mode 100644
index 0000000..a8c161e
--- /dev/null
+++ b/pkg/auth/verify.go
@@ -0,0 +1,179 @@
+package auth
+
+import (
+ "context"
+ "crypto/ed25519"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "sultan/pkg/store"
+)
+
+var (
+ // ErrUnauthenticated: Signatur fehlt, ist falsch oder der Schlüssel ist
+ // nicht registriert. Bewusst ohne Unterscheidung nach außen.
+ ErrUnauthenticated = errors.New("auth: nicht authentifiziert")
+ // ErrClockSkew: Der Zeitstempel liegt außerhalb des erlaubten Fensters.
+ ErrClockSkew = errors.New("auth: Zeitstempel außerhalb des Toleranzfensters")
+ // ErrReplay: Die Nonce wurde bereits verwendet.
+ ErrReplay = errors.New("auth: Request wiederholt (Replay)")
+ // ErrForbidden: Authentifiziert, aber gesperrt (Mandant in Quarantäne).
+ ErrForbidden = errors.New("auth: Zugriff gesperrt")
+ // ErrBodyTooLarge: Der Body überschreitet MaxBodySize.
+ ErrBodyTooLarge = errors.New("auth: Body zu groß")
+)
+
+// Directory schlägt Knoten und Mandanten nach; *store.Store erfüllt es.
+type Directory interface {
+ GetNodeByPublicKey(ctx context.Context, publicKey string) (store.Node, error)
+ GetTenant(ctx context.Context, id string) (store.Tenant, error)
+}
+
+// Identity ist der geprüfte Absender eines Requests.
+type Identity struct {
+ Node store.Node
+ Tenant store.Tenant
+}
+
+// Verifier prüft signierte Requests auf Server-Seite. Der Nullwert (plus
+// Directory) ist einsatzbereit; er darf parallel verwendet werden.
+type Verifier struct {
+ Directory Directory
+ // MaxSkew ist die erlaubte Zeitabweichung zwischen Agent und Server (Default 5min).
+ MaxSkew time.Duration
+ // MaxBodySize begrenzt den signierten Body (Default 1 MiB).
+ MaxBodySize int64
+
+ now func() time.Time
+ nonces nonceCache
+}
+
+func (v *Verifier) maxSkew() time.Duration {
+ if v.MaxSkew > 0 {
+ return v.MaxSkew
+ }
+ return 5 * time.Minute
+}
+
+func (v *Verifier) maxBodySize() int64 {
+ if v.MaxBodySize > 0 {
+ return v.MaxBodySize
+ }
+ return 1 << 20
+}
+
+func (v *Verifier) clock() time.Time {
+ if v.now != nil {
+ return v.now()
+ }
+ return time.Now()
+}
+
+// Verify prüft r und liefert die Identität des registrierten Knotens.
+// Prüfreihenfolge: TLS, Signatur (ohne Datenbank), Zeitfenster, Nonce,
+// erst dann der Store – ungültige Requests kosten also keinen DB-Zugriff
+// und können keine Nonces verbrauchen.
+//
+// Gesperrt wird nur bei Quarantäne (ErrForbidden). Zahlungsverzug, Ablauf
+// und gedrainte Knoten regelt die Policy-Engine über das Routing; der Agent
+// braucht den Kanal weiterhin, z. B. für lease_renew.
+func (v *Verifier) Verify(r *http.Request) (Identity, error) {
+ key, err := v.check(r)
+ if err != nil {
+ return Identity{}, err
+ }
+ node, err := v.Directory.GetNodeByPublicKey(r.Context(), key)
+ if errors.Is(err, store.ErrNotFound) {
+ return Identity{}, fmt.Errorf("%w: Schlüssel nicht registriert", ErrUnauthenticated)
+ }
+ if err != nil {
+ return Identity{}, fmt.Errorf("auth: Knoten nachschlagen: %w", err)
+ }
+ tenant, err := v.Directory.GetTenant(r.Context(), node.TenantID)
+ if errors.Is(err, store.ErrNotFound) {
+ return Identity{}, fmt.Errorf("%w: Mandant fehlt", ErrUnauthenticated)
+ }
+ if err != nil {
+ return Identity{}, fmt.Errorf("auth: Mandant nachschlagen: %w", err)
+ }
+ if tenant.Status == store.TenantQuarantined {
+ return Identity{}, fmt.Errorf("%w: Mandant %s in Quarantäne", ErrForbidden, tenant.ID)
+ }
+ return Identity{Node: node, Tenant: tenant}, nil
+}
+
+// VerifyUnregistered prüft einen signierten Request eines noch nicht
+// registrierten Schlüssels (Enrollment). Bewiesen ist damit nur der Besitz
+// des privaten Schlüssels; Token und Mandant prüft store.Enroll.
+func (v *Verifier) VerifyUnregistered(r *http.Request) (ed25519.PublicKey, error) {
+ key, err := v.check(r)
+ if err != nil {
+ return nil, err
+ }
+ return DecodePublicKey(key)
+}
+
+// check prüft alles außer der Registrierung und liefert den kanonisch
+// kodierten Public Key.
+func (v *Verifier) check(r *http.Request) (string, error) {
+ if r.TLS == nil {
+ return "", ErrInsecureTransport
+ }
+ pub, err := DecodePublicKey(r.Header.Get(HeaderKey))
+ if err != nil {
+ return "", fmt.Errorf("%w: %s fehlt oder ungültig", ErrUnauthenticated, HeaderKey)
+ }
+ tsRaw := r.Header.Get(HeaderTimestamp)
+ ts, err := strconv.ParseInt(tsRaw, 10, 64)
+ if err != nil {
+ return "", fmt.Errorf("%w: %s fehlt oder ungültig", ErrUnauthenticated, HeaderTimestamp)
+ }
+ nonce := r.Header.Get(HeaderNonce)
+ if len(nonce) < 16 || len(nonce) > 64 {
+ return "", fmt.Errorf("%w: %s fehlt oder ungültig", ErrUnauthenticated, HeaderNonce)
+ }
+ sig, err := base64.StdEncoding.DecodeString(r.Header.Get(HeaderSignature))
+ if err != nil || len(sig) != ed25519.SignatureSize {
+ return "", fmt.Errorf("%w: %s fehlt oder ungültig", ErrUnauthenticated, HeaderSignature)
+ }
+ body, err := readBody(r, v.maxBodySize())
+ if err != nil {
+ return "", err
+ }
+ if !ed25519.Verify(pub, canonical(r.Method, r.URL.RequestURI(), tsRaw, nonce, body), sig) {
+ return "", fmt.Errorf("%w: Signatur ungültig", ErrUnauthenticated)
+ }
+
+ now := v.clock()
+ signedAt := time.Unix(ts, 0)
+ if d := now.Sub(signedAt); d > v.maxSkew() || d < -v.maxSkew() {
+ return "", fmt.Errorf("%w: Abweichung %v", ErrClockSkew, d.Round(time.Second))
+ }
+ key := EncodePublicKey(pub)
+ if !v.nonces.add(key, nonce, signedAt.Add(v.maxSkew()), now) {
+ return "", ErrReplay
+ }
+ return key, nil
+}
+
+// StatusCode bildet Fehler von Verify auf HTTP-Statuscodes ab.
+func StatusCode(err error) int {
+ switch {
+ case err == nil:
+ return http.StatusOK
+ case errors.Is(err, ErrInsecureTransport):
+ return http.StatusUpgradeRequired
+ case errors.Is(err, ErrBodyTooLarge):
+ return http.StatusRequestEntityTooLarge
+ case errors.Is(err, ErrForbidden):
+ return http.StatusForbidden
+ case errors.Is(err, ErrUnauthenticated), errors.Is(err, ErrClockSkew), errors.Is(err, ErrReplay):
+ return http.StatusUnauthorized
+ default:
+ return http.StatusInternalServerError
+ }
+}
diff --git a/pkg/auth/verify_test.go b/pkg/auth/verify_test.go
new file mode 100644
index 0000000..3a23740
--- /dev/null
+++ b/pkg/auth/verify_test.go
@@ -0,0 +1,385 @@
+package auth
+
+import (
+ "context"
+ "crypto/ed25519"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "sultan/pkg/store"
+)
+
+var ctx = context.Background()
+
+// fakeDirectory ist ein In-Memory-Ersatz für den Store.
+type fakeDirectory struct {
+ nodes map[string]store.Node // nach Public Key
+ tenants map[string]store.Tenant
+ err error // simuliert z. B. eine nicht erreichbare Datenbank
+}
+
+func (d *fakeDirectory) GetNodeByPublicKey(_ context.Context, key string) (store.Node, error) {
+ if d.err != nil {
+ return store.Node{}, d.err
+ }
+ n, ok := d.nodes[key]
+ if !ok {
+ return store.Node{}, store.ErrNotFound
+ }
+ return n, nil
+}
+
+func (d *fakeDirectory) GetTenant(_ context.Context, id string) (store.Tenant, error) {
+ t, ok := d.tenants[id]
+ if !ok {
+ return store.Tenant{}, store.ErrNotFound
+ }
+ return t, nil
+}
+
+type testEnv struct {
+ dir *fakeDirectory
+ signer *Signer
+ ver *Verifier
+ now time.Time
+}
+
+// newEnv: Mandant t1 im Community-Tier (Homelab) mit registriertem Knoten n1.
+func newEnv(t *testing.T) *testEnv {
+ t.Helper()
+ priv, err := GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub := EncodePublicKey(priv.Public().(ed25519.PublicKey))
+ env := &testEnv{
+ dir: &fakeDirectory{
+ nodes: map[string]store.Node{pub: {ID: "n1", TenantID: "t1", PublicKey: pub, Status: store.NodeOnline}},
+ tenants: map[string]store.Tenant{"t1": {ID: "t1", Tier: store.TierCommunity, Status: store.TenantActive}},
+ },
+ now: time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC),
+ }
+ clock := func() time.Time { return env.now }
+ env.signer = &Signer{Key: priv, now: clock}
+ env.ver = &Verifier{Directory: env.dir, now: clock}
+ return env
+}
+
+func (e *testEnv) signed(t *testing.T, method, url, body string) *http.Request {
+ t.Helper()
+ var rd io.Reader
+ if body != "" {
+ rd = strings.NewReader(body)
+ }
+ r := httptest.NewRequest(method, url, rd)
+ // httptest.NewRequest erzeugt einen Server-Request; zum Signieren
+ // braucht Sign die Client-Sicht mit Schema.
+ r.URL.Scheme, r.URL.Host = "https", r.Host
+ if err := e.signer.Sign(r); err != nil {
+ t.Fatalf("Sign: %v", err)
+ }
+ return r
+}
+
+func TestVerify_Valid(t *testing.T) {
+ env := newEnv(t)
+ id, err := env.ver.Verify(env.signed(t, http.MethodGet, "https://ingress/api/v1/stream?since=5", ""))
+ if err != nil {
+ t.Fatalf("Verify: %v", err)
+ }
+ if id.Node.ID != "n1" || id.Tenant.ID != "t1" {
+ t.Errorf("Identität = %+v", id)
+ }
+}
+
+// Auch im Community-/Homelab-Betrieb gibt es keinen Zugang ohne Signatur.
+func TestVerify_CommunityTierRequiresSignature(t *testing.T) {
+ env := newEnv(t)
+ r := httptest.NewRequest(http.MethodGet, "https://ingress/api/v1/stream", nil)
+ if _, err := env.ver.Verify(r); !errors.Is(err, ErrUnauthenticated) {
+ t.Errorf("unsignierter Request: erwartet ErrUnauthenticated, bekommen %v", err)
+ }
+}
+
+func TestVerify_BodyCoveredAndPreserved(t *testing.T) {
+ env := newEnv(t)
+ r := env.signed(t, http.MethodPost, "https://ingress/api/v1/state", `{"labels":{}}`)
+ if _, err := env.ver.Verify(r); err != nil {
+ t.Fatalf("Verify: %v", err)
+ }
+ body, _ := io.ReadAll(r.Body)
+ if string(body) != `{"labels":{}}` {
+ t.Errorf("Body nach Verify = %q, Handler muss ihn noch lesen können", body)
+ }
+}
+
+func TestVerify_MissingHeaders(t *testing.T) {
+ for _, h := range []string{HeaderKey, HeaderTimestamp, HeaderNonce, HeaderSignature} {
+ t.Run(h, func(t *testing.T) {
+ env := newEnv(t)
+ r := env.signed(t, http.MethodGet, "https://ingress/x", "")
+ r.Header.Del(h)
+ if _, err := env.ver.Verify(r); !errors.Is(err, ErrUnauthenticated) {
+ t.Errorf("erwartet ErrUnauthenticated, bekommen %v", err)
+ }
+ })
+ }
+}
+
+func TestVerify_Tampering(t *testing.T) {
+ other, _ := GenerateKey()
+ tests := map[string]func(r *http.Request){
+ "Methode": func(r *http.Request) { r.Method = http.MethodDelete },
+ "Pfad": func(r *http.Request) { r.URL.Path = "/api/v1/admin" },
+ "Query": func(r *http.Request) { r.URL.RawQuery = "since=0" },
+ "Body": func(r *http.Request) { r.Body = io.NopCloser(strings.NewReader(`{"labels":{"böse":1}}`)) },
+ "Zeitstempel": func(r *http.Request) { r.Header.Set(HeaderTimestamp, "1790000001") },
+ "Nonce": func(r *http.Request) { r.Header.Set(HeaderNonce, "andere-nonce-1234") },
+ "Signatur Müll": func(r *http.Request) { r.Header.Set(HeaderSignature, "!!!") },
+ "Signatur kurz": func(r *http.Request) { r.Header.Set(HeaderSignature, "AAAA") },
+ "Schlüssel Müll": func(r *http.Request) { r.Header.Set(HeaderKey, "AAAA") },
+ "fremder Schlüssel": func(r *http.Request) {
+ r.Header.Set(HeaderKey, EncodePublicKey(other.Public().(ed25519.PublicKey)))
+ },
+ }
+ for name, tamper := range tests {
+ t.Run(name, func(t *testing.T) {
+ env := newEnv(t)
+ r := env.signed(t, http.MethodPost, "https://ingress/api/v1/state?since=5", `{"labels":{}}`)
+ tamper(r)
+ if _, err := env.ver.Verify(r); !errors.Is(err, ErrUnauthenticated) {
+ t.Errorf("erwartet ErrUnauthenticated, bekommen %v", err)
+ }
+ })
+ }
+}
+
+func TestVerify_UnknownKey(t *testing.T) {
+ env := newEnv(t)
+ stranger, _ := GenerateKey()
+ env.signer.Key = stranger // korrekt signiert, aber nicht registriert
+ if _, err := env.ver.Verify(env.signed(t, http.MethodGet, "https://ingress/x", "")); !errors.Is(err, ErrUnauthenticated) {
+ t.Errorf("erwartet ErrUnauthenticated, bekommen %v", err)
+ }
+}
+
+func TestVerify_ClockSkew(t *testing.T) {
+ tests := []struct {
+ name string
+ offset time.Duration // Zeit des Signers relativ zum Server
+ ok bool
+ }{
+ {"4 Minuten alt", -4 * time.Minute, true},
+ {"4 Minuten voraus", 4 * time.Minute, true},
+ {"6 Minuten alt", -6 * time.Minute, false},
+ {"6 Minuten voraus", 6 * time.Minute, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ env := newEnv(t)
+ signTime := env.now.Add(tt.offset)
+ env.signer.now = func() time.Time { return signTime }
+ _, err := env.ver.Verify(env.signed(t, http.MethodGet, "https://ingress/x", ""))
+ if tt.ok && err != nil {
+ t.Errorf("erwartet OK, bekommen %v", err)
+ }
+ if !tt.ok && !errors.Is(err, ErrClockSkew) {
+ t.Errorf("erwartet ErrClockSkew, bekommen %v", err)
+ }
+ })
+ }
+ env := newEnv(t)
+ r := env.signed(t, http.MethodGet, "https://ingress/x", "")
+ r.Header.Set(HeaderTimestamp, "gestern")
+ if _, err := env.ver.Verify(r); !errors.Is(err, ErrUnauthenticated) {
+ t.Errorf("unlesbarer Zeitstempel: erwartet ErrUnauthenticated, bekommen %v", err)
+ }
+}
+
+func TestVerify_Replay(t *testing.T) {
+ env := newEnv(t)
+ r := env.signed(t, http.MethodGet, "https://ingress/x", "")
+ replay := r.Clone(ctx)
+ if _, err := env.ver.Verify(r); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := env.ver.Verify(replay); !errors.Is(err, ErrReplay) {
+ t.Errorf("erwartet ErrReplay, bekommen %v", err)
+ }
+}
+
+// Ein Angreifer ohne gültige Signatur darf keine Nonces "verbrennen".
+func TestVerify_InvalidSignatureDoesNotConsumeNonce(t *testing.T) {
+ env := newEnv(t)
+ r := env.signed(t, http.MethodGet, "https://ingress/x", "")
+ forged := r.Clone(ctx)
+ forged.Header.Set(HeaderSignature, r.Header.Get(HeaderKey)+"xx")
+ if _, err := env.ver.Verify(forged); !errors.Is(err, ErrUnauthenticated) {
+ t.Fatalf("Fälschung: %v", err)
+ }
+ if _, err := env.ver.Verify(r); err != nil {
+ t.Errorf("echter Request nach Fälschung abgelehnt: %v", err)
+ }
+}
+
+func TestVerify_RejectsPlainHTTP(t *testing.T) {
+ env := newEnv(t)
+ r := env.signed(t, http.MethodGet, "https://ingress/x", "")
+ r.TLS = nil // korrekt signiert, aber über Klartext angekommen
+ if _, err := env.ver.Verify(r); !errors.Is(err, ErrInsecureTransport) {
+ t.Errorf("erwartet ErrInsecureTransport, bekommen %v", err)
+ }
+}
+
+func TestVerify_BodyTooLarge(t *testing.T) {
+ env := newEnv(t)
+ env.ver.MaxBodySize = 10
+ r := env.signed(t, http.MethodPost, "https://ingress/x", strings.Repeat("x", 11))
+ if _, err := env.ver.Verify(r); !errors.Is(err, ErrBodyTooLarge) {
+ t.Errorf("erwartet ErrBodyTooLarge, bekommen %v", err)
+ }
+}
+
+// Verify beantwortet "wer ist das?". Gesperrt wird hier nur bei
+// Quarantäne; Zahlungsverzug, Ablauf und Drain regelt die Policy-Engine
+// über das Routing – der Agent braucht den Kanal weiterhin (z. B. für
+// lease_renew oder Reaktivierung).
+func TestVerify_StatusPolicy(t *testing.T) {
+ tests := []struct {
+ name string
+ tenant store.TenantStatus
+ node store.NodeStatus
+ want error
+ }{
+ {"aktiv", store.TenantActive, store.NodeOnline, nil},
+ {"Zahlungsverzug", store.TenantSuspendedPayment, store.NodeOnline, nil},
+ {"abgelaufen", store.TenantExpired, store.NodeOnline, nil},
+ {"Knoten gedraint", store.TenantActive, store.NodeDrained, nil},
+ {"Quarantäne", store.TenantQuarantined, store.NodeOnline, ErrForbidden},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ env := newEnv(t)
+ for k, n := range env.dir.nodes {
+ n.Status = tt.node
+ env.dir.nodes[k] = n
+ }
+ tn := env.dir.tenants["t1"]
+ tn.Status = tt.tenant
+ env.dir.tenants["t1"] = tn
+
+ id, err := env.ver.Verify(env.signed(t, http.MethodGet, "https://ingress/x", ""))
+ if !errors.Is(err, tt.want) {
+ t.Fatalf("erwartet %v, bekommen %v", tt.want, err)
+ }
+ if err == nil && (id.Tenant.Status != tt.tenant || id.Node.Status != tt.node) {
+ t.Errorf("Identität ohne Status: %+v", id)
+ }
+ })
+ }
+}
+
+func TestVerify_TenantMissing(t *testing.T) {
+ env := newEnv(t)
+ delete(env.dir.tenants, "t1")
+ if _, err := env.ver.Verify(env.signed(t, http.MethodGet, "https://ingress/x", "")); !errors.Is(err, ErrUnauthenticated) {
+ t.Errorf("erwartet ErrUnauthenticated, bekommen %v", err)
+ }
+}
+
+// Infrastrukturfehler sind keine Authentifizierungsfehler (500 statt 401).
+func TestVerify_DirectoryError(t *testing.T) {
+ env := newEnv(t)
+ dbDown := errors.New("Datenbank weg")
+ env.dir.err = dbDown
+ _, err := env.ver.Verify(env.signed(t, http.MethodGet, "https://ingress/x", ""))
+ if !errors.Is(err, dbDown) || errors.Is(err, ErrUnauthenticated) {
+ t.Errorf("erwartet den DB-Fehler, bekommen %v", err)
+ }
+ if StatusCode(err) != http.StatusInternalServerError {
+ t.Errorf("StatusCode = %d", StatusCode(err))
+ }
+}
+
+func TestStatusCode(t *testing.T) {
+ tests := map[error]int{
+ nil: http.StatusOK,
+ ErrUnauthenticated: http.StatusUnauthorized,
+ ErrClockSkew: http.StatusUnauthorized,
+ ErrReplay: http.StatusUnauthorized,
+ ErrForbidden: http.StatusForbidden,
+ ErrInsecureTransport: http.StatusUpgradeRequired,
+ ErrBodyTooLarge: http.StatusRequestEntityTooLarge,
+ }
+ for err, want := range tests {
+ if got := StatusCode(err); got != want {
+ t.Errorf("StatusCode(%v) = %d, erwartet %d", err, got, want)
+ }
+ }
+}
+
+// Enrollment: Der Schlüssel ist noch nicht registriert. Die Signatur beweist
+// nur den Besitz des privaten Schlüssels; den Rest prüft store.Enroll.
+func TestVerifyUnregistered(t *testing.T) {
+ env := newEnv(t)
+ fresh, _ := GenerateKey()
+ env.signer.Key = fresh
+ r := env.signed(t, http.MethodPost, "https://ingress/api/v1/enroll", `{"token":"sltn_x"}`)
+ replay := r.Clone(ctx)
+ replay.Body = io.NopCloser(strings.NewReader(`{"token":"sltn_x"}`))
+
+ pub, err := env.ver.VerifyUnregistered(r)
+ if err != nil {
+ t.Fatalf("VerifyUnregistered: %v", err)
+ }
+ if !pub.Equal(fresh.Public()) {
+ t.Error("falscher Public Key zurückgegeben")
+ }
+ if body, _ := io.ReadAll(r.Body); string(body) != `{"token":"sltn_x"}` {
+ t.Errorf("Body nicht erhalten: %q", body)
+ }
+ if _, err := env.ver.VerifyUnregistered(replay); !errors.Is(err, ErrReplay) {
+ t.Errorf("Replay: erwartet ErrReplay, bekommen %v", err)
+ }
+
+ tampered := env.signed(t, http.MethodPost, "https://ingress/api/v1/enroll", `{"token":"sltn_x"}`)
+ tampered.Body = io.NopCloser(strings.NewReader(`{"token":"sltn_y"}`))
+ if _, err := env.ver.VerifyUnregistered(tampered); !errors.Is(err, ErrUnauthenticated) {
+ t.Errorf("manipuliert: erwartet ErrUnauthenticated, bekommen %v", err)
+ }
+ plain := env.signed(t, http.MethodPost, "https://ingress/api/v1/enroll", "")
+ plain.TLS = nil
+ if _, err := env.ver.VerifyUnregistered(plain); !errors.Is(err, ErrInsecureTransport) {
+ t.Errorf("Klartext: erwartet ErrInsecureTransport, bekommen %v", err)
+ }
+}
+
+func TestVerifier_DefaultLimits(t *testing.T) {
+ var v Verifier
+ if v.maxSkew() != 5*time.Minute {
+ t.Errorf("MaxSkew-Default = %v", v.maxSkew())
+ }
+ if v.maxBodySize() != 1<<20 {
+ t.Errorf("MaxBodySize-Default = %d", v.maxBodySize())
+ }
+}
+
+func TestVerify_NonceFormat(t *testing.T) {
+ for _, nonce := range []string{"kurz", strings.Repeat("x", 200)} {
+ env := newEnv(t)
+ r := httptest.NewRequest(http.MethodGet, "https://ingress/x", nil)
+ r.Header.Set(HeaderKey, EncodePublicKey(env.signer.Key.Public().(ed25519.PublicKey)))
+ r.Header.Set(HeaderTimestamp, strconv.FormatInt(env.now.Unix(), 10))
+ r.Header.Set(HeaderNonce, nonce)
+ r.Header.Set(HeaderSignature, "AAAA")
+ if _, err := env.ver.Verify(r); !errors.Is(err, ErrUnauthenticated) {
+ t.Errorf("Nonce der Länge %d: erwartet ErrUnauthenticated, bekommen %v", len(nonce), err)
+ }
+ }
+}
diff --git a/pkg/crypto/cert.go b/pkg/crypto/cert.go
new file mode 100644
index 0000000..c6c9325
--- /dev/null
+++ b/pkg/crypto/cert.go
@@ -0,0 +1,191 @@
+// Package crypto stellt die Transportverschlüsselung zwischen Agent und
+// Server sicher (Spec Abschnitt 3.1):
+//
+// - 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.
+package crypto
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/base64"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "math/big"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+const (
+ serverKeyFile = "server.key"
+ serverCertFile = "server.crt"
+ certValidity = 20 * 365 * 24 * time.Hour
+)
+
+var (
+ // ErrIncompleteCert: Nur Schlüssel oder nur Zertifikat vorhanden. Es
+ // wird bewusst nichts neu erzeugt, weil das alle Pins der Agents brechen
+ // würde.
+ ErrIncompleteCert = errors.New("crypto: server.key und server.crt müssen beide vorhanden sein oder beide fehlen")
+ // ErrInsecureKeyFile: server.key ist für Gruppe oder andere lesbar.
+ ErrInsecureKeyFile = errors.New("crypto: server.key hat zu offene Rechte (erwartet 0600)")
+ // ErrInvalidFingerprint: Kein gültiger Fingerprint.
+ ErrInvalidFingerprint = errors.New("crypto: ungültiger Fingerprint")
+)
+
+// Fingerprint ist der SHA-256 des DER-kodierten Zertifikats, Base64url ohne
+// Padding (43 Zeichen) – kompakt genug für den Enrollment-Token.
+type Fingerprint string
+
+// FingerprintOf berechnet den Fingerprint eines Zertifikats.
+func FingerprintOf(cert *x509.Certificate) Fingerprint {
+ sum := sha256.Sum256(cert.Raw)
+ return Fingerprint(base64.RawURLEncoding.EncodeToString(sum[:]))
+}
+
+// ParseFingerprint prüft und übernimmt einen Fingerprint.
+func ParseFingerprint(s string) (Fingerprint, error) {
+ b, err := base64.RawURLEncoding.DecodeString(s)
+ if err != nil || len(b) != sha256.Size {
+ return "", fmt.Errorf("%w: %q", ErrInvalidFingerprint, s)
+ }
+ return Fingerprint(s), nil
+}
+
+// ServerCert ist das TLS-Zertifikat des Servers samt Fingerprint.
+type ServerCert struct {
+ Certificate tls.Certificate
+ Fingerprint Fingerprint
+}
+
+// Leaf liefert das geparste Zertifikat.
+func (s ServerCert) Leaf() *x509.Certificate { return s.Certificate.Leaf }
+
+// LoadOrCreateServerCert lädt server.key/server.crt aus dir oder erzeugt sie
+// beim allerersten Start. Das Zertifikat ist 20 Jahre gültig: Agents pinnen
+// den Fingerprint, ein Wechsel würde alle aussperren.
+func LoadOrCreateServerCert(dir string) (ServerCert, error) {
+ keyPath, certPath := filepath.Join(dir, serverKeyFile), filepath.Join(dir, serverCertFile)
+ keyExists, err := exists(keyPath)
+ if err != nil {
+ return ServerCert{}, err
+ }
+ certExists, err := exists(certPath)
+ if err != nil {
+ return ServerCert{}, err
+ }
+ switch {
+ case keyExists && certExists:
+ case !keyExists && !certExists:
+ if err := createServerCert(keyPath, certPath); err != nil {
+ return ServerCert{}, err
+ }
+ default:
+ return ServerCert{}, fmt.Errorf("%w (in %s)", ErrIncompleteCert, dir)
+ }
+
+ info, err := os.Stat(keyPath)
+ if err != nil {
+ return ServerCert{}, err
+ }
+ if info.Mode().Perm()&0o077 != 0 {
+ return ServerCert{}, fmt.Errorf("%w: %s hat %o", ErrInsecureKeyFile, keyPath, info.Mode().Perm())
+ }
+ cert, err := tls.LoadX509KeyPair(certPath, keyPath)
+ if err != nil {
+ return ServerCert{}, fmt.Errorf("crypto: Server-Zertifikat laden: %w", err)
+ }
+ if cert.Leaf == nil {
+ if cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0]); err != nil {
+ return ServerCert{}, err
+ }
+ }
+ return ServerCert{Certificate: cert, Fingerprint: FingerprintOf(cert.Leaf)}, nil
+}
+
+func exists(path string) (bool, error) {
+ _, err := os.Stat(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return false, nil
+ }
+ return err == nil, err
+}
+
+func createServerCert(keyPath, certPath string) error {
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return err
+ }
+ serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
+ if err != nil {
+ return err
+ }
+ now := time.Now()
+ tmpl := &x509.Certificate{
+ SerialNumber: serial,
+ Subject: pkix.Name{CommonName: "sultan-server"},
+ NotBefore: now.Add(-time.Hour),
+ NotAfter: now.Add(certValidity),
+ KeyUsage: x509.KeyUsageDigitalSignature,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ BasicConstraintsValid: true,
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ if err != nil {
+ return err
+ }
+ keyDER, err := x509.MarshalPKCS8PrivateKey(key)
+ if err != nil {
+ return err
+ }
+ if err := writeNew(keyPath, 0o600, &pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}); err != nil {
+ return err
+ }
+ if err := writeNew(certPath, 0o644, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
+ os.Remove(keyPath)
+ return err
+ }
+ return nil
+}
+
+// writeNew legt path neu an (nie überschreiben) und setzt die Rechte
+// unabhängig von der umask.
+func writeNew(path string, perm os.FileMode, block *pem.Block) error {
+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm)
+ if err != nil {
+ return err
+ }
+ err = pem.Encode(f, block)
+ if cerr := f.Close(); err == nil {
+ err = cerr
+ }
+ if err == nil {
+ err = os.Chmod(path, perm)
+ }
+ if err != nil {
+ os.Remove(path)
+ }
+ return err
+}
+
+// ServerTLSConfig liefert die TLS-Konfiguration für den Agent-Port (:8443):
+// nur TLS 1.3.
+func ServerTLSConfig(sc ServerCert) *tls.Config {
+ return &tls.Config{
+ Certificates: []tls.Certificate{sc.Certificate},
+ MinVersion: tls.VersionTLS13,
+ }
+}
diff --git a/pkg/crypto/cert_test.go b/pkg/crypto/cert_test.go
new file mode 100644
index 0000000..9cd2596
--- /dev/null
+++ b/pkg/crypto/cert_test.go
@@ -0,0 +1,139 @@
+package crypto
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/tls"
+ "crypto/x509"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestLoadOrCreateServerCert_Creates(t *testing.T) {
+ dir := t.TempDir()
+ sc, err := LoadOrCreateServerCert(dir)
+ if err != nil {
+ t.Fatalf("LoadOrCreateServerCert: %v", err)
+ }
+ for name, wantPerm := range map[string]os.FileMode{serverKeyFile: 0o600, serverCertFile: 0o644} {
+ info, err := os.Stat(filepath.Join(dir, name))
+ if err != nil {
+ t.Fatalf("%s fehlt: %v", name, err)
+ }
+ if info.Mode().Perm() != wantPerm {
+ t.Errorf("%s hat %o, erwartet %o", name, info.Mode().Perm(), wantPerm)
+ }
+ }
+
+ leaf := sc.Leaf()
+ key, ok := leaf.PublicKey.(*ecdsa.PublicKey)
+ if !ok || key.Curve != elliptic.P256() {
+ t.Errorf("Schlüssel ist kein ECDSA P-256: %T", leaf.PublicKey)
+ }
+ // Selbstsigniert, aber bewusst keine CA (könnte sonst weitere
+ // Zertifikate ausstellen) – daher nicht CheckSignatureFrom.
+ if err := leaf.CheckSignature(leaf.SignatureAlgorithm, leaf.RawTBSCertificate, leaf.Signature); err != nil ||
+ leaf.Issuer.String() != leaf.Subject.String() {
+ t.Errorf("nicht selbstsigniert: %v", err)
+ }
+ if leaf.IsCA {
+ t.Error("Server-Zertifikat darf keine CA sein")
+ }
+ if len(leaf.ExtKeyUsage) != 1 || leaf.ExtKeyUsage[0] != x509.ExtKeyUsageServerAuth {
+ t.Errorf("ExtKeyUsage = %v, erwartet ServerAuth", leaf.ExtKeyUsage)
+ }
+ if leaf.NotAfter.Before(time.Now().AddDate(9, 0, 0)) {
+ t.Errorf("gültig nur bis %v – ein Ablauf würde alle Pins brechen", leaf.NotAfter)
+ }
+ if sc.Fingerprint != FingerprintOf(leaf) {
+ t.Error("Fingerprint passt nicht zum Zertifikat")
+ }
+}
+
+func TestLoadOrCreateServerCert_StableAcrossRestarts(t *testing.T) {
+ dir := t.TempDir()
+ first, err := LoadOrCreateServerCert(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := LoadOrCreateServerCert(dir)
+ if err != nil {
+ t.Fatalf("erneutes Laden: %v", err)
+ }
+ if first.Fingerprint != second.Fingerprint {
+ t.Error("Fingerprint hat sich beim Neustart geändert – alle Agents würden die Verbindung verweigern")
+ }
+}
+
+func TestLoadOrCreateServerCert_Errors(t *testing.T) {
+ t.Run("nur Zertifikat vorhanden", func(t *testing.T) {
+ dir := t.TempDir()
+ LoadOrCreateServerCert(dir)
+ os.Remove(filepath.Join(dir, serverKeyFile))
+ if _, err := LoadOrCreateServerCert(dir); !errors.Is(err, ErrIncompleteCert) {
+ t.Errorf("erwartet ErrIncompleteCert, bekommen %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(dir, serverKeyFile)); err == nil {
+ t.Error("fehlender Schlüssel wurde stillschweigend neu erzeugt")
+ }
+ })
+ t.Run("nur Schlüssel vorhanden", func(t *testing.T) {
+ dir := t.TempDir()
+ LoadOrCreateServerCert(dir)
+ os.Remove(filepath.Join(dir, serverCertFile))
+ if _, err := LoadOrCreateServerCert(dir); !errors.Is(err, ErrIncompleteCert) {
+ t.Errorf("erwartet ErrIncompleteCert, bekommen %v", err)
+ }
+ })
+ t.Run("Schlüssel zu offen", func(t *testing.T) {
+ dir := t.TempDir()
+ LoadOrCreateServerCert(dir)
+ os.Chmod(filepath.Join(dir, serverKeyFile), 0o644)
+ if _, err := LoadOrCreateServerCert(dir); !errors.Is(err, ErrInsecureKeyFile) {
+ t.Errorf("erwartet ErrInsecureKeyFile, bekommen %v", err)
+ }
+ })
+ t.Run("Schlüssel passt nicht zum Zertifikat", func(t *testing.T) {
+ a, b := t.TempDir(), t.TempDir()
+ LoadOrCreateServerCert(a)
+ LoadOrCreateServerCert(b)
+ data, _ := os.ReadFile(filepath.Join(b, serverCertFile))
+ os.WriteFile(filepath.Join(a, serverCertFile), data, 0o644)
+ if _, err := LoadOrCreateServerCert(a); err == nil {
+ t.Error("erwartet Fehler bei fremdem Zertifikat")
+ }
+ })
+ t.Run("Verzeichnis fehlt", func(t *testing.T) {
+ if _, err := LoadOrCreateServerCert(filepath.Join(t.TempDir(), "fehlt")); err == nil {
+ t.Error("erwartet Fehler")
+ }
+ })
+}
+
+func TestServerTLSConfig_OnlyTLS13(t *testing.T) {
+ sc, err := LoadOrCreateServerCert(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg := ServerTLSConfig(sc)
+ if cfg.MinVersion != tls.VersionTLS13 {
+ t.Errorf("MinVersion = %x, erwartet TLS 1.3", cfg.MinVersion)
+ }
+
+ srv := httptest.NewUnstartedServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+ srv.TLS = cfg
+ srv.StartTLS()
+ defer srv.Close()
+
+ old := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true, MaxVersion: tls.VersionTLS12,
+ }}}
+ if _, err := old.Get(srv.URL); err == nil {
+ t.Error("TLS-1.2-Client wurde akzeptiert")
+ }
+}
diff --git a/pkg/crypto/pin.go b/pkg/crypto/pin.go
new file mode 100644
index 0000000..ea06edc
--- /dev/null
+++ b/pkg/crypto/pin.go
@@ -0,0 +1,142 @@
+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).
+ 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:
+//
+// - 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 {
+ return &tls.Config{
+ MinVersion: tls.VersionTLS13,
+ // Die CA-Prüfung entfällt bewusst; VerifyConnection ersetzt sie
+ // durch die strengere Fingerprint-Prüfung.
+ InsecureSkipVerify: true,
+ VerifyConnection: func(cs tls.ConnectionState) error {
+ if len(cs.PeerCertificates) == 0 {
+ return fmt.Errorf("%w: kein Zertifikat", ErrFingerprintMismatch)
+ }
+ actual := FingerprintOf(cs.PeerCertificates[0])
+ pinned, ok, err := ks.Lookup(server)
+ if err != nil {
+ return err
+ }
+ if ok {
+ if actual != pinned {
+ return fmt.Errorf("%w: %s präsentiert %s, gepinnt ist %s", ErrFingerprintMismatch, server, actual, pinned)
+ }
+ 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)
+ },
+ }
+}
diff --git a/pkg/crypto/pin_test.go b/pkg/crypto/pin_test.go
new file mode 100644
index 0000000..8c3ee90
--- /dev/null
+++ b/pkg/crypto/pin_test.go
@@ -0,0 +1,193 @@
+package crypto
+
+import (
+ "crypto/tls"
+ "errors"
+ "io"
+ "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) {
+ t.Helper()
+ sc, err := LoadOrCreateServerCert(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var hits atomic.Int32
+ srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ }))
+ srv.TLS = ServerTLSConfig(sc)
+ srv.Config.ErrorLog = log.New(io.Discard, "", 0) // abgewiesene Handshakes sind hier gewollt
+ srv.StartTLS()
+ t.Cleanup(srv.Close)
+ return srv, sc, &hits
+}
+
+func get(t *testing.T, srv *httptest.Server, cfg *tls.Config) error {
+ t.Helper()
+ c := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}}
+ resp, err := c.Get(srv.URL)
+ if err == nil {
+ resp.Body.Close()
+ }
+ return err
+}
+
+func TestClientTLS_FirstContactWithExpectedFingerprint(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 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)
+ }
+}
+
+// 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) {
+ 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) {
+ 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)
+ }
+}
diff --git a/pkg/crypto/token.go b/pkg/crypto/token.go
new file mode 100644
index 0000000..e7c0e4c
--- /dev/null
+++ b/pkg/crypto/token.go
@@ -0,0 +1,65 @@
+package crypto
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "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")
+
+// 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.
+type EnrollmentToken struct {
+ Secret string
+ Fingerprint Fingerprint
+}
+
+// String liefert die Form, die der Admin an den Agenten weitergibt.
+func (t EnrollmentToken) String() string { return t.Secret + "." + string(t.Fingerprint) }
+
+// NewEnrollmentToken erzeugt einen Token mit 256 Bit Zufall für den Server
+// mit Fingerprint fp.
+func NewEnrollmentToken(fp Fingerprint) (EnrollmentToken, error) {
+ if _, err := ParseFingerprint(string(fp)); err != nil {
+ return EnrollmentToken{}, err
+ }
+ 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
+}
+
+// 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)
+ }
+ secret, rawFP := parts[0], parts[1]
+ body, ok := strings.CutPrefix(secret, TokenPrefix)
+ if !ok {
+ return EnrollmentToken{}, fmt.Errorf("%w: Präfix %s fehlt", ErrInvalidToken, TokenPrefix)
+ }
+ if b, err := base64.RawURLEncoding.DecodeString(body); err != nil || len(b) != 32 {
+ return EnrollmentToken{}, fmt.Errorf("%w: Secret beschädigt", ErrInvalidToken)
+ }
+ fp, err := ParseFingerprint(rawFP)
+ if err != nil {
+ return EnrollmentToken{}, fmt.Errorf("%w: %w", ErrInvalidToken, err)
+ }
+ return EnrollmentToken{Secret: secret, Fingerprint: fp}, nil
+}
diff --git a/pkg/crypto/token_test.go b/pkg/crypto/token_test.go
new file mode 100644
index 0000000..1b8f341
--- /dev/null
+++ b/pkg/crypto/token_test.go
@@ -0,0 +1,92 @@
+package crypto
+
+import (
+ "errors"
+ "strings"
+ "testing"
+)
+
+func TestFingerprint_ParseRoundTrip(t *testing.T) {
+ sc, err := LoadOrCreateServerCert(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := ParseFingerprint(string(sc.Fingerprint))
+ if err != nil || got != sc.Fingerprint {
+ t.Errorf("ParseFingerprint = %q, %v", got, err)
+ }
+ for _, bad := range []string{"", "abc", strings.Repeat("A", 42), strings.Repeat("A", 44), strings.Repeat("+", 43), "sha256:" + string(sc.Fingerprint)} {
+ if _, err := ParseFingerprint(bad); !errors.Is(err, ErrInvalidFingerprint) {
+ t.Errorf("ParseFingerprint(%q): erwartet ErrInvalidFingerprint, bekommen %v", bad, err)
+ }
+ }
+}
+
+func TestEnrollmentToken_RoundTrip(t *testing.T) {
+ sc, _ := LoadOrCreateServerCert(t.TempDir())
+ tok, err := NewEnrollmentToken(sc.Fingerprint)
+ if err != nil {
+ t.Fatalf("NewEnrollmentToken: %v", err)
+ }
+ s := tok.String()
+ if !strings.HasPrefix(s, TokenPrefix) {
+ t.Errorf("Token %q ohne Präfix %q", s, TokenPrefix)
+ }
+ if !strings.HasPrefix(tok.Secret, TokenPrefix) || strings.Contains(tok.Secret, ".") {
+ t.Errorf("Secret %q: erwartet Präfix und keinen Fingerprint-Anteil", tok.Secret)
+ }
+
+ parsed, err := ParseEnrollmentToken(s)
+ if err != nil {
+ t.Fatalf("ParseEnrollmentToken: %v", err)
+ }
+ if parsed != tok {
+ t.Errorf("Round-Trip weicht ab: %+v vs %+v", parsed, tok)
+ }
+ if parsed2, _ := ParseEnrollmentToken(" " + s + "\n"); parsed2 != tok {
+ t.Error("Leerraum beim Kopieren sollte toleriert werden")
+ }
+}
+
+func TestEnrollmentToken_Unique(t *testing.T) {
+ sc, _ := LoadOrCreateServerCert(t.TempDir())
+ seen := map[string]bool{}
+ for range 100 {
+ tok, err := NewEnrollmentToken(sc.Fingerprint)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if seen[tok.Secret] {
+ t.Fatal("doppeltes Secret")
+ }
+ seen[tok.Secret] = true
+ }
+}
+
+func TestEnrollmentToken_Errors(t *testing.T) {
+ sc, _ := LoadOrCreateServerCert(t.TempDir())
+ good, _ := NewEnrollmentToken(sc.Fingerprint)
+ secretPart := strings.TrimPrefix(good.Secret, TokenPrefix)
+ fp := string(sc.Fingerprint)
+
+ tests := map[string]string{
+ "leer": "",
+ "falsches Präfix": "sltn_token_" + secretPart + "." + fp,
+ "ohne Fingerprint": good.Secret,
+ "leerer Fingerprint": good.Secret + ".",
+ "kaputter Fingerprint": good.Secret + ".xyz",
+ "Secret zu kurz": TokenPrefix + "abc." + fp,
+ "Secret kein base64url": TokenPrefix + strings.Repeat("!", 43) + "." + fp,
+ "zu viele Teile": good.String() + ".extra",
+ }
+ for name, s := range tests {
+ t.Run(name, func(t *testing.T) {
+ if _, err := ParseEnrollmentToken(s); !errors.Is(err, ErrInvalidToken) {
+ t.Errorf("erwartet ErrInvalidToken, bekommen %v", err)
+ }
+ })
+ }
+ if _, err := NewEnrollmentToken("kaputt"); !errors.Is(err, ErrInvalidFingerprint) {
+ t.Errorf("NewEnrollmentToken mit kaputtem Fingerprint: %v", err)
+ }
+}
diff --git a/pkg/store/nodes.go b/pkg/store/nodes.go
new file mode 100644
index 0000000..eeaf978
--- /dev/null
+++ b/pkg/store/nodes.go
@@ -0,0 +1,155 @@
+package store
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+)
+
+// NodeStatus ist der Verbindungsstatus eines Agenten.
+type NodeStatus string
+
+const (
+ NodeOnline NodeStatus = "online"
+ NodeOffline NodeStatus = "offline"
+ NodeDrained NodeStatus = "drained"
+)
+
+func (s NodeStatus) valid() bool {
+ return s == NodeOnline || s == NodeOffline || s == NodeDrained
+}
+
+// Node ist ein registrierter Agent.
+type Node struct {
+ ID string
+ TenantID string
+ Hostname string
+ PublicKey string // Ed25519 Public Key, vom Aufrufer kodiert (z. B. Base64)
+ LastSeen time.Time
+ IPAddress string
+ Status NodeStatus
+}
+
+func (n *Node) normalize(now time.Time) error {
+ if n.Status == "" {
+ n.Status = NodeOnline
+ }
+ if n.LastSeen.IsZero() {
+ n.LastSeen = now
+ }
+ n.LastSeen = normTime(n.LastSeen)
+
+ switch {
+ case blank(n.ID):
+ return invalid("Knoten-ID fehlt")
+ case blank(n.TenantID):
+ return invalid("Mandant fehlt")
+ case blank(n.Hostname):
+ return invalid("Hostname fehlt")
+ case blank(n.PublicKey):
+ return invalid("Public Key fehlt")
+ case blank(n.IPAddress):
+ return invalid("IP-Adresse fehlt")
+ case !n.Status.valid():
+ return invalid("unbekannter Status %q", n.Status)
+ }
+ return nil
+}
+
+// CreateNode legt einen Knoten an. Status ist ohne Angabe online, LastSeen
+// ohne Angabe jetzt. ID und Public Key müssen eindeutig sein (ErrExists), der
+// Mandant muss existieren (ErrNotFound).
+func (s *Store) CreateNode(ctx context.Context, n Node) (Node, error) {
+ return s.insertNode(ctx, s.db, n)
+}
+
+func (s *Store) insertNode(ctx context.Context, db execer, n Node) (Node, error) {
+ if err := n.normalize(s.now()); err != nil {
+ return Node{}, err
+ }
+ _, err := db.ExecContext(ctx, `INSERT INTO nodes
+ (id, tenant_id, hostname, public_key, last_seen, ip_address, status)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ n.ID, n.TenantID, n.Hostname, n.PublicKey, formatTime(n.LastSeen), n.IPAddress, n.Status)
+ if err != nil {
+ return Node{}, mapConstraint(err, "Knoten "+n.ID)
+ }
+ return n, nil
+}
+
+const nodeColumns = `id, tenant_id, hostname, public_key, last_seen, ip_address, status`
+
+func scanNode(row interface{ Scan(...any) error }) (Node, error) {
+ var n Node
+ err := row.Scan(&n.ID, &n.TenantID, &n.Hostname, &n.PublicKey, dbTime{&n.LastSeen}, &n.IPAddress, &n.Status)
+ return n, err
+}
+
+func (s *Store) getNode(ctx context.Context, where, arg, what string) (Node, error) {
+ n, err := scanNode(s.db.QueryRowContext(ctx, `SELECT `+nodeColumns+` FROM nodes WHERE `+where+` = ?`, arg))
+ if errors.Is(err, sql.ErrNoRows) {
+ return Node{}, fmt.Errorf("%s: %w", what, ErrNotFound)
+ }
+ return n, err
+}
+
+// GetNode liest einen Knoten über seine ID.
+func (s *Store) GetNode(ctx context.Context, id string) (Node, error) {
+ return s.getNode(ctx, "id", id, "Knoten "+id)
+}
+
+// GetNodeByPublicKey liest einen Knoten über seinen Public Key (für die
+// Prüfung signierter Requests).
+func (s *Store) GetNodeByPublicKey(ctx context.Context, publicKey string) (Node, error) {
+ return s.getNode(ctx, "public_key", publicKey, "Knoten mit diesem Public Key")
+}
+
+// ListNodes liefert die Knoten eines Mandanten (tenantID "" = alle), sortiert nach ID.
+func (s *Store) ListNodes(ctx context.Context, tenantID string) ([]Node, error) {
+ q, args := `SELECT `+nodeColumns+` FROM nodes ORDER BY id`, []any{}
+ if tenantID != "" {
+ q, args = `SELECT `+nodeColumns+` FROM nodes WHERE tenant_id = ? ORDER BY id`, []any{tenantID}
+ }
+ rows, err := s.db.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ nodes := []Node{}
+ for rows.Next() {
+ n, err := scanNode(rows)
+ if err != nil {
+ return nil, err
+ }
+ nodes = append(nodes, n)
+ }
+ return nodes, rows.Err()
+}
+
+// TouchNode vermerkt ein Lebenszeichen: last_seen = jetzt und die aktuelle
+// IP. Der Status bleibt unverändert (ein gedrainter Knoten bleibt gedraint).
+func (s *Store) TouchNode(ctx context.Context, id, ip string) error {
+ if blank(ip) {
+ return invalid("IP-Adresse fehlt")
+ }
+ res, err := s.db.ExecContext(ctx, `UPDATE nodes SET last_seen = ?, ip_address = ? WHERE id = ?`,
+ formatTime(s.now()), ip, id)
+ return requireAffected(res, err, "Knoten "+id)
+}
+
+// SetNodeStatus ändert den Status eines Knotens.
+func (s *Store) SetNodeStatus(ctx context.Context, id string, status NodeStatus) error {
+ if !status.valid() {
+ return invalid("unbekannter Status %q", status)
+ }
+ res, err := s.db.ExecContext(ctx, `UPDATE nodes SET status = ? WHERE id = ?`, status, id)
+ return requireAffected(res, err, "Knoten "+id)
+}
+
+// DeleteNode löscht einen Knoten; sein Public Key wird damit wieder frei.
+func (s *Store) DeleteNode(ctx context.Context, id string) error {
+ res, err := s.db.ExecContext(ctx, `DELETE FROM nodes WHERE id = ?`, id)
+ return requireAffected(res, err, "Knoten "+id)
+}
diff --git a/pkg/store/nodes_test.go b/pkg/store/nodes_test.go
new file mode 100644
index 0000000..4e4aa7e
--- /dev/null
+++ b/pkg/store/nodes_test.go
@@ -0,0 +1,226 @@
+package store
+
+import (
+ "errors"
+ "reflect"
+ "testing"
+ "time"
+)
+
+func sampleNode(id, publicKey string) Node {
+ return Node{
+ ID: id,
+ Hostname: "host-" + id,
+ PublicKey: publicKey,
+ IPAddress: "100.64.0.42",
+ }
+}
+
+func mustNode(t *testing.T, s *Store, id, tenantID, publicKey string) Node {
+ t.Helper()
+ n := sampleNode(id, publicKey)
+ n.TenantID = tenantID
+ created, err := s.CreateNode(ctx, n)
+ if err != nil {
+ t.Fatalf("CreateNode(%s): %v", id, err)
+ }
+ return created
+}
+
+func TestCreateNode_RoundTrip(t *testing.T) {
+ s, clock := openTest(t)
+ mustTenant(t, s, "t1")
+ n := sampleNode("node-proxmox-vm-42", "MCowBQYDK2VwAyEAabc=")
+ n.TenantID = "t1"
+
+ created, err := s.CreateNode(ctx, n)
+ if err != nil {
+ t.Fatalf("CreateNode: %v", err)
+ }
+ if created.Status != NodeOnline {
+ t.Errorf("Default-Status = %q, erwartet online", created.Status)
+ }
+ if !created.LastSeen.Equal(clock.now()) {
+ t.Errorf("LastSeen = %v, erwartet jetzt", created.LastSeen)
+ }
+
+ byID, err := s.GetNode(ctx, "node-proxmox-vm-42")
+ if err != nil {
+ t.Fatal(err)
+ }
+ byKey, err := s.GetNodeByPublicKey(ctx, "MCowBQYDK2VwAyEAabc=")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(byID, created) || !reflect.DeepEqual(byKey, created) {
+ t.Errorf("gelesen weicht ab\n byID: %+v\nbyKey: %+v\n want: %+v", byID, byKey, created)
+ }
+}
+
+func TestCreateNode_KeepsExplicitValues(t *testing.T) {
+ s, _ := openTest(t)
+ mustTenant(t, s, "t1")
+ seen := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
+ n := sampleNode("n1", "k1")
+ n.TenantID, n.Status, n.LastSeen = "t1", NodeDrained, seen
+
+ created, err := s.CreateNode(ctx, n)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if created.Status != NodeDrained || !created.LastSeen.Equal(seen) {
+ t.Errorf("explizite Werte überschrieben: %+v", created)
+ }
+}
+
+func TestCreateNode_Errors(t *testing.T) {
+ s, _ := openTest(t)
+ mustTenant(t, s, "t1")
+ mustNode(t, s, "n1", "t1", "key1")
+
+ unknownTenant := sampleNode("n2", "key2")
+ unknownTenant.TenantID = "fehlt"
+ if _, err := s.CreateNode(ctx, unknownTenant); !errors.Is(err, ErrNotFound) {
+ t.Errorf("unbekannter Mandant: erwartet ErrNotFound, bekommen %v", err)
+ }
+
+ dupID := sampleNode("n1", "key-neu")
+ dupID.TenantID = "t1"
+ if _, err := s.CreateNode(ctx, dupID); !errors.Is(err, ErrExists) {
+ t.Errorf("doppelte ID: erwartet ErrExists, bekommen %v", err)
+ }
+
+ dupKey := sampleNode("n-neu", "key1")
+ dupKey.TenantID = "t1"
+ if _, err := s.CreateNode(ctx, dupKey); !errors.Is(err, ErrExists) {
+ t.Errorf("doppelter Public Key: erwartet ErrExists, bekommen %v", err)
+ }
+}
+
+func TestCreateNode_Validation(t *testing.T) {
+ tests := map[string]func(*Node){
+ "ID leer": func(n *Node) { n.ID = "" },
+ "Mandant leer": func(n *Node) { n.TenantID = "" },
+ "Hostname leer": func(n *Node) { n.Hostname = "" },
+ "Public Key leer": func(n *Node) { n.PublicKey = " " },
+ "IP leer": func(n *Node) { n.IPAddress = "" },
+ "unbekannter Status": func(n *Node) { n.Status = "schläft" },
+ }
+ for name, mutate := range tests {
+ t.Run(name, func(t *testing.T) {
+ s, _ := openTest(t)
+ mustTenant(t, s, "t1")
+ n := sampleNode("n1", "k1")
+ n.TenantID = "t1"
+ mutate(&n)
+ if _, err := s.CreateNode(ctx, n); !errors.Is(err, ErrInvalid) {
+ t.Errorf("erwartet ErrInvalid, bekommen %v", err)
+ }
+ })
+ }
+}
+
+func TestGetNode_NotFound(t *testing.T) {
+ s, _ := openTest(t)
+ if _, err := s.GetNode(ctx, "fehlt"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("GetNode: erwartet ErrNotFound, bekommen %v", err)
+ }
+ if _, err := s.GetNodeByPublicKey(ctx, "fehlt"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("GetNodeByPublicKey: erwartet ErrNotFound, bekommen %v", err)
+ }
+}
+
+func TestListNodes(t *testing.T) {
+ s, _ := openTest(t)
+ mustTenant(t, s, "t1")
+ mustTenant(t, s, "t2")
+ mustNode(t, s, "c", "t1", "k-c")
+ mustNode(t, s, "a", "t1", "k-a")
+ mustNode(t, s, "b", "t2", "k-b")
+
+ ids := func(tenantID string) []string {
+ t.Helper()
+ nodes, err := s.ListNodes(ctx, tenantID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := []string{}
+ for _, n := range nodes {
+ out = append(out, n.ID)
+ }
+ return out
+ }
+ if got := ids("t1"); !reflect.DeepEqual(got, []string{"a", "c"}) {
+ t.Errorf("t1: %v", got)
+ }
+ if got := ids(""); !reflect.DeepEqual(got, []string{"a", "b", "c"}) {
+ t.Errorf("alle: %v", got)
+ }
+ if got := ids("leer"); len(got) != 0 {
+ t.Errorf("unbekannter Mandant: %v", got)
+ }
+}
+
+func TestTouchNode(t *testing.T) {
+ s, clock := openTest(t)
+ mustTenant(t, s, "t1")
+ mustNode(t, s, "n1", "t1", "k1")
+ if err := s.SetNodeStatus(ctx, "n1", NodeDrained); err != nil {
+ t.Fatal(err)
+ }
+ clock.advance(45 * time.Second)
+
+ if err := s.TouchNode(ctx, "n1", "192.168.1.5"); err != nil {
+ t.Fatalf("TouchNode: %v", err)
+ }
+ got, _ := s.GetNode(ctx, "n1")
+ if !got.LastSeen.Equal(clock.now()) || got.IPAddress != "192.168.1.5" {
+ t.Errorf("last_seen=%v ip=%q", got.LastSeen, got.IPAddress)
+ }
+ if got.Status != NodeDrained {
+ t.Errorf("TouchNode darf den Status nicht ändern, ist %q", got.Status)
+ }
+ if err := s.TouchNode(ctx, "fehlt", "1.2.3.4"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("unbekannt: erwartet ErrNotFound, bekommen %v", err)
+ }
+ if err := s.TouchNode(ctx, "n1", ""); !errors.Is(err, ErrInvalid) {
+ t.Errorf("leere IP: erwartet ErrInvalid, bekommen %v", err)
+ }
+}
+
+func TestSetNodeStatus(t *testing.T) {
+ s, _ := openTest(t)
+ mustTenant(t, s, "t1")
+ mustNode(t, s, "n1", "t1", "k1")
+ for _, st := range []NodeStatus{NodeOffline, NodeDrained, NodeOnline} {
+ if err := s.SetNodeStatus(ctx, "n1", st); err != nil {
+ t.Fatalf("%s: %v", st, err)
+ }
+ if got, _ := s.GetNode(ctx, "n1"); got.Status != st {
+ t.Errorf("Status = %q, erwartet %q", got.Status, st)
+ }
+ }
+ if err := s.SetNodeStatus(ctx, "n1", "kaputt"); !errors.Is(err, ErrInvalid) {
+ t.Errorf("ungültig: erwartet ErrInvalid, bekommen %v", err)
+ }
+ if err := s.SetNodeStatus(ctx, "fehlt", NodeOnline); !errors.Is(err, ErrNotFound) {
+ t.Errorf("unbekannt: erwartet ErrNotFound, bekommen %v", err)
+ }
+}
+
+func TestDeleteNode(t *testing.T) {
+ s, _ := openTest(t)
+ mustTenant(t, s, "t1")
+ mustNode(t, s, "n1", "t1", "k1")
+ if err := s.DeleteNode(ctx, "n1"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.GetNode(ctx, "n1"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("Knoten noch da: %v", err)
+ }
+ if err := s.DeleteNode(ctx, "n1"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("zweites Löschen: erwartet ErrNotFound, bekommen %v", err)
+ }
+ // Public Key ist nach dem Löschen wieder frei (z. B. für re_enroll).
+ mustNode(t, s, "n1-neu", "t1", "k1")
+}
diff --git a/pkg/store/store.go b/pkg/store/store.go
new file mode 100644
index 0000000..4951365
--- /dev/null
+++ b/pkg/store/store.go
@@ -0,0 +1,214 @@
+// Package store hält den persistenten Zustand des Sultan-Servers (Mandanten,
+// Knoten, Enrollment-Tokens) in SQLite, Schema gemäß Spec Abschnitt 4.3.
+//
+// Verwendet wird der reine Go-Treiber modernc.org/sqlite (kein cgo), damit
+// sultan ein statisch gelinktes Single Binary bleibt.
+package store
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "modernc.org/sqlite"
+ sqlite3 "modernc.org/sqlite/lib"
+)
+
+var (
+ // ErrNotFound: Der Datensatz (oder ein referenzierter Datensatz) existiert nicht.
+ ErrNotFound = errors.New("store: nicht gefunden")
+ // ErrExists: Ein Datensatz mit derselben ID bzw. demselben eindeutigen Wert existiert bereits.
+ ErrExists = errors.New("store: existiert bereits")
+ // ErrInvalid: Die Eingabe verletzt eine Validierungsregel.
+ ErrInvalid = errors.New("store: ungültige Eingabe")
+ // ErrTokenUsed: Der Enrollment-Token wurde bereits eingelöst.
+ ErrTokenUsed = errors.New("store: Token bereits verwendet")
+ // ErrTokenExpired: Der Enrollment-Token ist abgelaufen.
+ ErrTokenExpired = errors.New("store: Token abgelaufen")
+ // ErrSchemaTooNew: Die Datenbank stammt von einer neueren sultan-Version.
+ ErrSchemaTooNew = errors.New("store: Datenbankschema neuer als diese Programmversion")
+)
+
+// migrations[i] hebt das Schema von Version i auf i+1. Bestehende Einträge
+// dürfen nie verändert, nur neue angehängt werden.
+var migrations = []string{
+ `CREATE TABLE tenants (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ email TEXT NOT NULL,
+ tier TEXT NOT NULL DEFAULT 'community',
+ max_routes INTEGER NOT NULL DEFAULT 5,
+ allowed_domains TEXT NOT NULL,
+ valid_until DATETIME NOT NULL,
+ status TEXT NOT NULL DEFAULT 'active',
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL
+ );
+ CREATE TABLE nodes (
+ id TEXT PRIMARY KEY,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ hostname TEXT NOT NULL,
+ public_key TEXT NOT NULL UNIQUE,
+ last_seen DATETIME NOT NULL,
+ ip_address TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'online'
+ );
+ CREATE INDEX nodes_tenant_id ON nodes(tenant_id);
+ CREATE TABLE enrollment_tokens (
+ token_hash TEXT PRIMARY KEY,
+ tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ expires_at DATETIME NOT NULL,
+ used INTEGER NOT NULL DEFAULT 0
+ );
+ CREATE INDEX enrollment_tokens_tenant_id ON enrollment_tokens(tenant_id);`,
+}
+
+// Store ist der Zugriff auf die SQLite-Datenbank. Er ist für parallele
+// Nutzung aus mehreren Goroutinen geeignet.
+type Store struct {
+ db *sql.DB
+ now func() time.Time
+}
+
+// Open öffnet (bzw. erstellt) die Datenbank unter path und bringt das Schema
+// auf den aktuellen Stand.
+func Open(ctx context.Context, path string) (*Store, error) {
+ // foreign_keys: sonst greift ON DELETE CASCADE nicht (gilt pro Verbindung).
+ // _txlock=immediate: Schreib-Transaktionen holen die Sperre sofort und
+ // warten per busy_timeout, statt beim Upgrade mit SQLITE_BUSY abzubrechen.
+ dsn := "file:" + path +
+ "?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_txlock=immediate"
+ db, err := sql.Open("sqlite", dsn)
+ if err != nil {
+ return nil, err
+ }
+ s := &Store{db: db, now: time.Now}
+ if err := s.migrate(ctx); err != nil {
+ db.Close()
+ return nil, err
+ }
+ return s, nil
+}
+
+// Close schließt die Datenbank.
+func (s *Store) Close() error { return s.db.Close() }
+
+// SchemaVersion liefert die aktuelle Schema-Version.
+func (s *Store) SchemaVersion(ctx context.Context) (int, error) {
+ var v int
+ err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&v)
+ return v, err
+}
+
+func (s *Store) migrate(ctx context.Context) error {
+ v, err := s.SchemaVersion(ctx)
+ if err != nil {
+ return fmt.Errorf("store: Schema-Version lesen: %w", err)
+ }
+ if v > len(migrations) {
+ return fmt.Errorf("%w (Datenbank v%d, bekannt bis v%d)", ErrSchemaTooNew, v, len(migrations))
+ }
+ for i := v; i < len(migrations); i++ {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ if _, err := tx.ExecContext(ctx, migrations[i]); err != nil {
+ tx.Rollback()
+ return fmt.Errorf("store: Migration auf v%d: %w", i+1, err)
+ }
+ if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil {
+ tx.Rollback()
+ return err
+ }
+ if err := tx.Commit(); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// timeLayout hat feste Breite, damit Zeitstempel als Text korrekt sortieren
+// und sich in SQL vergleichen lassen.
+const timeLayout = "2006-01-02T15:04:05.000000000Z"
+
+func formatTime(t time.Time) string { return t.UTC().Format(timeLayout) }
+
+// normTime entfernt Monotonic-Clock und Zeitzone, damit gespeicherte und
+// gelesene Werte identisch sind.
+func normTime(t time.Time) time.Time { return t.Round(0).UTC() }
+
+// dbTime liest einen Zeitstempel unabhängig davon, ob der Treiber ihn als
+// Text oder bereits als time.Time liefert.
+type dbTime struct{ t *time.Time }
+
+func (d dbTime) Scan(src any) error {
+ switch v := src.(type) {
+ case time.Time:
+ *d.t = normTime(v)
+ return nil
+ case string:
+ return d.parse(v)
+ case []byte:
+ return d.parse(string(v))
+ default:
+ return fmt.Errorf("store: unerwarteter Zeittyp %T", src)
+ }
+}
+
+func (d dbTime) parse(s string) error {
+ t, err := time.Parse(timeLayout, s)
+ if err != nil {
+ return fmt.Errorf("store: Zeitstempel %q: %w", s, err)
+ }
+ *d.t = t
+ return nil
+}
+
+// execer ist die gemeinsame Teilmenge von *sql.DB und *sql.Tx.
+type execer interface {
+ ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
+}
+
+func sqliteCode(err error) int {
+ var se *sqlite.Error
+ if errors.As(err, &se) {
+ return se.Code()
+ }
+ return 0
+}
+
+// mapConstraint übersetzt SQLite-Constraint-Fehler in die Fehler des Packages.
+func mapConstraint(err error, what string) error {
+ switch sqliteCode(err) {
+ case sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY, sqlite3.SQLITE_CONSTRAINT_UNIQUE:
+ return fmt.Errorf("%s: %w", what, ErrExists)
+ case sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY:
+ return fmt.Errorf("%s: Mandant: %w", what, ErrNotFound)
+ }
+ return err
+}
+
+func blank(s string) bool { return strings.TrimSpace(s) == "" }
+
+func invalid(format string, args ...any) error {
+ return fmt.Errorf("%w: %s", ErrInvalid, fmt.Sprintf(format, args...))
+}
+
+// requireAffected liefert ErrNotFound, wenn res keine Zeile betroffen hat.
+func requireAffected(res sql.Result, err error, what string) error {
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("%s: %w", what, ErrNotFound)
+ }
+ return nil
+}
diff --git a/pkg/store/store_test.go b/pkg/store/store_test.go
new file mode 100644
index 0000000..4d36a68
--- /dev/null
+++ b/pkg/store/store_test.go
@@ -0,0 +1,143 @@
+package store
+
+import (
+ "context"
+ "errors"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+var ctx = context.Background()
+
+// fakeClock ist eine steuerbare Uhr für deterministische Zeitstempel.
+type fakeClock struct{ t time.Time }
+
+func (c *fakeClock) now() time.Time { return c.t }
+func (c *fakeClock) advance(d time.Duration) { c.t = c.t.Add(d) }
+
+// openTest öffnet einen frischen Store in einem temporären Verzeichnis mit
+// steuerbarer Uhr.
+func openTest(t *testing.T) (*Store, *fakeClock) {
+ t.Helper()
+ s, err := Open(ctx, filepath.Join(t.TempDir(), "sultan.db"))
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ t.Cleanup(func() { s.Close() })
+ clock := &fakeClock{t: time.Date(2026, 10, 1, 12, 0, 0, 123456789, time.UTC)}
+ s.now = clock.now
+ return s, clock
+}
+
+func TestOpen_MigratesSchema(t *testing.T) {
+ s, _ := openTest(t)
+ v, err := s.SchemaVersion(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v != len(migrations) || v < 1 {
+ t.Errorf("Schema-Version = %d, erwartet %d", v, len(migrations))
+ }
+}
+
+func TestOpen_ReopenKeepsData(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "sultan.db")
+ s, err := Open(ctx, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.CreateTenant(ctx, sampleTenant("t1")); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ s, err = Open(ctx, path)
+ if err != nil {
+ t.Fatalf("erneutes Open: %v", err)
+ }
+ defer s.Close()
+ if _, err := s.GetTenant(ctx, "t1"); err != nil {
+ t.Errorf("Daten nach erneutem Öffnen weg: %v", err)
+ }
+ if v, _ := s.SchemaVersion(ctx); v != len(migrations) {
+ t.Errorf("Schema-Version nach erneutem Öffnen = %d", v)
+ }
+}
+
+func TestOpen_InvalidPath(t *testing.T) {
+ _, err := Open(ctx, filepath.Join(t.TempDir(), "gibt", "es", "nicht", "sultan.db"))
+ if err == nil {
+ t.Error("erwartet Fehler für nicht existierendes Verzeichnis")
+ }
+}
+
+func TestOpen_RejectsNewerSchema(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "sultan.db")
+ s, err := Open(ctx, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.db.ExecContext(ctx, "PRAGMA user_version = 999"); err != nil {
+ t.Fatal(err)
+ }
+ s.Close()
+
+ if _, err := Open(ctx, path); !errors.Is(err, ErrSchemaTooNew) {
+ t.Errorf("erwartet ErrSchemaTooNew, bekommen %v", err)
+ }
+}
+
+func TestOpen_ForeignKeysEnabled(t *testing.T) {
+ s, _ := openTest(t)
+ var on int
+ if err := s.db.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&on); err != nil {
+ t.Fatal(err)
+ }
+ if on != 1 {
+ t.Error("foreign_keys ist nicht aktiv – ON DELETE CASCADE würde nicht greifen")
+ }
+}
+
+func TestDBTime_Scan(t *testing.T) {
+ want := time.Date(2026, 12, 15, 10, 30, 0, 987654321, time.UTC)
+ cet := want.In(time.FixedZone("CET", 3600))
+ for name, src := range map[string]any{
+ "Text": "2026-12-15T10:30:00.987654321Z",
+ "Bytes": []byte("2026-12-15T10:30:00.987654321Z"),
+ "time.Time (CET)": cet,
+ } {
+ t.Run(name, func(t *testing.T) {
+ var got time.Time
+ if err := (dbTime{&got}).Scan(src); err != nil {
+ t.Fatal(err)
+ }
+ if !got.Equal(want) || got.Location() != time.UTC {
+ t.Errorf("got %v, erwartet %v in UTC", got, want)
+ }
+ })
+ }
+ var got time.Time
+ if err := (dbTime{&got}).Scan("15.12.2026"); err == nil {
+ t.Error("ungültiger Text: erwartet Fehler")
+ }
+ if err := (dbTime{&got}).Scan(int64(42)); err == nil {
+ t.Error("unerwarteter Typ: erwartet Fehler")
+ }
+}
+
+// Zeitstempel müssen als Text fester Breite gespeichert werden, sonst
+// funktionieren Vergleiche in SQL (z. B. PurgeEnrollmentTokens) nicht.
+func TestTimesStoredAsSortableText(t *testing.T) {
+ s, _ := openTest(t)
+ mustTenant(t, s, "t1")
+ var typ, raw string
+ if err := s.db.QueryRowContext(ctx, "SELECT typeof(created_at), CAST(created_at AS TEXT) FROM tenants").Scan(&typ, &raw); err != nil {
+ t.Fatal(err)
+ }
+ if typ != "text" || raw != "2026-10-01T12:00:00.123456789Z" {
+ t.Errorf("gespeichert als %s %q", typ, raw)
+ }
+}
diff --git a/pkg/store/tenants.go b/pkg/store/tenants.go
new file mode 100644
index 0000000..5160ac8
--- /dev/null
+++ b/pkg/store/tenants.go
@@ -0,0 +1,200 @@
+package store
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+)
+
+// Tier ist die Lizenzstufe eines Mandanten.
+type Tier string
+
+const (
+ TierCommunity Tier = "community"
+ TierPro Tier = "pro"
+ TierEnterprise Tier = "enterprise"
+)
+
+// TenantStatus ist der Abo-Status eines Mandanten.
+type TenantStatus string
+
+const (
+ TenantActive TenantStatus = "active"
+ TenantSuspendedPayment TenantStatus = "suspended_payment"
+ TenantQuarantined TenantStatus = "quarantined"
+ TenantExpired TenantStatus = "expired"
+)
+
+func (t Tier) valid() bool {
+ return t == TierCommunity || t == TierPro || t == TierEnterprise
+}
+
+func (s TenantStatus) valid() bool {
+ switch s {
+ case TenantActive, TenantSuspendedPayment, TenantQuarantined, TenantExpired:
+ return true
+ }
+ return false
+}
+
+// Tenant ist ein Mandant mit seinem Abonnement.
+type Tenant struct {
+ ID string
+ Name string
+ Email string
+ Tier Tier
+ MaxRoutes int // -1 = unbegrenzt
+ AllowedDomains []string
+ ValidUntil time.Time
+ Status TenantStatus
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// normalize setzt Defaults (Tier community, Status active, leere
+// Domainliste) und prüft die Eingabe.
+func (t *Tenant) normalize() error {
+ if t.Tier == "" {
+ t.Tier = TierCommunity
+ }
+ if t.Status == "" {
+ t.Status = TenantActive
+ }
+ if t.AllowedDomains == nil {
+ t.AllowedDomains = []string{}
+ }
+ t.ValidUntil = normTime(t.ValidUntil)
+
+ switch {
+ case blank(t.ID):
+ return invalid("Mandanten-ID fehlt")
+ case blank(t.Name):
+ return invalid("Name fehlt")
+ case blank(t.Email):
+ return invalid("E-Mail fehlt")
+ case !t.Tier.valid():
+ return invalid("unbekannter Tier %q", t.Tier)
+ case !t.Status.valid():
+ return invalid("unbekannter Status %q", t.Status)
+ case t.MaxRoutes < -1:
+ return invalid("max_routes %d (erlaubt: -1 oder ≥ 0)", t.MaxRoutes)
+ case t.ValidUntil.IsZero():
+ return invalid("valid_until fehlt")
+ }
+ for _, d := range t.AllowedDomains {
+ if blank(d) {
+ return invalid("leerer Eintrag in allowed_domains")
+ }
+ }
+ return nil
+}
+
+// CreateTenant legt einen Mandanten an und liefert ihn mit Defaults und
+// Zeitstempeln zurück.
+func (s *Store) CreateTenant(ctx context.Context, t Tenant) (Tenant, error) {
+ if err := t.normalize(); err != nil {
+ return Tenant{}, err
+ }
+ domains, err := json.Marshal(t.AllowedDomains)
+ if err != nil {
+ return Tenant{}, err
+ }
+ now := normTime(s.now())
+ t.CreatedAt, t.UpdatedAt = now, now
+
+ _, err = s.db.ExecContext(ctx, `INSERT INTO tenants
+ (id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ t.ID, t.Name, t.Email, t.Tier, t.MaxRoutes, string(domains),
+ formatTime(t.ValidUntil), t.Status, formatTime(now), formatTime(now))
+ if err != nil {
+ return Tenant{}, mapConstraint(err, "Mandant "+t.ID)
+ }
+ return t, nil
+}
+
+const tenantColumns = `id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at`
+
+func scanTenant(row interface{ Scan(...any) error }) (Tenant, error) {
+ var t Tenant
+ var domains string
+ err := row.Scan(&t.ID, &t.Name, &t.Email, &t.Tier, &t.MaxRoutes, &domains,
+ dbTime{&t.ValidUntil}, &t.Status, dbTime{&t.CreatedAt}, dbTime{&t.UpdatedAt})
+ if err != nil {
+ return Tenant{}, err
+ }
+ if err := json.Unmarshal([]byte(domains), &t.AllowedDomains); err != nil {
+ return Tenant{}, fmt.Errorf("store: allowed_domains von %s: %w", t.ID, err)
+ }
+ if t.AllowedDomains == nil {
+ t.AllowedDomains = []string{}
+ }
+ return t, nil
+}
+
+// GetTenant liest einen Mandanten.
+func (s *Store) GetTenant(ctx context.Context, id string) (Tenant, error) {
+ t, err := scanTenant(s.db.QueryRowContext(ctx, `SELECT `+tenantColumns+` FROM tenants WHERE id = ?`, id))
+ if errors.Is(err, sql.ErrNoRows) {
+ return Tenant{}, fmt.Errorf("Mandant %s: %w", id, ErrNotFound)
+ }
+ return t, err
+}
+
+// ListTenants liefert alle Mandanten, sortiert nach ID.
+func (s *Store) ListTenants(ctx context.Context) ([]Tenant, error) {
+ rows, err := s.db.QueryContext(ctx, `SELECT `+tenantColumns+` FROM tenants ORDER BY id`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ tenants := []Tenant{}
+ for rows.Next() {
+ t, err := scanTenant(rows)
+ if err != nil {
+ return nil, err
+ }
+ tenants = append(tenants, t)
+ }
+ return tenants, rows.Err()
+}
+
+// UpdateTenant überschreibt alle veränderlichen Felder des Mandanten t.ID.
+// CreatedAt bleibt erhalten, UpdatedAt wird gesetzt.
+func (s *Store) UpdateTenant(ctx context.Context, t Tenant) (Tenant, error) {
+ if err := t.normalize(); err != nil {
+ return Tenant{}, err
+ }
+ domains, err := json.Marshal(t.AllowedDomains)
+ if err != nil {
+ return Tenant{}, err
+ }
+ res, err := s.db.ExecContext(ctx, `UPDATE tenants SET
+ name = ?, email = ?, tier = ?, max_routes = ?, allowed_domains = ?, valid_until = ?, status = ?, updated_at = ?
+ WHERE id = ?`,
+ t.Name, t.Email, t.Tier, t.MaxRoutes, string(domains), formatTime(t.ValidUntil), t.Status,
+ formatTime(s.now()), t.ID)
+ if err := requireAffected(res, err, "Mandant "+t.ID); err != nil {
+ return Tenant{}, err
+ }
+ return s.GetTenant(ctx, t.ID)
+}
+
+// SetTenantStatus ändert nur den Status, z. B. bei Zahlungsverzug.
+func (s *Store) SetTenantStatus(ctx context.Context, id string, status TenantStatus) error {
+ if !status.valid() {
+ return invalid("unbekannter Status %q", status)
+ }
+ res, err := s.db.ExecContext(ctx, `UPDATE tenants SET status = ?, updated_at = ? WHERE id = ?`,
+ status, formatTime(s.now()), id)
+ return requireAffected(res, err, "Mandant "+id)
+}
+
+// DeleteTenant löscht den Mandanten samt seiner Knoten und Tokens.
+func (s *Store) DeleteTenant(ctx context.Context, id string) error {
+ res, err := s.db.ExecContext(ctx, `DELETE FROM tenants WHERE id = ?`, id)
+ return requireAffected(res, err, "Mandant "+id)
+}
diff --git a/pkg/store/tenants_test.go b/pkg/store/tenants_test.go
new file mode 100644
index 0000000..8c1b65f
--- /dev/null
+++ b/pkg/store/tenants_test.go
@@ -0,0 +1,253 @@
+package store
+
+import (
+ "errors"
+ "reflect"
+ "testing"
+ "time"
+)
+
+func sampleTenant(id string) Tenant {
+ return Tenant{
+ ID: id,
+ Name: "Kunde " + id,
+ Email: id + "@example.com",
+ Tier: TierPro,
+ MaxRoutes: 10,
+ AllowedDomains: []string{"*.firma.de", "shop.de"},
+ ValidUntil: time.Date(2026, 12, 15, 0, 0, 0, 0, time.UTC),
+ Status: TenantActive,
+ }
+}
+
+func TestCreateTenant_RoundTrip(t *testing.T) {
+ s, clock := openTest(t)
+ in := sampleTenant("kunde_schmidt")
+ in.ValidUntil = time.Date(2026, 12, 15, 10, 30, 0, 987654321, time.FixedZone("CET", 3600))
+
+ created, err := s.CreateTenant(ctx, in)
+ if err != nil {
+ t.Fatalf("CreateTenant: %v", err)
+ }
+ if !created.CreatedAt.Equal(clock.now()) || !created.UpdatedAt.Equal(clock.now()) {
+ t.Errorf("Zeitstempel: created=%v updated=%v, erwartet %v", created.CreatedAt, created.UpdatedAt, clock.now())
+ }
+ if !created.ValidUntil.Equal(in.ValidUntil) || created.ValidUntil.Location() != time.UTC {
+ t.Errorf("ValidUntil = %v, erwartet %v in UTC", created.ValidUntil, in.ValidUntil)
+ }
+
+ got, err := s.GetTenant(ctx, "kunde_schmidt")
+ if err != nil {
+ t.Fatalf("GetTenant: %v", err)
+ }
+ if !reflect.DeepEqual(got, created) {
+ t.Errorf("gelesen weicht ab\n got: %+v\nwant: %+v", got, created)
+ }
+}
+
+func TestCreateTenant_Defaults(t *testing.T) {
+ s, _ := openTest(t)
+ in := sampleTenant("t1")
+ in.Tier, in.Status, in.AllowedDomains = "", "", nil
+
+ created, err := s.CreateTenant(ctx, in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if created.Tier != TierCommunity || created.Status != TenantActive {
+ t.Errorf("Defaults: tier=%q status=%q", created.Tier, created.Status)
+ }
+ if created.AllowedDomains == nil || len(created.AllowedDomains) != 0 {
+ t.Errorf("AllowedDomains = %#v, erwartet leere, nicht-nil Liste", created.AllowedDomains)
+ }
+ got, _ := s.GetTenant(ctx, "t1")
+ if !reflect.DeepEqual(got, created) {
+ t.Errorf("gelesen weicht ab\n got: %+v\nwant: %+v", got, created)
+ }
+}
+
+func TestCreateTenant_Duplicate(t *testing.T) {
+ s, _ := openTest(t)
+ if _, err := s.CreateTenant(ctx, sampleTenant("t1")); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.CreateTenant(ctx, sampleTenant("t1")); !errors.Is(err, ErrExists) {
+ t.Errorf("erwartet ErrExists, bekommen %v", err)
+ }
+}
+
+func TestCreateTenant_Validation(t *testing.T) {
+ tests := map[string]func(*Tenant){
+ "ID leer": func(t *Tenant) { t.ID = "" },
+ "Name leer": func(t *Tenant) { t.Name = " " },
+ "E-Mail leer": func(t *Tenant) { t.Email = "" },
+ "unbekannter Tier": func(t *Tenant) { t.Tier = "gold" },
+ "unbekannter Status": func(t *Tenant) { t.Status = "pausiert" },
+ "MaxRoutes < -1": func(t *Tenant) { t.MaxRoutes = -2 },
+ "leere Domain": func(t *Tenant) { t.AllowedDomains = []string{"a.de", ""} },
+ "ValidUntil fehlt": func(t *Tenant) { t.ValidUntil = time.Time{} },
+ }
+ for name, mutate := range tests {
+ t.Run(name, func(t *testing.T) {
+ s, _ := openTest(t)
+ in := sampleTenant("t1")
+ mutate(&in)
+ if _, err := s.CreateTenant(ctx, in); !errors.Is(err, ErrInvalid) {
+ t.Errorf("erwartet ErrInvalid, bekommen %v", err)
+ }
+ })
+ }
+}
+
+func TestCreateTenant_AllStatusesAndTiers(t *testing.T) {
+ s, _ := openTest(t)
+ i := 0
+ for _, tier := range []Tier{TierCommunity, TierPro, TierEnterprise} {
+ for _, st := range []TenantStatus{TenantActive, TenantSuspendedPayment, TenantQuarantined, TenantExpired} {
+ in := sampleTenant(string(rune('a' + i)))
+ in.Tier, in.Status = tier, st
+ if _, err := s.CreateTenant(ctx, in); err != nil {
+ t.Errorf("tier=%s status=%s: %v", tier, st, err)
+ }
+ i++
+ }
+ }
+}
+
+func TestGetTenant_NotFound(t *testing.T) {
+ s, _ := openTest(t)
+ if _, err := s.GetTenant(ctx, "fehlt"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("erwartet ErrNotFound, bekommen %v", err)
+ }
+}
+
+func TestListTenants(t *testing.T) {
+ s, _ := openTest(t)
+ got, err := s.ListTenants(ctx)
+ if err != nil || len(got) != 0 {
+ t.Fatalf("leere DB: %v, %v", got, err)
+ }
+ for _, id := range []string{"c", "a", "b"} {
+ if _, err := s.CreateTenant(ctx, sampleTenant(id)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err = s.ListTenants(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var ids []string
+ for _, tn := range got {
+ ids = append(ids, tn.ID)
+ }
+ if !reflect.DeepEqual(ids, []string{"a", "b", "c"}) {
+ t.Errorf("IDs = %v, erwartet sortiert", ids)
+ }
+}
+
+func TestUpdateTenant(t *testing.T) {
+ s, clock := openTest(t)
+ created, err := s.CreateTenant(ctx, sampleTenant("t1"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ clock.advance(time.Hour)
+
+ upd := created
+ upd.Name = "Neuer Name"
+ upd.Tier = TierEnterprise
+ upd.MaxRoutes = -1
+ upd.AllowedDomains = []string{"neu.de"}
+ upd.ValidUntil = created.ValidUntil.AddDate(1, 0, 0)
+ upd.CreatedAt = time.Time{} // darf nicht überschrieben werden
+
+ got, err := s.UpdateTenant(ctx, upd)
+ if err != nil {
+ t.Fatalf("UpdateTenant: %v", err)
+ }
+ if !got.CreatedAt.Equal(created.CreatedAt) {
+ t.Errorf("CreatedAt verändert: %v", got.CreatedAt)
+ }
+ if !got.UpdatedAt.Equal(clock.now()) {
+ t.Errorf("UpdatedAt = %v, erwartet %v", got.UpdatedAt, clock.now())
+ }
+ read, _ := s.GetTenant(ctx, "t1")
+ if !reflect.DeepEqual(read, got) {
+ t.Errorf("gelesen weicht ab\n got: %+v\nwant: %+v", read, got)
+ }
+ if read.Name != "Neuer Name" || read.MaxRoutes != -1 || read.Tier != TierEnterprise {
+ t.Errorf("Änderungen nicht gespeichert: %+v", read)
+ }
+}
+
+func TestUpdateTenant_Errors(t *testing.T) {
+ s, _ := openTest(t)
+ if _, err := s.UpdateTenant(ctx, sampleTenant("fehlt")); !errors.Is(err, ErrNotFound) {
+ t.Errorf("unbekannt: erwartet ErrNotFound, bekommen %v", err)
+ }
+ created, _ := s.CreateTenant(ctx, sampleTenant("t1"))
+ created.Email = ""
+ if _, err := s.UpdateTenant(ctx, created); !errors.Is(err, ErrInvalid) {
+ t.Errorf("ungültig: erwartet ErrInvalid, bekommen %v", err)
+ }
+}
+
+func TestSetTenantStatus(t *testing.T) {
+ s, clock := openTest(t)
+ if _, err := s.CreateTenant(ctx, sampleTenant("t1")); err != nil {
+ t.Fatal(err)
+ }
+ clock.advance(time.Minute)
+ if err := s.SetTenantStatus(ctx, "t1", TenantSuspendedPayment); err != nil {
+ t.Fatal(err)
+ }
+ got, _ := s.GetTenant(ctx, "t1")
+ if got.Status != TenantSuspendedPayment || !got.UpdatedAt.Equal(clock.now()) {
+ t.Errorf("status=%q updated=%v", got.Status, got.UpdatedAt)
+ }
+ if err := s.SetTenantStatus(ctx, "t1", "kaputt"); !errors.Is(err, ErrInvalid) {
+ t.Errorf("ungültig: erwartet ErrInvalid, bekommen %v", err)
+ }
+ if err := s.SetTenantStatus(ctx, "fehlt", TenantActive); !errors.Is(err, ErrNotFound) {
+ t.Errorf("unbekannt: erwartet ErrNotFound, bekommen %v", err)
+ }
+}
+
+func TestDeleteTenant_Cascades(t *testing.T) {
+ s, clock := openTest(t)
+ mustTenant(t, s, "t1")
+ mustTenant(t, s, "t2")
+ mustNode(t, s, "n1", "t1", "key1")
+ mustNode(t, s, "n2", "t2", "key2")
+ if err := s.AddEnrollmentToken(ctx, "tok1", "t1", clock.now().Add(time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.DeleteTenant(ctx, "t1"); err != nil {
+ t.Fatalf("DeleteTenant: %v", err)
+ }
+ if _, err := s.GetTenant(ctx, "t1"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("Mandant noch da: %v", err)
+ }
+ if _, err := s.GetNode(ctx, "n1"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("Knoten des Mandanten nicht mitgelöscht: %v", err)
+ }
+ if _, err := s.Enroll(ctx, "tok1", sampleNode("n3", "key3")); !errors.Is(err, ErrNotFound) {
+ t.Errorf("Token des Mandanten nicht mitgelöscht: %v", err)
+ }
+ if _, err := s.GetNode(ctx, "n2"); err != nil {
+ t.Errorf("fremder Knoten gelöscht: %v", err)
+ }
+ if err := s.DeleteTenant(ctx, "t1"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("zweites Löschen: erwartet ErrNotFound, bekommen %v", err)
+ }
+}
+
+func mustTenant(t *testing.T, s *Store, id string) Tenant {
+ t.Helper()
+ tn, err := s.CreateTenant(ctx, sampleTenant(id))
+ if err != nil {
+ t.Fatalf("CreateTenant(%s): %v", id, err)
+ }
+ return tn
+}
diff --git a/pkg/store/tokens.go b/pkg/store/tokens.go
new file mode 100644
index 0000000..cdbc862
--- /dev/null
+++ b/pkg/store/tokens.go
@@ -0,0 +1,88 @@
+package store
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "time"
+)
+
+// hashToken: Tokens werden nur als SHA-256 gespeichert. Ein einfacher Hash
+// reicht, weil Tokens zufällig mit hoher Entropie erzeugt werden.
+func hashToken(token string) string {
+ sum := sha256.Sum256([]byte(token))
+ return hex.EncodeToString(sum[:])
+}
+
+// AddEnrollmentToken hinterlegt einen einmaligen Einladungs-Token für
+// tenantID. Erzeugt wird der Token vom Aufrufer; gespeichert wird nur sein
+// Hash.
+func (s *Store) AddEnrollmentToken(ctx context.Context, token, tenantID string, expiresAt time.Time) error {
+ if token == "" {
+ return invalid("Token fehlt")
+ }
+ if !expiresAt.After(s.now()) {
+ return invalid("Ablaufzeitpunkt %v liegt nicht in der Zukunft", expiresAt)
+ }
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO enrollment_tokens (token_hash, tenant_id, expires_at, used) VALUES (?, ?, ?, 0)`,
+ hashToken(token), tenantID, formatTime(expiresAt))
+ return mapConstraint(err, "Enrollment-Token")
+}
+
+// Enroll löst token ein und legt in derselben Transaktion den Knoten n an.
+// n.TenantID wird durch den Mandanten des Tokens ersetzt. Schlägt das Anlegen
+// fehl, bleibt der Token unverbraucht. Fehler: ErrNotFound (unbekannter
+// Token), ErrTokenUsed, ErrTokenExpired sowie die Fehler von CreateNode.
+func (s *Store) Enroll(ctx context.Context, token string, n Node) (Node, error) {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return Node{}, err
+ }
+ defer tx.Rollback()
+
+ hash := hashToken(token)
+ var tenantID string
+ var expiresAt time.Time
+ var used bool
+ err = tx.QueryRowContext(ctx,
+ `SELECT tenant_id, expires_at, used FROM enrollment_tokens WHERE token_hash = ?`, hash).
+ Scan(&tenantID, dbTime{&expiresAt}, &used)
+ switch {
+ case errors.Is(err, sql.ErrNoRows):
+ return Node{}, fmt.Errorf("Enrollment-Token: %w", ErrNotFound)
+ case err != nil:
+ return Node{}, err
+ case used:
+ return Node{}, ErrTokenUsed
+ case !s.now().Before(expiresAt):
+ return Node{}, ErrTokenExpired
+ }
+
+ if _, err := tx.ExecContext(ctx, `UPDATE enrollment_tokens SET used = 1 WHERE token_hash = ?`, hash); err != nil {
+ return Node{}, err
+ }
+ n.TenantID = tenantID
+ node, err := s.insertNode(ctx, tx, n)
+ if err != nil {
+ return Node{}, err
+ }
+ if err := tx.Commit(); err != nil {
+ return Node{}, err
+ }
+ return node, nil
+}
+
+// PurgeEnrollmentTokens löscht verbrauchte und abgelaufene Tokens und
+// liefert ihre Anzahl.
+func (s *Store) PurgeEnrollmentTokens(ctx context.Context) (int64, error) {
+ res, err := s.db.ExecContext(ctx,
+ `DELETE FROM enrollment_tokens WHERE used = 1 OR expires_at <= ?`, formatTime(s.now()))
+ if err != nil {
+ return 0, err
+ }
+ return res.RowsAffected()
+}
diff --git a/pkg/store/tokens_test.go b/pkg/store/tokens_test.go
new file mode 100644
index 0000000..7d67280
--- /dev/null
+++ b/pkg/store/tokens_test.go
@@ -0,0 +1,188 @@
+package store
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestEnroll(t *testing.T) {
+ s, clock := openTest(t)
+ mustTenant(t, s, "t1")
+ if err := s.AddEnrollmentToken(ctx, "sltn_geheim", "t1", clock.now().Add(time.Hour)); err != nil {
+ t.Fatalf("AddEnrollmentToken: %v", err)
+ }
+
+ n := sampleNode("n1", "key1")
+ n.TenantID = "manipuliert" // muss durch den Mandanten des Tokens ersetzt werden
+ node, err := s.Enroll(ctx, "sltn_geheim", n)
+ if err != nil {
+ t.Fatalf("Enroll: %v", err)
+ }
+ if node.TenantID != "t1" {
+ t.Errorf("TenantID = %q, erwartet t1 (aus dem Token)", node.TenantID)
+ }
+ if got, err := s.GetNodeByPublicKey(ctx, "key1"); err != nil || got.ID != "n1" {
+ t.Errorf("Knoten nicht angelegt: %+v, %v", got, err)
+ }
+}
+
+func TestAddEnrollmentToken_StoresOnlyHash(t *testing.T) {
+ s, clock := openTest(t)
+ mustTenant(t, s, "t1")
+ if err := s.AddEnrollmentToken(ctx, "sltn_geheim", "t1", clock.now().Add(time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ var stored string
+ if err := s.db.QueryRowContext(ctx, "SELECT token_hash FROM enrollment_tokens").Scan(&stored); err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256([]byte("sltn_geheim"))
+ if stored != hex.EncodeToString(sum[:]) {
+ t.Errorf("gespeichert %q, erwartet SHA-256-Hex des Tokens", stored)
+ }
+}
+
+func TestAddEnrollmentToken_Errors(t *testing.T) {
+ s, clock := openTest(t)
+ mustTenant(t, s, "t1")
+ later := clock.now().Add(time.Hour)
+
+ if err := s.AddEnrollmentToken(ctx, "tok", "fehlt", later); !errors.Is(err, ErrNotFound) {
+ t.Errorf("unbekannter Mandant: erwartet ErrNotFound, bekommen %v", err)
+ }
+ if err := s.AddEnrollmentToken(ctx, "", "t1", later); !errors.Is(err, ErrInvalid) {
+ t.Errorf("leerer Token: erwartet ErrInvalid, bekommen %v", err)
+ }
+ if err := s.AddEnrollmentToken(ctx, "tok", "t1", clock.now()); !errors.Is(err, ErrInvalid) {
+ t.Errorf("Ablauf nicht in der Zukunft: erwartet ErrInvalid, bekommen %v", err)
+ }
+ if err := s.AddEnrollmentToken(ctx, "tok", "t1", later); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.AddEnrollmentToken(ctx, "tok", "t1", later); !errors.Is(err, ErrExists) {
+ t.Errorf("doppelter Token: erwartet ErrExists, bekommen %v", err)
+ }
+}
+
+func TestEnroll_TokenOnlyOnce(t *testing.T) {
+ s, clock := openTest(t)
+ mustTenant(t, s, "t1")
+ s.AddEnrollmentToken(ctx, "tok", "t1", clock.now().Add(time.Hour))
+
+ if _, err := s.Enroll(ctx, "tok", sampleNode("n1", "k1")); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Enroll(ctx, "tok", sampleNode("n2", "k2")); !errors.Is(err, ErrTokenUsed) {
+ t.Errorf("erwartet ErrTokenUsed, bekommen %v", err)
+ }
+ if _, err := s.GetNode(ctx, "n2"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("zweiter Knoten wurde trotzdem angelegt")
+ }
+}
+
+func TestEnroll_Expired(t *testing.T) {
+ s, clock := openTest(t)
+ mustTenant(t, s, "t1")
+ s.AddEnrollmentToken(ctx, "tok", "t1", clock.now().Add(time.Hour))
+ clock.advance(time.Hour) // Ablaufzeitpunkt selbst gilt schon als abgelaufen
+
+ if _, err := s.Enroll(ctx, "tok", sampleNode("n1", "k1")); !errors.Is(err, ErrTokenExpired) {
+ t.Errorf("erwartet ErrTokenExpired, bekommen %v", err)
+ }
+ if _, err := s.GetNode(ctx, "n1"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("Knoten trotz abgelaufenem Token angelegt")
+ }
+}
+
+func TestEnroll_UnknownToken(t *testing.T) {
+ s, _ := openTest(t)
+ if _, err := s.Enroll(ctx, "gibts-nicht", sampleNode("n1", "k1")); !errors.Is(err, ErrNotFound) {
+ t.Errorf("erwartet ErrNotFound, bekommen %v", err)
+ }
+}
+
+// Schlägt das Anlegen des Knotens fehl, darf der Token nicht verbraucht sein.
+func TestEnroll_RollbackKeepsToken(t *testing.T) {
+ s, clock := openTest(t)
+ mustTenant(t, s, "t1")
+ mustNode(t, s, "vorhanden", "t1", "k-belegt")
+ s.AddEnrollmentToken(ctx, "tok", "t1", clock.now().Add(time.Hour))
+
+ if _, err := s.Enroll(ctx, "tok", sampleNode("n1", "k-belegt")); !errors.Is(err, ErrExists) {
+ t.Fatalf("doppelter Key: erwartet ErrExists, bekommen %v", err)
+ }
+ invalid := sampleNode("n1", "k-neu")
+ invalid.Hostname = ""
+ if _, err := s.Enroll(ctx, "tok", invalid); !errors.Is(err, ErrInvalid) {
+ t.Fatalf("ungültiger Knoten: erwartet ErrInvalid, bekommen %v", err)
+ }
+ if _, err := s.Enroll(ctx, "tok", sampleNode("n1", "k-neu")); err != nil {
+ t.Errorf("Token nach fehlgeschlagenem Enroll nicht mehr nutzbar: %v", err)
+ }
+}
+
+func TestEnroll_ConcurrentSingleUse(t *testing.T) {
+ s, clock := openTest(t)
+ mustTenant(t, s, "t1")
+ s.AddEnrollmentToken(ctx, "tok", "t1", clock.now().Add(time.Hour))
+
+ const workers = 10
+ errs := make([]error, workers)
+ var wg sync.WaitGroup
+ for i := range workers {
+ wg.Go(func() {
+ _, errs[i] = s.Enroll(ctx, "tok", sampleNode(fmt.Sprintf("n%d", i), fmt.Sprintf("k%d", i)))
+ })
+ }
+ wg.Wait()
+
+ ok, used := 0, 0
+ for _, err := range errs {
+ switch {
+ case err == nil:
+ ok++
+ case errors.Is(err, ErrTokenUsed):
+ used++
+ default:
+ t.Errorf("unerwarteter Fehler: %v", err)
+ }
+ }
+ if ok != 1 || used != workers-1 {
+ t.Errorf("%d erfolgreich, %d ErrTokenUsed – erwartet genau 1 Erfolg", ok, used)
+ }
+ nodes, _ := s.ListNodes(ctx, "t1")
+ if len(nodes) != 1 {
+ t.Errorf("%d Knoten angelegt, erwartet 1", len(nodes))
+ }
+}
+
+func TestPurgeEnrollmentTokens(t *testing.T) {
+ s, clock := openTest(t)
+ mustTenant(t, s, "t1")
+ s.AddEnrollmentToken(ctx, "benutzt", "t1", clock.now().Add(time.Hour))
+ s.AddEnrollmentToken(ctx, "läuft-ab", "t1", clock.now().Add(time.Minute))
+ s.AddEnrollmentToken(ctx, "gültig", "t1", clock.now().Add(2*time.Hour))
+ if _, err := s.Enroll(ctx, "benutzt", sampleNode("n1", "k1")); err != nil {
+ t.Fatal(err)
+ }
+ clock.advance(time.Minute)
+
+ n, err := s.PurgeEnrollmentTokens(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != 2 {
+ t.Errorf("%d Tokens gelöscht, erwartet 2", n)
+ }
+ if _, err := s.Enroll(ctx, "gültig", sampleNode("n2", "k2")); err != nil {
+ t.Errorf("gültiger Token wurde gelöscht: %v", err)
+ }
+ if _, err := s.Enroll(ctx, "läuft-ab", sampleNode("n3", "k3")); !errors.Is(err, ErrNotFound) {
+ t.Errorf("abgelaufener Token noch vorhanden: %v", err)
+ }
+}
diff --git a/pkg/stream/client.go b/pkg/stream/client.go
new file mode 100644
index 0000000..8bf756a
--- /dev/null
+++ b/pkg/stream/client.go
@@ -0,0 +1,197 @@
+package stream
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "mime"
+ "net/http"
+ "time"
+)
+
+var (
+ // ErrUnexpectedResponse: Der Server antwortet nicht mit 200 und text/event-stream.
+ ErrUnexpectedResponse = errors.New("stream: unerwartete Server-Antwort")
+ // ErrIdleTimeout: Innerhalb von IdleTimeout kam nichts an, auch kein Keepalive.
+ ErrIdleTimeout = errors.New("stream: Server schweigt (Idle-Timeout)")
+ // ErrStreamClosed: Der Server hat den Stream beendet.
+ ErrStreamClosed = errors.New("stream: vom Server beendet")
+)
+
+// Client ist die Agent-Seite des Streams.
+type Client struct {
+ // URL des Stream-Endpunkts.
+ URL string
+ // HTTPClient für die Verbindung (Default http.DefaultClient). Er darf
+ // kein Timeout gesetzt haben, sonst bricht er den langlebigen Stream ab;
+ // die Erkennung toter Verbindungen übernimmt IdleTimeout.
+ HTTPClient *http.Client
+ // Prepare wird vor jedem Verbindungsaufbau mit dem Request aufgerufen,
+ // z. B. um ihn zu signieren.
+ Prepare func(*http.Request) error
+ // IdleTimeout: Kommt so lange gar nichts an, gilt die Verbindung als tot
+ // (Default 90s = 2 × Server-Keepalive).
+ IdleTimeout time.Duration
+ // MinBackoff und MaxBackoff begrenzen die Wartezeit zwischen
+ // Verbindungsversuchen in Run (Default 1s bzw. 1min).
+ MinBackoff, MaxBackoff time.Duration
+ // OnError wird in Run vor jeder Wartezeit mit dem Grund aufgerufen.
+ OnError func(err error, retryIn time.Duration)
+
+ sleep func(ctx context.Context, d time.Duration) error // für Tests ersetzbar
+}
+
+func (c *Client) idleTimeout() time.Duration { return orDefault(c.IdleTimeout, 90*time.Second) }
+func (c *Client) minBackoff() time.Duration { return orDefault(c.MinBackoff, time.Second) }
+func (c *Client) maxBackoff() time.Duration { return orDefault(c.MaxBackoff, time.Minute) }
+
+func (c *Client) httpClient() *http.Client {
+ if c.HTTPClient != nil {
+ return c.HTTPClient
+ }
+ return http.DefaultClient
+}
+
+// handlerError markiert Fehler des Handlers, damit Run sie nicht als
+// Verbindungsproblem behandelt.
+type handlerError struct{ err error }
+
+func (e *handlerError) Error() string { return e.err.Error() }
+func (e *handlerError) Unwrap() error { return e.err }
+
+// Connect baut genau eine Verbindung auf und ruft handle für jedes Event auf,
+// bis die Verbindung endet. Gibt handle einen Fehler zurück, endet Connect
+// mit diesem Fehler.
+func (c *Client) Connect(ctx context.Context, handle func(Event) error) error {
+ _, err := c.connect(ctx, handle)
+ var he *handlerError
+ if errors.As(err, &he) {
+ return he.err
+ }
+ return err
+}
+
+// connect meldet zusätzlich, ob der Stream zustande kam (für das
+// Zurücksetzen des Backoffs).
+func (c *Client) connect(ctx context.Context, handle func(Event) error) (connected bool, err error) {
+ ctx, cancel := context.WithCancelCause(ctx)
+ defer cancel(nil)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.URL, nil)
+ if err != nil {
+ return false, err
+ }
+ req.Header.Set("Accept", "text/event-stream")
+ req.Header.Set("Cache-Control", "no-cache")
+ if c.Prepare != nil {
+ if err := c.Prepare(req); err != nil {
+ return false, err
+ }
+ }
+
+ resp, err := c.httpClient().Do(req)
+ if err != nil {
+ return false, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return false, fmt.Errorf("%w: Status %d", ErrUnexpectedResponse, resp.StatusCode)
+ }
+ if mt, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")); mt != "text/event-stream" {
+ return false, fmt.Errorf("%w: Content-Type %q", ErrUnexpectedResponse, resp.Header.Get("Content-Type"))
+ }
+
+ idle := c.idleTimeout()
+ timer := time.AfterFunc(idle, func() { cancel(ErrIdleTimeout) })
+ defer timer.Stop()
+ dec := NewDecoder(&activityReader{r: resp.Body, timer: timer, idle: idle})
+
+ for {
+ ev, err := dec.Next()
+ if err != nil {
+ if ctx.Err() != nil {
+ return true, context.Cause(ctx)
+ }
+ if errors.Is(err, io.EOF) {
+ return true, ErrStreamClosed
+ }
+ return true, err
+ }
+ if err := handle(ev); err != nil {
+ return true, &handlerError{err}
+ }
+ }
+}
+
+// activityReader setzt den Idle-Timer bei jedem empfangenen Byte zurück –
+// Keepalives zählen also mit.
+type activityReader struct {
+ r io.Reader
+ timer *time.Timer
+ idle time.Duration
+}
+
+func (a *activityReader) Read(p []byte) (int, error) {
+ n, err := a.r.Read(p)
+ if n > 0 {
+ a.timer.Reset(a.idle)
+ }
+ return n, err
+}
+
+// Run hält die Verbindung dauerhaft aufrecht: Nach jedem Verbindungsende
+// wird mit exponentiellem Backoff neu verbunden; nach einer erfolgreich
+// aufgebauten Verbindung beginnt das Backoff wieder bei MinBackoff. Run endet
+// nur, wenn ctx endet (ctx.Err()) oder handle einen Fehler liefert.
+func (c *Client) Run(ctx context.Context, handle func(Event) error) error {
+ sleep := c.sleep
+ if sleep == nil {
+ sleep = sleepCtx
+ }
+ attempt := 0
+ for {
+ connected, err := c.connect(ctx, handle)
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ var he *handlerError
+ if errors.As(err, &he) {
+ return he.err
+ }
+ if connected {
+ attempt = 0
+ }
+ d := backoff(attempt, c.minBackoff(), c.maxBackoff())
+ attempt++
+ if c.OnError != nil {
+ c.OnError(err, d)
+ }
+ if err := sleep(ctx, d); err != nil {
+ return err
+ }
+ }
+}
+
+// backoff liefert minD · 2^attempt, höchstens maxD.
+func backoff(attempt int, minD, maxD time.Duration) time.Duration {
+ d := minD
+ for range attempt {
+ if d >= maxD/2 {
+ return maxD
+ }
+ d *= 2
+ }
+ return min(d, maxD)
+}
+
+func sleepCtx(ctx context.Context, d time.Duration) error {
+ t := time.NewTimer(d)
+ defer t.Stop()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-t.C:
+ return nil
+ }
+}
diff --git a/pkg/stream/client_test.go b/pkg/stream/client_test.go
new file mode 100644
index 0000000..ceda693
--- /dev/null
+++ b/pkg/stream/client_test.go
@@ -0,0 +1,333 @@
+package stream
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func TestBackoff(t *testing.T) {
+ const minD, maxD = time.Second, 60 * time.Second
+ tests := map[int]time.Duration{
+ 0: time.Second,
+ 1: 2 * time.Second,
+ 5: 32 * time.Second,
+ 6: 60 * time.Second,
+ 100: 60 * time.Second,
+ }
+ for attempt, want := range tests {
+ if got := backoff(attempt, minD, maxD); got != want {
+ t.Errorf("backoff(%d) = %v, erwartet %v", attempt, got, want)
+ }
+ }
+ if got := backoff(3, 5*time.Second, 5*time.Second); got != 5*time.Second {
+ t.Errorf("min == max: %v", got)
+ }
+}
+
+func TestClient_Defaults(t *testing.T) {
+ var c Client
+ if got := c.idleTimeout(); got != 90*time.Second {
+ t.Errorf("IdleTimeout-Default = %v, erwartet 90s (2 × Keepalive)", got)
+ }
+ if got := c.minBackoff(); got != time.Second {
+ t.Errorf("MinBackoff-Default = %v", got)
+ }
+ if got := c.maxBackoff(); got != time.Minute {
+ t.Errorf("MaxBackoff-Default = %v", got)
+ }
+ if c.httpClient() != http.DefaultClient {
+ t.Error("HTTPClient-Default sollte http.DefaultClient sein")
+ }
+}
+
+// Ende-zu-Ende: Client verbindet sich über Run mit einem Hub, Prepare setzt
+// Authentifizierung, ein vom Hub gesendetes Event kommt beim Handler an.
+func TestClient_ReceivesEvents(t *testing.T) {
+ h := &Hub{}
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer geheim" {
+ http.Error(w, "nein", http.StatusUnauthorized)
+ return
+ }
+ if r.Header.Get("Accept") != "text/event-stream" {
+ http.Error(w, "falscher Accept", http.StatusNotAcceptable)
+ return
+ }
+ h.Serve(w, r, "node-1")
+ }))
+ t.Cleanup(srv.Close)
+ t.Cleanup(h.Close)
+
+ c := &Client{
+ URL: srv.URL,
+ Prepare: func(r *http.Request) error { r.Header.Set("Authorization", "Bearer geheim"); return nil },
+ }
+ got := make(chan Event, 1)
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- c.Run(ctx, func(ev Event) error { got <- ev; return nil }) }()
+
+ eventually(t, "Client verbunden", func() bool { return h.Connected("node-1") })
+ if err := h.Send("node-1", Event{Type: EventNodeCommand}); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case ev := <-got:
+ if ev.Type != EventNodeCommand || ev.NodeID != "node-1" {
+ t.Errorf("unerwartetes Event: %+v", ev)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("kein Event empfangen")
+ }
+ cancel()
+ if err := waitErr(t, done); !errors.Is(err, context.Canceled) {
+ t.Errorf("Run: erwartet context.Canceled, bekommen %v", err)
+ }
+}
+
+func TestClient_UnexpectedResponse(t *testing.T) {
+ tests := map[string]http.HandlerFunc{
+ "Status 401": func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "nein", http.StatusUnauthorized)
+ },
+ "falscher Content-Type": func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain")
+ w.Write([]byte("hallo"))
+ },
+ }
+ for name, handler := range tests {
+ t.Run(name, func(t *testing.T) {
+ srv := httptest.NewServer(handler)
+ defer srv.Close()
+ c := &Client{URL: srv.URL}
+ err := c.Connect(context.Background(), func(Event) error { return nil })
+ if !errors.Is(err, ErrUnexpectedResponse) {
+ t.Errorf("erwartet ErrUnexpectedResponse, bekommen %v", err)
+ }
+ })
+ }
+}
+
+// sseHandler sendet nur die Header und hält die Verbindung ohne Daten offen.
+func silentSSE(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.WriteHeader(http.StatusOK)
+ w.(http.Flusher).Flush()
+ <-r.Context().Done()
+}
+
+func TestClient_IdleTimeout(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(silentSSE))
+ defer srv.Close()
+ c := &Client{URL: srv.URL, IdleTimeout: 50 * time.Millisecond}
+
+ start := time.Now()
+ err := c.Connect(context.Background(), func(Event) error { return nil })
+ if !errors.Is(err, ErrIdleTimeout) {
+ t.Fatalf("erwartet ErrIdleTimeout, bekommen %v", err)
+ }
+ if d := time.Since(start); d > time.Second {
+ t.Errorf("Timeout griff erst nach %v", d)
+ }
+}
+
+func TestClient_KeepAlivePreventsIdleTimeout(t *testing.T) {
+ h := &Hub{KeepAlive: 10 * time.Millisecond}
+ srv, _ := hubServer(t, h)
+ c := &Client{URL: srv.URL + "?node=n", IdleTimeout: 60 * time.Millisecond}
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- c.Connect(ctx, func(Event) error { return nil }) }()
+
+ select {
+ case err := <-done:
+ t.Fatalf("Verbindung trotz Keepalives beendet: %v", err)
+ case <-time.After(300 * time.Millisecond):
+ }
+ cancel()
+ if err := waitErr(t, done); !errors.Is(err, context.Canceled) {
+ t.Errorf("erwartet context.Canceled, bekommen %v", err)
+ }
+}
+
+func TestClient_ServerClosesStream(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
+ w.Write([]byte("data: " + evJSON + "\n\n"))
+ }))
+ defer srv.Close()
+
+ var got []Event
+ err := (&Client{URL: srv.URL}).Connect(context.Background(), func(ev Event) error {
+ got = append(got, ev)
+ return nil
+ })
+ if !errors.Is(err, ErrStreamClosed) {
+ t.Errorf("erwartet ErrStreamClosed, bekommen %v", err)
+ }
+ if !reflect.DeepEqual(got, []Event{evWant}) {
+ t.Errorf("Events: %+v", got)
+ }
+}
+
+func TestClient_PrepareError(t *testing.T) {
+ errSign := errors.New("Signatur fehlgeschlagen")
+ c := &Client{URL: "http://127.0.0.1:1", Prepare: func(*http.Request) error { return errSign }}
+ if err := c.Connect(context.Background(), nil); !errors.Is(err, errSign) {
+ t.Errorf("erwartet Prepare-Fehler, bekommen %v", err)
+ }
+}
+
+func TestClient_ConnectionRefused(t *testing.T) {
+ srv := httptest.NewServer(http.NotFoundHandler())
+ url := srv.URL
+ srv.Close()
+ if err := (&Client{URL: url}).Connect(context.Background(), nil); err == nil {
+ t.Error("erwartet Verbindungsfehler")
+ }
+}
+
+func TestClient_HandlerErrorStopsRun(t *testing.T) {
+ var requests atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests.Add(1)
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Write([]byte("data: " + evJSON + "\n\n"))
+ }))
+ defer srv.Close()
+
+ errStop := errors.New("Handler bricht ab")
+ err := (&Client{URL: srv.URL}).Run(context.Background(), func(Event) error { return errStop })
+ if !errors.Is(err, errStop) {
+ t.Errorf("erwartet Handler-Fehler, bekommen %v", err)
+ }
+ if n := requests.Load(); n != 1 {
+ t.Errorf("%d Verbindungsversuche, erwartet 1 (kein Reconnect nach Handler-Fehler)", n)
+ }
+}
+
+// Ablauf: 503, 503, erfolgreicher Stream (endet), danach nur noch 503.
+// Erwartet: exponentielles Backoff, Reset nach erfolgreicher Verbindung,
+// Deckelung bei MaxBackoff.
+func TestClient_RunRetriesWithBackoff(t *testing.T) {
+ var requests atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if requests.Add(1) == 3 {
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Write([]byte("data: " + evJSON + "\n\n"))
+ return
+ }
+ http.Error(w, "später", http.StatusServiceUnavailable)
+ }))
+ defer srv.Close()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ var mu sync.Mutex
+ var delays []time.Duration
+ var reported int
+ var events atomic.Int32
+ c := &Client{
+ URL: srv.URL,
+ MinBackoff: 10 * time.Millisecond,
+ MaxBackoff: 40 * time.Millisecond,
+ OnError: func(err error, retryIn time.Duration) {
+ mu.Lock()
+ defer mu.Unlock()
+ if err == nil {
+ t.Error("OnError ohne Fehler aufgerufen")
+ }
+ reported++
+ },
+ sleep: func(ctx context.Context, d time.Duration) error {
+ mu.Lock()
+ defer mu.Unlock()
+ delays = append(delays, d)
+ if len(delays) == 6 {
+ cancel()
+ return ctx.Err()
+ }
+ return nil
+ },
+ }
+ err := c.Run(ctx, func(Event) error { events.Add(1); return nil })
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("erwartet context.Canceled, bekommen %v", err)
+ }
+ ms := time.Millisecond
+ want := []time.Duration{10 * ms, 20 * ms, 10 * ms, 20 * ms, 40 * ms, 40 * ms}
+ mu.Lock()
+ defer mu.Unlock()
+ if !reflect.DeepEqual(delays, want) {
+ t.Errorf("Backoff-Folge = %v, erwartet %v", delays, want)
+ }
+ if reported != 6 {
+ t.Errorf("OnError %d-mal aufgerufen, erwartet 6", reported)
+ }
+ if events.Load() != 1 {
+ t.Errorf("%d Events empfangen, erwartet 1", events.Load())
+ }
+}
+
+func TestClient_ConnectReturnsHandlerError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Write([]byte("data: " + evJSON + "\n\n"))
+ }))
+ defer srv.Close()
+
+ errStop := errors.New("stopp")
+ err := (&Client{URL: srv.URL}).Connect(context.Background(), func(Event) error { return errStop })
+ if err != errStop {
+ t.Errorf("erwartet den unverpackten Handler-Fehler, bekommen %v", err)
+ }
+}
+
+func TestClient_CustomHTTPClient(t *testing.T) {
+ var used atomic.Bool
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ }))
+ defer srv.Close()
+ hc := srv.Client()
+ base := hc.Transport
+ hc.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ used.Store(true)
+ return base.RoundTrip(r)
+ })
+
+ err := (&Client{URL: srv.URL, HTTPClient: hc}).Connect(context.Background(), nil)
+ if !errors.Is(err, ErrStreamClosed) {
+ t.Errorf("erwartet ErrStreamClosed, bekommen %v", err)
+ }
+ if !used.Load() {
+ t.Error("eigener HTTPClient wurde nicht verwendet")
+ }
+}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
+
+func TestSleepCtx(t *testing.T) {
+ if err := sleepCtx(context.Background(), time.Millisecond); err != nil {
+ t.Errorf("abgelaufener Timer: %v", err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ start := time.Now()
+ if err := sleepCtx(ctx, time.Hour); !errors.Is(err, context.Canceled) {
+ t.Errorf("erwartet context.Canceled, bekommen %v", err)
+ }
+ if time.Since(start) > time.Second {
+ t.Error("sleepCtx hat trotz Abbruch gewartet")
+ }
+}
diff --git a/pkg/stream/decoder.go b/pkg/stream/decoder.go
new file mode 100644
index 0000000..0587ff9
--- /dev/null
+++ b/pkg/stream/decoder.go
@@ -0,0 +1,80 @@
+package stream
+
+import (
+ "bufio"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+)
+
+// MaxEventSize begrenzt die Datenmenge eines einzelnen Events.
+const MaxEventSize = 1 << 20
+
+// ErrEventTooLarge meldet ein Event über MaxEventSize.
+var ErrEventTooLarge = errors.New("stream: Event zu groß")
+
+// Decoder liest Events aus einem SSE-Stream. Kommentarzeilen (Keepalives)
+// und unbekannte Felder werden übersprungen; maßgeblich ist allein das JSON
+// in den data-Zeilen.
+type Decoder struct {
+ sc *bufio.Scanner
+}
+
+// NewDecoder liest aus r.
+func NewDecoder(r io.Reader) *Decoder {
+ sc := bufio.NewScanner(r)
+ sc.Buffer(make([]byte, 4096), MaxEventSize+1024)
+ return &Decoder{sc: sc}
+}
+
+// Next liefert das nächste Event. Am sauberen Stream-Ende kommt io.EOF, endet
+// der Stream mitten in einem Event io.ErrUnexpectedEOF.
+func (d *Decoder) Next() (Event, error) {
+ var data strings.Builder
+ pending := false
+ for d.sc.Scan() {
+ line := d.sc.Text() // ScanLines entfernt auch ein \r vor dem \n
+ switch {
+ case line == "":
+ if !pending {
+ continue
+ }
+ return parseEvent(data.String())
+ case strings.HasPrefix(line, ":"):
+ // Kommentar / Keepalive
+ case strings.HasPrefix(line, "data:"):
+ v := strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")
+ if pending {
+ data.WriteByte('\n')
+ }
+ data.WriteString(v)
+ pending = true
+ if data.Len() > MaxEventSize {
+ return Event{}, ErrEventTooLarge
+ }
+ }
+ }
+ if err := d.sc.Err(); err != nil {
+ if errors.Is(err, bufio.ErrTooLong) {
+ return Event{}, ErrEventTooLarge
+ }
+ return Event{}, err
+ }
+ if pending {
+ return Event{}, io.ErrUnexpectedEOF
+ }
+ return Event{}, io.EOF
+}
+
+func parseEvent(data string) (Event, error) {
+ var ev Event
+ if err := json.Unmarshal([]byte(data), &ev); err != nil {
+ return Event{}, fmt.Errorf("%w: %v", ErrInvalidEvent, err)
+ }
+ if ev.Type == "" {
+ return Event{}, fmt.Errorf("%w: Typ fehlt", ErrInvalidEvent)
+ }
+ return ev, nil
+}
diff --git a/pkg/stream/decoder_test.go b/pkg/stream/decoder_test.go
new file mode 100644
index 0000000..9c1f5cf
--- /dev/null
+++ b/pkg/stream/decoder_test.go
@@ -0,0 +1,110 @@
+package stream
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+const evJSON = `{"event_id":"evt_1","timestamp":1,"type":"node_command","node_id":"n1","payload":{"action":"drain"}}`
+
+var evWant = Event{
+ ID: "evt_1",
+ Timestamp: 1,
+ Type: EventNodeCommand,
+ NodeID: "n1",
+ Payload: json.RawMessage(`{"action":"drain"}`),
+}
+
+func decodeAll(t *testing.T, input string) ([]Event, error) {
+ t.Helper()
+ dec := NewDecoder(strings.NewReader(input))
+ var out []Event
+ for {
+ ev, err := dec.Next()
+ if err != nil {
+ return out, err
+ }
+ out = append(out, ev)
+ }
+}
+
+func TestDecoder(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want []Event
+ }{
+ {"einzelnes Event", "id: evt_1\nevent: node_command\ndata: " + evJSON + "\n\n", []Event{evWant}},
+ {"Keepalives werden übersprungen", ":\n\n:\n\ndata: " + evJSON + "\n\n:\n\n", []Event{evWant}},
+ {"CRLF-Zeilenenden", "id: evt_1\r\ndata: " + evJSON + "\r\n\r\n", []Event{evWant}},
+ {"data ohne Leerzeichen", "data:" + evJSON + "\n\n", []Event{evWant}},
+ {
+ "mehrere data-Zeilen",
+ "data: {\"event_id\":\"evt_1\",\"timestamp\":1,\ndata: \"type\":\"node_command\",\"node_id\":\"n1\",\ndata: \"payload\":{\"action\":\"drain\"}}\n\n",
+ []Event{evWant},
+ },
+ {"unbekannte Felder ignoriert", "retry: 1000\nfoo: bar\ndata: " + evJSON + "\n\n", []Event{evWant}},
+ {"Leerzeilen ohne Daten", "\n\n\nevent: x\n\n", nil},
+ {"zwei Events", "data: " + evJSON + "\n\ndata: " + evJSON + "\n\n", []Event{evWant, evWant}},
+ {"leerer Stream", "", nil},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := decodeAll(t, tt.input)
+ if !errors.Is(err, io.EOF) {
+ t.Fatalf("erwartet io.EOF am Ende, bekommen %v", err)
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("Events weichen ab\n got: %+v\nwant: %+v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestDecoder_Errors(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want error
+ }{
+ {"ungültiges JSON", "data: {kaputt\n\n", ErrInvalidEvent},
+ {"Event ohne Typ", "data: {\"event_id\":\"evt_1\"}\n\n", ErrInvalidEvent},
+ {"abgeschnittenes Event", "data: " + evJSON + "\n", io.ErrUnexpectedEOF},
+ {"Zeile zu lang", "data: " + strings.Repeat("x", MaxEventSize+1) + "\n\n", ErrEventTooLarge},
+ {"Summe der data-Zeilen zu lang", strings.Repeat("data: "+strings.Repeat("x", MaxEventSize/4)+"\n", 5) + "\n", ErrEventTooLarge},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := decodeAll(t, tt.input)
+ if !errors.Is(err, tt.want) {
+ t.Errorf("erwartet %v, bekommen %v", tt.want, err)
+ }
+ })
+ }
+}
+
+func TestEncodeDecodeRoundTrip(t *testing.T) {
+ ev, err := NewEvent(EventServiceState, map[string]any{"labels": map[string]string{"traefik.enable": "true"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ ev.NodeID = "n1"
+ frame, err := encode(ev)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(string(frame), "id: "+ev.ID+"\nevent: service_state\ndata: ") || !strings.HasSuffix(string(frame), "\n\n") {
+ t.Errorf("unerwarteter SSE-Rahmen: %q", frame)
+ }
+ got, err := NewDecoder(strings.NewReader(string(frame))).Next()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, ev) {
+ t.Errorf("Round-Trip weicht ab\n got: %+v\nwant: %+v", got, ev)
+ }
+}
diff --git a/pkg/stream/event.go b/pkg/stream/event.go
new file mode 100644
index 0000000..7ee9060
--- /dev/null
+++ b/pkg/stream/event.go
@@ -0,0 +1,78 @@
+// Package stream implementiert den langlebigen Kanal vom Sultan-Server zum
+// Agenten als Server-Sent Events (SSE) über eine ausgehende HTTP-Verbindung
+// des Agenten.
+//
+// Der Server (Hub) hält pro Knoten einen offenen Stream, schickt Events sofort
+// und sendet in Ruhephasen alle 45s die SSE-Kommentarzeile ":\n\n" als
+// Keepalive, damit NAT-/Conntrack-Einträge nicht verfallen. Der Agent (Client)
+// liest den Stream, erkennt einen toten Server, wenn zu lange gar nichts
+// ankommt (auch kein Keepalive), und verbindet sich mit exponentiellem
+// Backoff neu.
+//
+// Die Gegenrichtung (Agent -> Server, z. B. service_state) ist nicht Teil
+// dieses Streams; sie läuft über eigene Requests.
+package stream
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+)
+
+// EventType ist der Typ eines Events gemäß Spec Abschnitt 4.2.
+type EventType string
+
+const (
+ EventServiceState EventType = "service_state"
+ EventLeaseRenew EventType = "lease_renew"
+ EventNodeCommand EventType = "node_command"
+ EventError EventType = "error"
+)
+
+// ErrInvalidEvent meldet ein Event, das sich nicht senden oder lesen lässt.
+var ErrInvalidEvent = errors.New("stream: ungültiges Event")
+
+// Event ist die Nachricht auf dem Stream.
+type Event struct {
+ ID string `json:"event_id"`
+ Timestamp int64 `json:"timestamp"`
+ Type EventType `json:"type"`
+ NodeID string `json:"node_id"`
+ Payload json.RawMessage `json:"payload,omitempty"`
+}
+
+// NewEvent erzeugt ein Event mit neuer ID, aktuellem Zeitstempel und der als
+// JSON serialisierten payload (nil ergibt keine Payload). NodeID setzt der Hub
+// beim Senden.
+func NewEvent(t EventType, payload any) (Event, error) {
+ if t == "" {
+ return Event{}, fmt.Errorf("%w: Typ fehlt", ErrInvalidEvent)
+ }
+ ev := Event{ID: newEventID(), Timestamp: time.Now().Unix(), Type: t}
+ if payload != nil {
+ b, err := json.Marshal(payload)
+ if err != nil {
+ return Event{}, fmt.Errorf("%w: %v", ErrInvalidEvent, err)
+ }
+ ev.Payload = b
+ }
+ return ev, nil
+}
+
+func newEventID() string {
+ var b [16]byte
+ rand.Read(b[:])
+ return "evt_" + hex.EncodeToString(b[:])
+}
+
+// encode erzeugt den SSE-Rahmen für ev.
+func encode(ev Event) ([]byte, error) {
+ data, err := json.Marshal(ev)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalidEvent, err)
+ }
+ return fmt.Appendf(nil, "id: %s\nevent: %s\ndata: %s\n\n", ev.ID, ev.Type, data), nil
+}
diff --git a/pkg/stream/event_test.go b/pkg/stream/event_test.go
new file mode 100644
index 0000000..32f249b
--- /dev/null
+++ b/pkg/stream/event_test.go
@@ -0,0 +1,84 @@
+package stream
+
+import (
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestNewEvent(t *testing.T) {
+ before := time.Now().Unix()
+ ev, err := NewEvent(EventNodeCommand, map[string]string{"action": "drain"})
+ if err != nil {
+ t.Fatalf("unerwarteter Fehler: %v", err)
+ }
+ if !strings.HasPrefix(ev.ID, "evt_") || len(ev.ID) <= len("evt_") {
+ t.Errorf("ID = %q, erwartet Präfix evt_", ev.ID)
+ }
+ if ev.Timestamp < before || ev.Timestamp > time.Now().Unix() {
+ t.Errorf("Timestamp = %d liegt nicht im Testzeitraum", ev.Timestamp)
+ }
+ if ev.Type != EventNodeCommand {
+ t.Errorf("Type = %q", ev.Type)
+ }
+ if ev.NodeID != "" {
+ t.Errorf("NodeID = %q, erwartet leer (setzt der Hub)", ev.NodeID)
+ }
+ if string(ev.Payload) != `{"action":"drain"}` {
+ t.Errorf("Payload = %s", ev.Payload)
+ }
+}
+
+func TestNewEvent_NilPayload(t *testing.T) {
+ ev, err := NewEvent(EventLeaseRenew, nil)
+ if err != nil {
+ t.Fatalf("unerwarteter Fehler: %v", err)
+ }
+ if ev.Payload != nil {
+ t.Errorf("Payload = %s, erwartet nil", ev.Payload)
+ }
+}
+
+func TestNewEvent_Errors(t *testing.T) {
+ if _, err := NewEvent("", nil); !errors.Is(err, ErrInvalidEvent) {
+ t.Errorf("leerer Typ: erwartet ErrInvalidEvent, bekommen %v", err)
+ }
+ if _, err := NewEvent(EventError, make(chan int)); !errors.Is(err, ErrInvalidEvent) {
+ t.Errorf("nicht serialisierbare Payload: erwartet ErrInvalidEvent, bekommen %v", err)
+ }
+}
+
+func TestNewEvent_UniqueIDs(t *testing.T) {
+ seen := map[string]bool{}
+ for range 1000 {
+ ev, err := NewEvent(EventError, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if seen[ev.ID] {
+ t.Fatalf("doppelte ID %q", ev.ID)
+ }
+ seen[ev.ID] = true
+ }
+}
+
+// Feldnamen gemäß Spec Abschnitt 4.2.
+func TestEvent_JSONFieldNames(t *testing.T) {
+ ev := Event{
+ ID: "evt_1",
+ Timestamp: 1727618531,
+ Type: EventServiceState,
+ NodeID: "node-proxmox-vm-42",
+ Payload: json.RawMessage(`{"a":1}`),
+ }
+ b, err := json.Marshal(ev)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := `{"event_id":"evt_1","timestamp":1727618531,"type":"service_state","node_id":"node-proxmox-vm-42","payload":{"a":1}}`
+ if string(b) != want {
+ t.Errorf("JSON weicht ab\n got: %s\nwant: %s", b, want)
+ }
+}
diff --git a/pkg/stream/hub.go b/pkg/stream/hub.go
new file mode 100644
index 0000000..254a40a
--- /dev/null
+++ b/pkg/stream/hub.go
@@ -0,0 +1,271 @@
+package stream
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "slices"
+ "sync"
+ "time"
+)
+
+var (
+ // ErrNodeOffline: Für den Knoten ist kein Stream offen.
+ ErrNodeOffline = errors.New("stream: Knoten nicht verbunden")
+ // ErrBufferFull: Der Knoten liest nicht schnell genug; das Event wurde verworfen.
+ ErrBufferFull = errors.New("stream: Sendepuffer voll")
+ // ErrHubClosed: Der Hub wurde geschlossen.
+ ErrHubClosed = errors.New("stream: Hub geschlossen")
+ // ErrReplaced: Derselbe Knoten hat sich neu verbunden; der alte Stream endet.
+ ErrReplaced = errors.New("stream: durch neue Verbindung ersetzt")
+ // ErrStreamingUnsupported: Der ResponseWriter kann nicht flushen.
+ ErrStreamingUnsupported = errors.New("stream: ResponseWriter unterstützt kein Streaming")
+ // ErrInvalidNodeID: Serve wurde ohne Knoten-ID aufgerufen.
+ ErrInvalidNodeID = errors.New("stream: Knoten-ID fehlt")
+ // ErrDisconnected: Der Server hat den Stream per Disconnect beendet.
+ ErrDisconnected = errors.New("stream: vom Server getrennt")
+)
+
+// Hub hält die offenen Streams aller Knoten. Der Nullwert ist einsatzbereit.
+// Felder dürfen nach der ersten Verwendung nicht mehr geändert werden.
+type Hub struct {
+ // KeepAlive ist das Intervall der Keepalive-Kommentare (Default 45s).
+ KeepAlive time.Duration
+ // WriteTimeout begrenzt jeden einzelnen Schreibvorgang (Default 10s).
+ // Hängt ein Schreibvorgang länger, gilt der Knoten als tot.
+ WriteTimeout time.Duration
+ // BufferSize ist die Zahl der Events, die pro Knoten gepuffert werden (Default 16).
+ BufferSize int
+ // OnConnect und OnDisconnect werden aufgerufen, wenn ein Knoten online
+ // bzw. offline geht. Ersetzt ein Knoten seine eigene Verbindung, gibt es
+ // nur ein weiteres OnConnect, kein OnDisconnect.
+ OnConnect func(nodeID string)
+ OnDisconnect func(nodeID string)
+
+ mu sync.Mutex
+ conns map[string]*conn
+ closed bool
+}
+
+type conn struct {
+ frames chan []byte
+ done chan struct{}
+ once sync.Once
+ reason error
+}
+
+func (c *conn) stop(reason error) {
+ c.once.Do(func() {
+ c.reason = reason
+ close(c.done)
+ })
+}
+
+func (h *Hub) keepAlive() time.Duration { return orDefault(h.KeepAlive, 45*time.Second) }
+func (h *Hub) writeTimeout() time.Duration { return orDefault(h.WriteTimeout, 10*time.Second) }
+
+func (h *Hub) bufferSize() int {
+ if h.BufferSize > 0 {
+ return h.BufferSize
+ }
+ return 16
+}
+
+// Serve betreibt den Stream für nodeID auf w, bis der Client die Verbindung
+// trennt, ein Schreibvorgang fehlschlägt, derselbe Knoten sich neu verbindet
+// (ErrReplaced) oder der Hub geschlossen wird (ErrHubClosed). Authentifizierung
+// ist Sache des Aufrufers: nodeID muss bereits geprüft sein.
+func (h *Hub) Serve(w http.ResponseWriter, r *http.Request, nodeID string) error {
+ if nodeID == "" {
+ return ErrInvalidNodeID
+ }
+ h.mu.Lock()
+ closed := h.closed
+ h.mu.Unlock()
+ if closed {
+ return ErrHubClosed
+ }
+
+ rc := http.NewResponseController(w)
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("X-Accel-Buffering", "no") // Reverse-Proxies nicht puffern lassen
+ w.WriteHeader(http.StatusOK)
+ if err := rc.Flush(); err != nil {
+ if errors.Is(err, http.ErrNotSupported) {
+ return ErrStreamingUnsupported
+ }
+ return err
+ }
+
+ c := &conn{frames: make(chan []byte, h.bufferSize()), done: make(chan struct{})}
+ if err := h.register(nodeID, c); err != nil {
+ return err
+ }
+ defer h.unregister(nodeID, c)
+
+ ticker := time.NewTicker(h.keepAlive())
+ defer ticker.Stop()
+ write := func(p []byte) error {
+ err := rc.SetWriteDeadline(time.Now().Add(h.writeTimeout()))
+ if err != nil && !errors.Is(err, http.ErrNotSupported) {
+ return err
+ }
+ if _, err := w.Write(p); err != nil {
+ return err
+ }
+ return rc.Flush()
+ }
+
+ keepalive := []byte(":\n\n")
+ for {
+ select {
+ case <-r.Context().Done():
+ return r.Context().Err()
+ case <-c.done:
+ return c.reason
+ case frame := <-c.frames:
+ if err := write(frame); err != nil {
+ return err
+ }
+ ticker.Reset(h.keepAlive())
+ case <-ticker.C:
+ if err := write(keepalive); err != nil {
+ return err
+ }
+ }
+ }
+}
+
+func (h *Hub) register(nodeID string, c *conn) error {
+ h.mu.Lock()
+ if h.closed {
+ h.mu.Unlock()
+ return ErrHubClosed
+ }
+ if h.conns == nil {
+ h.conns = map[string]*conn{}
+ }
+ if old := h.conns[nodeID]; old != nil {
+ old.stop(ErrReplaced)
+ }
+ h.conns[nodeID] = c
+ h.mu.Unlock()
+
+ if h.OnConnect != nil {
+ h.OnConnect(nodeID)
+ }
+ return nil
+}
+
+func (h *Hub) unregister(nodeID string, c *conn) {
+ h.mu.Lock()
+ current := h.conns[nodeID] == c
+ if current {
+ delete(h.conns, nodeID)
+ }
+ h.mu.Unlock()
+
+ c.stop(ErrNodeOffline)
+ if current && h.OnDisconnect != nil {
+ h.OnDisconnect(nodeID)
+ }
+}
+
+// Send stellt ev in den Stream von nodeID, ohne zu blockieren. NodeID wird
+// immer auf nodeID gesetzt, ID und Timestamp nur ergänzt, wenn sie fehlen.
+func (h *Hub) Send(nodeID string, ev Event) error {
+ if ev.Type == "" {
+ return fmt.Errorf("%w: Typ fehlt", ErrInvalidEvent)
+ }
+ if ev.ID == "" {
+ ev.ID = newEventID()
+ }
+ if ev.Timestamp == 0 {
+ ev.Timestamp = time.Now().Unix()
+ }
+ ev.NodeID = nodeID
+ frame, err := encode(ev)
+ if err != nil {
+ return err
+ }
+
+ h.mu.Lock()
+ closed, c := h.closed, h.conns[nodeID]
+ h.mu.Unlock()
+ if closed {
+ return ErrHubClosed
+ }
+ if c == nil {
+ return ErrNodeOffline
+ }
+ select {
+ case <-c.done:
+ return ErrNodeOffline
+ default:
+ }
+ select {
+ case c.frames <- frame:
+ return nil
+ case <-c.done:
+ return ErrNodeOffline
+ default:
+ return ErrBufferFull
+ }
+}
+
+// Disconnect beendet den Stream von nodeID sofort, z. B. wenn Mandant oder
+// Knoten gesperrt wurden. Der Knoten gilt ab Rückkehr als offline,
+// OnDisconnect wird aufgerufen, Serve endet mit ErrDisconnected.
+func (h *Hub) Disconnect(nodeID string) error {
+ h.mu.Lock()
+ c := h.conns[nodeID]
+ if c != nil {
+ delete(h.conns, nodeID)
+ }
+ h.mu.Unlock()
+ if c == nil {
+ return ErrNodeOffline
+ }
+ c.stop(ErrDisconnected)
+ if h.OnDisconnect != nil {
+ h.OnDisconnect(nodeID)
+ }
+ return nil
+}
+
+// Connected meldet, ob für nodeID ein Stream offen ist.
+func (h *Hub) Connected(nodeID string) bool {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return h.conns[nodeID] != nil
+}
+
+// Nodes liefert die verbundenen Knoten, sortiert.
+func (h *Hub) Nodes() []string {
+ h.mu.Lock()
+ nodes := make([]string, 0, len(h.conns))
+ for n := range h.conns {
+ nodes = append(nodes, n)
+ }
+ h.mu.Unlock()
+ slices.Sort(nodes)
+ return nodes
+}
+
+// Close beendet alle Streams; danach liefern Serve und Send ErrHubClosed.
+func (h *Hub) Close() {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.closed = true
+ for _, c := range h.conns {
+ c.stop(ErrHubClosed)
+ }
+}
+
+func orDefault(d, def time.Duration) time.Duration {
+ if d > 0 {
+ return d
+ }
+ return def
+}
diff --git a/pkg/stream/hub_test.go b/pkg/stream/hub_test.go
new file mode 100644
index 0000000..80a644b
--- /dev/null
+++ b/pkg/stream/hub_test.go
@@ -0,0 +1,406 @@
+package stream
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// eventually wartet, bis cond wahr ist, höchstens zwei Sekunden.
+func eventually(t *testing.T, msg string, cond func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for !cond() {
+ if time.Now().After(deadline) {
+ t.Fatalf("Bedingung nicht erfüllt: %s", msg)
+ }
+ time.Sleep(2 * time.Millisecond)
+ }
+}
+
+// hubServer startet einen Testserver, der Verbindungen an h übergibt. Die
+// Knoten-ID kommt aus ?node=. Rückgabewerte von Serve landen in errs.
+func hubServer(t *testing.T, h *Hub) (srv *httptest.Server, errs chan error) {
+ t.Helper()
+ errs = make(chan error, 16)
+ srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ errs <- h.Serve(w, r, r.URL.Query().Get("node"))
+ }))
+ t.Cleanup(srv.Close)
+ t.Cleanup(h.Close)
+ return srv, errs
+}
+
+func openStream(t *testing.T, ctx context.Context, srv *httptest.Server, node string) *http.Response {
+ t.Helper()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"?node="+node, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := srv.Client().Do(req)
+ if err != nil {
+ t.Fatalf("Verbindung fehlgeschlagen: %v", err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+}
+
+func waitErr(t *testing.T, errs <-chan error) error {
+ t.Helper()
+ select {
+ case err := <-errs:
+ return err
+ case <-time.After(2 * time.Second):
+ t.Fatal("Serve ist nicht zurückgekehrt")
+ return nil
+ }
+}
+
+func TestHub_SendOffline(t *testing.T) {
+ var h Hub
+ if err := h.Send("node-1", Event{Type: EventNodeCommand}); !errors.Is(err, ErrNodeOffline) {
+ t.Errorf("erwartet ErrNodeOffline, bekommen %v", err)
+ }
+}
+
+func TestHub_Defaults(t *testing.T) {
+ var h Hub
+ if got := h.keepAlive(); got != 45*time.Second {
+ t.Errorf("KeepAlive-Default = %v, erwartet 45s (Spec)", got)
+ }
+ if got := h.writeTimeout(); got != 10*time.Second {
+ t.Errorf("WriteTimeout-Default = %v", got)
+ }
+ if got := h.bufferSize(); got != 16 {
+ t.Errorf("BufferSize-Default = %d", got)
+ }
+}
+
+func TestHub_DeliversEvent(t *testing.T) {
+ h := &Hub{}
+ srv, _ := hubServer(t, h)
+ resp := openStream(t, context.Background(), srv, "node-1")
+
+ if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
+ t.Errorf("Content-Type = %q", ct)
+ }
+ if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
+ t.Errorf("Cache-Control = %q", cc)
+ }
+ eventually(t, "node-1 verbunden", func() bool { return h.Connected("node-1") })
+
+ if err := h.Send("node-1", Event{Type: EventNodeCommand, Payload: json.RawMessage(`{"action":"drain"}`)}); err != nil {
+ t.Fatalf("Send: %v", err)
+ }
+ ev, err := NewDecoder(resp.Body).Next()
+ if err != nil {
+ t.Fatalf("Next: %v", err)
+ }
+ if ev.Type != EventNodeCommand || ev.NodeID != "node-1" || string(ev.Payload) != `{"action":"drain"}` {
+ t.Errorf("unerwartetes Event: %+v", ev)
+ }
+ if !strings.HasPrefix(ev.ID, "evt_") || ev.Timestamp == 0 {
+ t.Errorf("ID/Timestamp nicht ergänzt: %+v", ev)
+ }
+}
+
+func TestHub_SendKeepsIDAndTimestamp(t *testing.T) {
+ h := &Hub{}
+ srv, _ := hubServer(t, h)
+ resp := openStream(t, context.Background(), srv, "n")
+ eventually(t, "verbunden", func() bool { return h.Connected("n") })
+
+ want := Event{ID: "evt_fix", Timestamp: 42, Type: EventLeaseRenew, NodeID: "n"}
+ if err := h.Send("n", Event{ID: "evt_fix", Timestamp: 42, Type: EventLeaseRenew, NodeID: "anderer"}); err != nil {
+ t.Fatal(err)
+ }
+ got, err := NewDecoder(resp.Body).Next()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("got %+v, erwartet %+v (NodeID wird immer vom Hub gesetzt)", got, want)
+ }
+}
+
+func TestHub_SendInvalid(t *testing.T) {
+ h := &Hub{}
+ srv, _ := hubServer(t, h)
+ openStream(t, context.Background(), srv, "n")
+ eventually(t, "verbunden", func() bool { return h.Connected("n") })
+
+ if err := h.Send("n", Event{}); !errors.Is(err, ErrInvalidEvent) {
+ t.Errorf("leerer Typ: erwartet ErrInvalidEvent, bekommen %v", err)
+ }
+ if err := h.Send("n", Event{Type: EventError, Payload: json.RawMessage(`{`)}); !errors.Is(err, ErrInvalidEvent) {
+ t.Errorf("kaputte Payload: erwartet ErrInvalidEvent, bekommen %v", err)
+ }
+}
+
+func TestHub_KeepAlive(t *testing.T) {
+ h := &Hub{KeepAlive: 10 * time.Millisecond}
+ srv, _ := hubServer(t, h)
+ resp := openStream(t, context.Background(), srv, "n")
+
+ buf := make([]byte, 6)
+ if _, err := io.ReadFull(resp.Body, buf); err != nil {
+ t.Fatalf("lesen: %v", err)
+ }
+ if string(buf) != ":\n\n:\n\n" {
+ t.Errorf("erwartet zwei Keepalives \":\\n\\n\", bekommen %q", buf)
+ }
+}
+
+func TestHub_DisconnectDetected(t *testing.T) {
+ var mu sync.Mutex
+ var connects, disconnects []string
+ h := &Hub{
+ OnConnect: func(n string) { mu.Lock(); connects = append(connects, n); mu.Unlock() },
+ OnDisconnect: func(n string) { mu.Lock(); disconnects = append(disconnects, n); mu.Unlock() },
+ }
+ srv, errs := hubServer(t, h)
+ ctx, cancel := context.WithCancel(context.Background())
+ openStream(t, ctx, srv, "node-1")
+ eventually(t, "verbunden", func() bool { return h.Connected("node-1") })
+
+ cancel()
+ if err := waitErr(t, errs); !errors.Is(err, context.Canceled) {
+ t.Errorf("Serve: erwartet context.Canceled, bekommen %v", err)
+ }
+ if h.Connected("node-1") {
+ t.Error("node-1 noch verbunden")
+ }
+ if err := h.Send("node-1", Event{Type: EventError}); !errors.Is(err, ErrNodeOffline) {
+ t.Errorf("erwartet ErrNodeOffline, bekommen %v", err)
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if !reflect.DeepEqual(connects, []string{"node-1"}) || !reflect.DeepEqual(disconnects, []string{"node-1"}) {
+ t.Errorf("Callbacks: connects=%v disconnects=%v", connects, disconnects)
+ }
+}
+
+func TestHub_ReconnectReplacesOld(t *testing.T) {
+ var connects, disconnects atomic.Int32
+ h := &Hub{
+ OnConnect: func(string) { connects.Add(1) },
+ OnDisconnect: func(string) { disconnects.Add(1) },
+ }
+ srv, errs := hubServer(t, h)
+ old := openStream(t, context.Background(), srv, "n")
+ eventually(t, "erste Verbindung", func() bool { return connects.Load() == 1 })
+ fresh := openStream(t, context.Background(), srv, "n")
+
+ if err := waitErr(t, errs); !errors.Is(err, ErrReplaced) {
+ t.Errorf("alte Verbindung: erwartet ErrReplaced, bekommen %v", err)
+ }
+ if _, err := io.ReadAll(old.Body); err != nil {
+ t.Errorf("alter Stream sollte sauber enden: %v", err)
+ }
+ if err := h.Send("n", Event{Type: EventLeaseRenew}); err != nil {
+ t.Fatal(err)
+ }
+ ev, err := NewDecoder(fresh.Body).Next()
+ if err != nil || ev.Type != EventLeaseRenew {
+ t.Errorf("neue Verbindung: ev=%+v err=%v", ev, err)
+ }
+ if c, d := connects.Load(), disconnects.Load(); c != 2 || d != 0 {
+ t.Errorf("Callbacks: connects=%d disconnects=%d, erwartet 2/0 (Ersatz ist kein Offline)", c, d)
+ }
+}
+
+func TestHub_Nodes(t *testing.T) {
+ h := &Hub{}
+ srv, _ := hubServer(t, h)
+ openStream(t, context.Background(), srv, "b")
+ openStream(t, context.Background(), srv, "a")
+ eventually(t, "beide verbunden", func() bool { return len(h.Nodes()) == 2 })
+ if got := h.Nodes(); !reflect.DeepEqual(got, []string{"a", "b"}) {
+ t.Errorf("Nodes = %v, erwartet sortiert [a b]", got)
+ }
+}
+
+func TestHub_Close(t *testing.T) {
+ h := &Hub{}
+ srv, errs := hubServer(t, h)
+ resp := openStream(t, context.Background(), srv, "n")
+ eventually(t, "verbunden", func() bool { return h.Connected("n") })
+
+ h.Close()
+ if err := waitErr(t, errs); !errors.Is(err, ErrHubClosed) {
+ t.Errorf("Serve: erwartet ErrHubClosed, bekommen %v", err)
+ }
+ if _, err := io.ReadAll(resp.Body); err != nil {
+ t.Errorf("Stream sollte sauber enden: %v", err)
+ }
+ if err := h.Send("n", Event{Type: EventError}); !errors.Is(err, ErrHubClosed) {
+ t.Errorf("Send nach Close: erwartet ErrHubClosed, bekommen %v", err)
+ }
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ if err := h.Serve(httptest.NewRecorder(), req, "neu"); !errors.Is(err, ErrHubClosed) {
+ t.Errorf("Serve nach Close: erwartet ErrHubClosed, bekommen %v", err)
+ }
+ h.Close() // zweimal schließen darf nicht paniken
+}
+
+// blockingWriter simuliert einen Client, der nicht mehr liest.
+type blockingWriter struct {
+ header http.Header
+ release chan struct{}
+}
+
+func (w *blockingWriter) Header() http.Header { return w.header }
+func (w *blockingWriter) WriteHeader(int) {}
+func (w *blockingWriter) Flush() {}
+func (w *blockingWriter) Write(p []byte) (int, error) { <-w.release; return len(p), nil }
+
+func TestHub_BufferFull(t *testing.T) {
+ h := &Hub{BufferSize: 2}
+ w := &blockingWriter{header: http.Header{}, release: make(chan struct{})}
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- h.Serve(w, httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx), "n") }()
+ eventually(t, "verbunden", func() bool { return h.Connected("n") })
+
+ var full bool
+ for range 4 {
+ if err := h.Send("n", Event{Type: EventError}); errors.Is(err, ErrBufferFull) {
+ full = true
+ }
+ }
+ if !full {
+ t.Error("erwartet ErrBufferFull bei vollem Puffer")
+ }
+ cancel()
+ close(w.release)
+ if err := waitErr(t, done); !errors.Is(err, context.Canceled) {
+ t.Errorf("Serve: erwartet context.Canceled, bekommen %v", err)
+ }
+}
+
+type failingWriter struct{ header http.Header }
+
+var errWrite = errors.New("Verbindung weg")
+
+func (w *failingWriter) Header() http.Header { return w.header }
+func (w *failingWriter) WriteHeader(int) {}
+func (w *failingWriter) Flush() {}
+func (w *failingWriter) Write([]byte) (int, error) { return 0, errWrite }
+
+func TestHub_WriteErrorEndsServe(t *testing.T) {
+ h := &Hub{KeepAlive: 5 * time.Millisecond}
+ err := h.Serve(&failingWriter{header: http.Header{}}, httptest.NewRequest(http.MethodGet, "/", nil), "n")
+ if !errors.Is(err, errWrite) {
+ t.Errorf("erwartet Schreibfehler, bekommen %v", err)
+ }
+ if h.Connected("n") {
+ t.Error("Knoten nach Schreibfehler noch verbunden")
+ }
+}
+
+type noFlushWriter struct{ header http.Header }
+
+func (w *noFlushWriter) Header() http.Header { return w.header }
+func (w *noFlushWriter) WriteHeader(int) {}
+func (w *noFlushWriter) Write(p []byte) (int, error) { return len(p), nil }
+
+func TestHub_StreamingUnsupported(t *testing.T) {
+ var h Hub
+ err := h.Serve(&noFlushWriter{header: http.Header{}}, httptest.NewRequest(http.MethodGet, "/", nil), "n")
+ if !errors.Is(err, ErrStreamingUnsupported) {
+ t.Errorf("erwartet ErrStreamingUnsupported, bekommen %v", err)
+ }
+ if h.Connected("n") {
+ t.Error("Knoten darf nicht registriert sein")
+ }
+}
+
+func TestHub_ConcurrentSend(t *testing.T) {
+ const senders, perSender = 20, 50
+ h := &Hub{}
+ srv, _ := hubServer(t, h)
+ resp := openStream(t, context.Background(), srv, "n")
+ eventually(t, "verbunden", func() bool { return h.Connected("n") })
+
+ var wg sync.WaitGroup
+ for range senders {
+ wg.Go(func() {
+ for range perSender {
+ for {
+ err := h.Send("n", Event{Type: EventServiceState})
+ if err == nil {
+ break
+ }
+ if !errors.Is(err, ErrBufferFull) {
+ t.Errorf("Send: %v", err)
+ return
+ }
+ time.Sleep(time.Millisecond)
+ }
+ }
+ })
+ }
+ dec := NewDecoder(resp.Body)
+ for i := range senders * perSender {
+ if _, err := dec.Next(); err != nil {
+ t.Fatalf("Event %d: %v", i, err)
+ }
+ }
+ wg.Wait()
+}
+
+func TestHub_ServeRejectsEmptyNodeID(t *testing.T) {
+ var connects atomic.Int32
+ h := &Hub{OnConnect: func(string) { connects.Add(1) }}
+ rec := httptest.NewRecorder()
+ err := h.Serve(rec, httptest.NewRequest(http.MethodGet, "/", nil), "")
+ if !errors.Is(err, ErrInvalidNodeID) {
+ t.Errorf("erwartet ErrInvalidNodeID, bekommen %v", err)
+ }
+ if rec.Body.Len() != 0 || rec.Header().Get("Content-Type") == "text/event-stream" {
+ t.Error("bei ungültiger ID darf kein Stream begonnen werden")
+ }
+ if connects.Load() != 0 || len(h.Nodes()) != 0 {
+ t.Error("leere ID wurde registriert")
+ }
+}
+
+func TestHub_Disconnect(t *testing.T) {
+ var disconnects atomic.Int32
+ h := &Hub{OnDisconnect: func(string) { disconnects.Add(1) }}
+ srv, errs := hubServer(t, h)
+ resp := openStream(t, context.Background(), srv, "n")
+ eventually(t, "verbunden", func() bool { return h.Connected("n") })
+
+ if err := h.Disconnect("n"); err != nil {
+ t.Fatalf("Disconnect: %v", err)
+ }
+ // Sofort wirksam, nicht erst wenn Serve zurückkehrt:
+ if h.Connected("n") {
+ t.Error("Knoten nach Disconnect noch verbunden")
+ }
+ if err := h.Send("n", Event{Type: EventError}); !errors.Is(err, ErrNodeOffline) {
+ t.Errorf("Send nach Disconnect: erwartet ErrNodeOffline, bekommen %v", err)
+ }
+ if err := waitErr(t, errs); !errors.Is(err, ErrDisconnected) {
+ t.Errorf("Serve: erwartet ErrDisconnected, bekommen %v", err)
+ }
+ if _, err := io.ReadAll(resp.Body); err != nil {
+ t.Errorf("Stream sollte sauber enden: %v", err)
+ }
+ if n := disconnects.Load(); n != 1 {
+ t.Errorf("OnDisconnect %d-mal aufgerufen, erwartet 1", n)
+ }
+ if err := h.Disconnect("n"); !errors.Is(err, ErrNodeOffline) {
+ t.Errorf("zweites Disconnect: erwartet ErrNodeOffline, bekommen %v", err)
+ }
+}
diff --git a/pkg/traefik/config.go b/pkg/traefik/config.go
new file mode 100644
index 0000000..4cdc434
--- /dev/null
+++ b/pkg/traefik/config.go
@@ -0,0 +1,125 @@
+// Package traefik erzeugt die dynamische Traefik-Konfiguration, die der
+// Sultan-Server über den HTTP-Provider (GET /v1/traefik-config) ausliefert.
+//
+// Die Typen bilden die Teilmenge von Traefiks "dynamic configuration" ab,
+// die Sultan aus Docker-Labels bzw. /etc/sultan.d-Dateien erzeugt. Die
+// Ausgabe ist JSON oder YAML; Traefiks HTTP-Provider akzeptiert beides.
+package traefik
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+// Configuration ist die Wurzel der dynamischen Traefik-Konfiguration.
+type Configuration struct {
+ HTTP *HTTPConfiguration `json:"http,omitempty" yaml:"http,omitempty"`
+}
+
+// HTTPConfiguration enthält Router, Services und Middlewares der HTTP-Ebene.
+type HTTPConfiguration struct {
+ Routers map[string]*Router `json:"routers,omitempty" yaml:"routers,omitempty"`
+ Services map[string]*Service `json:"services,omitempty" yaml:"services,omitempty"`
+ Middlewares map[string]Middleware `json:"middlewares,omitempty" yaml:"middlewares,omitempty"`
+}
+
+// Router entspricht einem Traefik-HTTP-Router.
+type Router struct {
+ EntryPoints []string `json:"entryPoints,omitempty" yaml:"entryPoints,omitempty"`
+ Middlewares []string `json:"middlewares,omitempty" yaml:"middlewares,omitempty"`
+ Service string `json:"service" yaml:"service"`
+ Rule string `json:"rule" yaml:"rule"`
+ Priority int `json:"priority,omitempty" yaml:"priority,omitempty"`
+ TLS *RouterTLS `json:"tls,omitempty" yaml:"tls,omitempty"`
+}
+
+// RouterTLS aktiviert TLS für einen Router. Ein leeres, nicht-nil RouterTLS
+// wird als "tls: {}" gerendert und schaltet TLS mit Standardwerten ein.
+type RouterTLS struct {
+ Options string `json:"options,omitempty" yaml:"options,omitempty"`
+ CertResolver string `json:"certResolver,omitempty" yaml:"certResolver,omitempty"`
+}
+
+// Service entspricht einem Traefik-HTTP-Service.
+type Service struct {
+ LoadBalancer *ServersLoadBalancer `json:"loadBalancer,omitempty" yaml:"loadBalancer,omitempty"`
+}
+
+// ServersLoadBalancer verteilt Anfragen auf die angegebenen Server.
+type ServersLoadBalancer struct {
+ Servers []Server `json:"servers" yaml:"servers"`
+ PassHostHeader *bool `json:"passHostHeader,omitempty" yaml:"passHostHeader,omitempty"`
+}
+
+// Server ist ein einzelnes Backend-Ziel.
+type Server struct {
+ URL string `json:"url" yaml:"url"`
+}
+
+// Middleware ist eine Traefik-Middleware als verschachtelte Map, z. B.
+// {"redirectRegex": {"regex": "...", "permanent": "true"}}.
+//
+// Die Blattwerte bleiben die unveränderten Label-Strings. Traefik wandelt sie
+// beim Einlesen genauso in Bool-, Zahl- und Listenwerte um wie Labels
+// (Listen kommagetrennt), deshalb braucht Sultan nicht jeden Middleware-Typ zu
+// kennen.
+type Middleware map[string]any
+
+// Format ist das Ausgabeformat der Konfiguration.
+type Format string
+
+const (
+ FormatJSON Format = "json"
+ FormatYAML Format = "yaml"
+)
+
+// ParseFormat wandelt z. B. einen Query-Parameter in ein Format um.
+// Ein leerer String ergibt FormatJSON.
+func ParseFormat(s string) (Format, error) {
+ switch strings.ToLower(strings.TrimSpace(s)) {
+ case "", "json":
+ return FormatJSON, nil
+ case "yaml", "yml":
+ return FormatYAML, nil
+ default:
+ return "", fmt.Errorf("traefik: unbekanntes Format %q", s)
+ }
+}
+
+// ContentType liefert den passenden HTTP-Content-Type.
+func (f Format) ContentType() string {
+ if f == FormatYAML {
+ return "application/yaml"
+ }
+ return "application/json"
+}
+
+// Marshal rendert die Konfiguration im angegebenen Format. Eine nil-
+// Konfiguration ergibt ein leeres Objekt, das Traefik als "keine Routen"
+// akzeptiert.
+func (c *Configuration) Marshal(f Format) ([]byte, error) {
+ if c == nil {
+ c = &Configuration{}
+ }
+ switch f {
+ case FormatJSON:
+ return json.Marshal(c)
+ case FormatYAML:
+ var buf bytes.Buffer
+ enc := yaml.NewEncoder(&buf)
+ enc.SetIndent(2)
+ if err := enc.Encode(c); err != nil {
+ return nil, err
+ }
+ if err := enc.Close(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+ default:
+ return nil, fmt.Errorf("traefik: unbekanntes Format %q", f)
+ }
+}
diff --git a/pkg/traefik/config_test.go b/pkg/traefik/config_test.go
new file mode 100644
index 0000000..ed0d416
--- /dev/null
+++ b/pkg/traefik/config_test.go
@@ -0,0 +1,146 @@
+package traefik
+
+import (
+ "encoding/json"
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+func mustJSON(t *testing.T, v any) string {
+ t.Helper()
+ b, err := json.Marshal(v)
+ if err != nil {
+ t.Fatalf("json.Marshal: %v", err)
+ }
+ return string(b)
+}
+
+func sampleConfig() *Configuration {
+ return &Configuration{HTTP: &HTTPConfiguration{
+ Routers: map[string]*Router{
+ "r": {EntryPoints: []string{"web"}, Service: "s", Rule: "Host(`a.de`)", TLS: &RouterTLS{}},
+ },
+ Services: map[string]*Service{
+ "s": {LoadBalancer: &ServersLoadBalancer{Servers: []Server{{URL: "http://10.0.0.1:80"}}}},
+ },
+ }}
+}
+
+func TestMarshal_JSON(t *testing.T) {
+ got, err := sampleConfig().Marshal(FormatJSON)
+ if err != nil {
+ t.Fatalf("unerwarteter Fehler: %v", err)
+ }
+ want := `{"http":{"routers":{"r":{"entryPoints":["web"],"service":"s","rule":"Host(` + "`a.de`" + `)","tls":{}}},` +
+ `"services":{"s":{"loadBalancer":{"servers":[{"url":"http://10.0.0.1:80"}]}}}}}`
+ if string(got) != want {
+ t.Errorf("JSON weicht ab\n got: %s\nwant: %s", got, want)
+ }
+}
+
+func TestMarshal_YAML(t *testing.T) {
+ got, err := sampleConfig().Marshal(FormatYAML)
+ if err != nil {
+ t.Fatalf("unerwarteter Fehler: %v", err)
+ }
+ want := "http:\n" +
+ " routers:\n" +
+ " r:\n" +
+ " entryPoints:\n" +
+ " - web\n" +
+ " service: s\n" +
+ " rule: Host(`a.de`)\n" +
+ " tls: {}\n" +
+ " services:\n" +
+ " s:\n" +
+ " loadBalancer:\n" +
+ " servers:\n" +
+ " - url: http://10.0.0.1:80\n"
+ if string(got) != want {
+ t.Errorf("YAML weicht ab\n got:\n%s\nwant:\n%s", got, want)
+ }
+}
+
+func TestMarshal_Empty(t *testing.T) {
+ for _, c := range []*Configuration{nil, {}} {
+ j, err := c.Marshal(FormatJSON)
+ if err != nil || string(j) != "{}" {
+ t.Errorf("JSON = %q, %v; erwartet {}", j, err)
+ }
+ y, err := c.Marshal(FormatYAML)
+ if err != nil || string(y) != "{}\n" {
+ t.Errorf("YAML = %q, %v; erwartet {}\\n", y, err)
+ }
+ }
+}
+
+func TestMarshal_UnknownFormat(t *testing.T) {
+ if _, err := sampleConfig().Marshal("toml"); err == nil {
+ t.Error("erwartet Fehler für unbekanntes Format")
+ }
+}
+
+// Die Ausgabe muss sich verlustfrei zurücklesen lassen – inklusive der
+// verschachtelten Middleware-Maps und der optionalen Felder.
+func TestMarshal_RoundTrip(t *testing.T) {
+ orig, err := FromLabels("100.64.0.42", wordpressLabels())
+ if err != nil {
+ t.Fatalf("FromLabels: %v", err)
+ }
+ orig.HTTP.Services["wp"].LoadBalancer.PassHostHeader = boolPtr(false)
+ orig.HTTP.Routers["wp"].Priority = 7
+
+ decoders := map[Format]func([]byte, any) error{
+ FormatJSON: json.Unmarshal,
+ FormatYAML: yaml.Unmarshal,
+ }
+ for f, decode := range decoders {
+ t.Run(string(f), func(t *testing.T) {
+ b, err := orig.Marshal(f)
+ if err != nil {
+ t.Fatalf("Marshal: %v", err)
+ }
+ var back Configuration
+ if err := decode(b, &back); err != nil {
+ t.Fatalf("Unmarshal: %v\n%s", err, b)
+ }
+ // Vergleich über JSON statt reflect.DeepEqual: yaml.v3 dekodiert
+ // verschachtelte Maps in den Typ der äußeren Map (Middleware statt
+ // map[string]any), inhaltlich ist das identisch.
+ if got, want := mustJSON(t, &back), mustJSON(t, orig); got != want {
+ t.Errorf("Round-Trip weicht ab\n got: %s\nwant: %s", got, want)
+ }
+ })
+ }
+}
+
+func TestParseFormat(t *testing.T) {
+ tests := []struct {
+ in string
+ want Format
+ wantErr bool
+ }{
+ {"", FormatJSON, false},
+ {"json", FormatJSON, false},
+ {" JSON ", FormatJSON, false},
+ {"yaml", FormatYAML, false},
+ {"YML", FormatYAML, false},
+ {"toml", "", true},
+ }
+ for _, tt := range tests {
+ got, err := ParseFormat(tt.in)
+ if (err != nil) != tt.wantErr || got != tt.want {
+ t.Errorf("ParseFormat(%q) = %q, %v; erwartet %q, Fehler=%v", tt.in, got, err, tt.want, tt.wantErr)
+ }
+ }
+}
+
+func TestFormat_ContentType(t *testing.T) {
+ if got := FormatJSON.ContentType(); got != "application/json" {
+ t.Errorf("JSON: %q", got)
+ }
+ if got := FormatYAML.ContentType(); got != "application/yaml" {
+ t.Errorf("YAML: %q", got)
+ }
+}
diff --git a/pkg/traefik/labels.go b/pkg/traefik/labels.go
new file mode 100644
index 0000000..6362a00
--- /dev/null
+++ b/pkg/traefik/labels.go
@@ -0,0 +1,303 @@
+package traefik
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+var (
+ // ErrUnsupportedLabel meldet ein Traefik-Label, das Sultan (noch) nicht übersetzt.
+ ErrUnsupportedLabel = errors.New("nicht unterstütztes Label")
+ // ErrInvalidLabel meldet ein Label mit ungültigem Wert.
+ ErrInvalidLabel = errors.New("ungültiges Label")
+ // ErrIncomplete meldet eine Konfiguration, der Pflichtangaben fehlen.
+ ErrIncomplete = errors.New("unvollständige Konfiguration")
+)
+
+const labelPrefix = "traefik."
+
+// FromLabels übersetzt die flachen Traefik-Labels eines Containers (bzw.
+// einer /etc/sultan.d-Datei) in eine verschachtelte Konfiguration.
+//
+// host ist die Adresse, unter der der Ingress das Backend erreicht (LAN- oder
+// NetBird-IP); sie wird zusammen mit loadbalancer.server.port in die
+// Server-URLs eingesetzt.
+//
+// Wie bei Traefik mit exposedByDefault=false wird nur übersetzt, wenn
+// traefik.enable=true gesetzt ist; sonst ist das Ergebnis leer. Label-Schlüssel
+// werden ohne Beachtung der Groß-/Kleinschreibung erkannt, Router-, Service-
+// und Middleware-Namen behalten ihre Schreibweise. traefik.docker.* wird
+// ignoriert. Alle gefundenen Fehler werden gesammelt per errors.Join
+// zurückgegeben.
+func FromLabels(host string, labels map[string]string) (*Configuration, error) {
+ enabled, err := isEnabled(labels)
+ if err != nil {
+ return nil, err
+ }
+ if !enabled {
+ return &Configuration{}, nil
+ }
+
+ b := builder{
+ routers: map[string]*Router{},
+ services: map[string]*serviceSpec{},
+ middlewares: map[string]Middleware{},
+ }
+
+ keys := make([]string, 0, len(labels))
+ for k := range labels {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+
+ for _, key := range keys {
+ if !strings.HasPrefix(strings.ToLower(key), labelPrefix) {
+ continue
+ }
+ if err := b.apply(key, labels[key]); err != nil {
+ b.errs = append(b.errs, fmt.Errorf("%s: %w", key, err))
+ }
+ }
+
+ cfg := b.build(host)
+ if len(b.errs) > 0 {
+ return nil, errors.Join(b.errs...)
+ }
+ return cfg, nil
+}
+
+func isEnabled(labels map[string]string) (bool, error) {
+ for k, v := range labels {
+ if strings.EqualFold(k, labelPrefix+"enable") {
+ enabled, err := strconv.ParseBool(strings.TrimSpace(v))
+ if err != nil {
+ return false, fmt.Errorf("%s: %w: %q ist kein Bool", k, ErrInvalidLabel, v)
+ }
+ return enabled, nil
+ }
+ }
+ return false, nil
+}
+
+type serviceSpec struct {
+ port string
+ scheme string
+ passHostHeader *bool
+}
+
+type builder struct {
+ routers map[string]*Router
+ services map[string]*serviceSpec
+ middlewares map[string]Middleware
+ errs []error
+}
+
+func (b *builder) apply(key, value string) error {
+ parts := strings.Split(key[len(labelPrefix):], ".")
+ switch strings.ToLower(parts[0]) {
+ case "enable", "docker":
+ return nil
+ case "http":
+ default:
+ return ErrUnsupportedLabel
+ }
+ if len(parts) < 4 {
+ return ErrUnsupportedLabel
+ }
+ kind, name, attr := strings.ToLower(parts[1]), parts[2], parts[3:]
+ if name == "" {
+ return fmt.Errorf("%w: leerer Name", ErrInvalidLabel)
+ }
+ switch kind {
+ case "routers":
+ r, ok := b.routers[name]
+ if !ok {
+ r = &Router{}
+ b.routers[name] = r
+ }
+ return setRouterAttr(r, strings.ToLower(strings.Join(attr, ".")), value)
+ case "services":
+ s, ok := b.services[name]
+ if !ok {
+ s = &serviceSpec{}
+ b.services[name] = s
+ }
+ return setServiceAttr(s, strings.ToLower(strings.Join(attr, ".")), value)
+ case "middlewares":
+ m, ok := b.middlewares[name]
+ if !ok {
+ m = Middleware{}
+ b.middlewares[name] = m
+ }
+ return setMiddlewareAttr(m, attr, value)
+ default:
+ return ErrUnsupportedLabel
+ }
+}
+
+func setRouterAttr(r *Router, attr, value string) error {
+ switch attr {
+ case "rule":
+ r.Rule = strings.TrimSpace(value)
+ case "entrypoints":
+ r.EntryPoints = splitList(value)
+ case "middlewares":
+ r.Middlewares = splitList(value)
+ case "service":
+ r.Service = strings.TrimSpace(value)
+ case "priority":
+ p, err := strconv.Atoi(strings.TrimSpace(value))
+ if err != nil {
+ return fmt.Errorf("%w: %q ist keine Zahl", ErrInvalidLabel, value)
+ }
+ r.Priority = p
+ case "tls":
+ on, err := strconv.ParseBool(strings.TrimSpace(value))
+ if err != nil {
+ return fmt.Errorf("%w: %q ist kein Bool", ErrInvalidLabel, value)
+ }
+ if on && r.TLS == nil {
+ r.TLS = &RouterTLS{}
+ }
+ case "tls.certresolver":
+ ensureTLS(r).CertResolver = strings.TrimSpace(value)
+ case "tls.options":
+ ensureTLS(r).Options = strings.TrimSpace(value)
+ default:
+ return ErrUnsupportedLabel
+ }
+ return nil
+}
+
+func ensureTLS(r *Router) *RouterTLS {
+ if r.TLS == nil {
+ r.TLS = &RouterTLS{}
+ }
+ return r.TLS
+}
+
+func setServiceAttr(s *serviceSpec, attr, value string) error {
+ value = strings.TrimSpace(value)
+ switch attr {
+ case "loadbalancer.server.port":
+ p, err := strconv.Atoi(value)
+ if err != nil || p < 1 || p > 65535 {
+ return fmt.Errorf("%w: %q ist kein gültiger Port", ErrInvalidLabel, value)
+ }
+ s.port = strconv.Itoa(p)
+ case "loadbalancer.server.scheme":
+ scheme := strings.ToLower(value)
+ switch scheme {
+ case "http", "https", "h2c":
+ s.scheme = scheme
+ default:
+ return fmt.Errorf("%w: Schema %q nicht erlaubt (http, https, h2c)", ErrInvalidLabel, value)
+ }
+ case "loadbalancer.passhostheader":
+ v, err := strconv.ParseBool(value)
+ if err != nil {
+ return fmt.Errorf("%w: %q ist kein Bool", ErrInvalidLabel, value)
+ }
+ s.passHostHeader = &v
+ default:
+ return ErrUnsupportedLabel
+ }
+ return nil
+}
+
+// setMiddlewareAttr legt value unter dem Pfad attr in m ab. attr[0] ist der
+// Middleware-Typ (z. B. redirectregex), der Rest die Optionen.
+func setMiddlewareAttr(m Middleware, attr []string, value string) error {
+ node := map[string]any(m)
+ for i, p := range attr {
+ if p == "" {
+ return fmt.Errorf("%w: leeres Pfadsegment", ErrInvalidLabel)
+ }
+ if i == len(attr)-1 {
+ if _, isMap := node[p].(map[string]any); isMap {
+ return fmt.Errorf("%w: %q ist bereits ein Objekt", ErrInvalidLabel, p)
+ }
+ node[p] = value
+ return nil
+ }
+ switch child := node[p].(type) {
+ case nil:
+ next := map[string]any{}
+ node[p] = next
+ node = next
+ case map[string]any:
+ node = child
+ default:
+ return fmt.Errorf("%w: %q ist bereits ein Wert", ErrInvalidLabel, p)
+ }
+ }
+ return nil
+}
+
+func (b *builder) build(host string) *Configuration {
+ if len(b.routers) == 0 && len(b.services) == 0 && len(b.middlewares) == 0 {
+ return &Configuration{}
+ }
+ http := &HTTPConfiguration{}
+
+ if len(b.services) > 0 {
+ if strings.TrimSpace(host) == "" {
+ b.errs = append(b.errs, fmt.Errorf("%w: keine Backend-Adresse (host) angegeben", ErrIncomplete))
+ }
+ http.Services = map[string]*Service{}
+ for name, s := range b.services {
+ if s.port == "" {
+ b.errs = append(b.errs, fmt.Errorf("service %q: %w: loadbalancer.server.port fehlt", name, ErrIncomplete))
+ continue
+ }
+ scheme := s.scheme
+ if scheme == "" {
+ scheme = "http"
+ }
+ http.Services[name] = &Service{LoadBalancer: &ServersLoadBalancer{
+ Servers: []Server{{URL: scheme + "://" + net.JoinHostPort(host, s.port)}},
+ PassHostHeader: s.passHostHeader,
+ }}
+ }
+ }
+
+ if len(b.routers) > 0 {
+ http.Routers = b.routers
+ for name, r := range b.routers {
+ if r.Rule == "" {
+ b.errs = append(b.errs, fmt.Errorf("router %q: %w: rule fehlt", name, ErrIncomplete))
+ }
+ if r.Service != "" {
+ continue
+ }
+ // Wie Traefiks Docker-Provider: ohne explizite Angabe wird der
+ // Router an den einzigen Service des Containers gebunden.
+ if len(b.services) != 1 {
+ b.errs = append(b.errs, fmt.Errorf("router %q: %w: service fehlt und ist bei %d Services nicht eindeutig", name, ErrIncomplete, len(b.services)))
+ continue
+ }
+ for svc := range b.services {
+ r.Service = svc
+ }
+ }
+ }
+
+ if len(b.middlewares) > 0 {
+ http.Middlewares = b.middlewares
+ }
+ return &Configuration{HTTP: http}
+}
+
+func splitList(value string) []string {
+ var out []string
+ for _, v := range strings.Split(value, ",") {
+ if v = strings.TrimSpace(v); v != "" {
+ out = append(out, v)
+ }
+ }
+ return out
+}
diff --git a/pkg/traefik/labels_test.go b/pkg/traefik/labels_test.go
new file mode 100644
index 0000000..87f26b0
--- /dev/null
+++ b/pkg/traefik/labels_test.go
@@ -0,0 +1,360 @@
+package traefik
+
+import (
+ "errors"
+ "reflect"
+ "testing"
+)
+
+func boolPtr(b bool) *bool { return &b }
+
+// Labels aus dem Compose-Beispiel der Architektur-Spec (Abschnitt 7.2).
+func wordpressLabels() map[string]string {
+ return map[string]string{
+ "traefik.enable": "true",
+ "traefik.http.routers.wp.rule": "Host(`kunde.de`) || Host(`www.kunde.de`)",
+ "traefik.http.routers.wp.entrypoints": "websecure",
+ "traefik.http.routers.wp.tls": "true",
+ "traefik.http.routers.wp.tls.certresolver": "dns-resolver",
+ "traefik.http.middlewares.wp-www.redirectregex.regex": `^https?://kunde\.de/(.*)`,
+ "traefik.http.middlewares.wp-www.redirectregex.replacement": "https://www.kunde.de/${1}",
+ "traefik.http.middlewares.wp-www.redirectregex.permanent": "true",
+ "traefik.http.routers.wp.middlewares": "wp-www",
+ "traefik.http.services.wp.loadbalancer.server.port": "80",
+ "com.docker.compose.project": "kunde",
+ }
+}
+
+func TestFromLabels_SpecExample(t *testing.T) {
+ got, err := FromLabels("100.64.0.42", wordpressLabels())
+ if err != nil {
+ t.Fatalf("unerwarteter Fehler: %v", err)
+ }
+ want := &Configuration{HTTP: &HTTPConfiguration{
+ Routers: map[string]*Router{
+ "wp": {
+ EntryPoints: []string{"websecure"},
+ Middlewares: []string{"wp-www"},
+ Service: "wp",
+ Rule: "Host(`kunde.de`) || Host(`www.kunde.de`)",
+ TLS: &RouterTLS{CertResolver: "dns-resolver"},
+ },
+ },
+ Services: map[string]*Service{
+ "wp": {LoadBalancer: &ServersLoadBalancer{Servers: []Server{{URL: "http://100.64.0.42:80"}}}},
+ },
+ Middlewares: map[string]Middleware{
+ "wp-www": {"redirectregex": map[string]any{
+ "regex": `^https?://kunde\.de/(.*)`,
+ "replacement": "https://www.kunde.de/${1}",
+ "permanent": "true",
+ }},
+ },
+ }}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("Konfiguration weicht ab\n got: %s\nwant: %s", mustJSON(t, got), mustJSON(t, want))
+ }
+}
+
+func TestFromLabels_Enable(t *testing.T) {
+ tests := []struct {
+ name string
+ labels map[string]string
+ wantErr bool
+ }{
+ {"fehlt", map[string]string{"traefik.http.routers.a.rule": "Host(`a.de`)"}, false},
+ {"false", map[string]string{"traefik.enable": "false", "traefik.http.routers.a.rule": "x"}, false},
+ {"leere Labels", nil, false},
+ {"ungültig", map[string]string{"traefik.enable": "jein"}, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := FromLabels("10.0.0.1", tt.labels)
+ if tt.wantErr {
+ if !errors.Is(err, ErrInvalidLabel) {
+ t.Fatalf("erwartet ErrInvalidLabel, bekommen %v", err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unerwarteter Fehler: %v", err)
+ }
+ if got == nil || got.HTTP != nil {
+ t.Errorf("erwartet leere Konfiguration, bekommen %+v", got)
+ }
+ })
+ }
+}
+
+func TestFromLabels_EnabledWithoutHTTPLabels(t *testing.T) {
+ got, err := FromLabels("10.0.0.1", map[string]string{
+ "traefik.enable": "true",
+ "traefik.docker.network": "proxy",
+ })
+ if err != nil {
+ t.Fatalf("unerwarteter Fehler: %v", err)
+ }
+ if got.HTTP != nil {
+ t.Errorf("erwartet leere Konfiguration, bekommen %+v", got.HTTP)
+ }
+}
+
+func TestFromLabels_CaseInsensitiveKeys(t *testing.T) {
+ got, err := FromLabels("10.0.0.1", map[string]string{
+ "Traefik.Enable": "TRUE",
+ "TRAEFIK.HTTP.Routers.MyApp.Rule": "Host(`a.de`)",
+ "traefik.http.SERVICES.MyApp.LoadBalancer.Server.Port": "8080",
+ })
+ if err != nil {
+ t.Fatalf("unerwarteter Fehler: %v", err)
+ }
+ r, ok := got.HTTP.Routers["MyApp"]
+ if !ok {
+ t.Fatalf("Router MyApp fehlt (Name muss Schreibweise behalten): %+v", got.HTTP.Routers)
+ }
+ if r.Service != "MyApp" {
+ t.Errorf("Service = %q, erwartet MyApp", r.Service)
+ }
+ if u := got.HTTP.Services["MyApp"].LoadBalancer.Servers[0].URL; u != "http://10.0.0.1:8080" {
+ t.Errorf("URL = %q", u)
+ }
+}
+
+func TestFromLabels_RouterAttributes(t *testing.T) {
+ got, err := FromLabels("10.0.0.1", map[string]string{
+ "traefik.enable": "true",
+ "traefik.http.routers.r.rule": " Host(`a.de`) ",
+ "traefik.http.routers.r.entrypoints": "web, websecure ,,",
+ "traefik.http.routers.r.middlewares": "auth@file,compress",
+ "traefik.http.routers.r.priority": " 42 ",
+ "traefik.http.routers.r.service": "api@internal",
+ "traefik.http.routers.r.tls.options": "modern@file",
+ "traefik.http.routers.plain.rule": "Host(`b.de`)",
+ "traefik.http.routers.plain.service": "noop@internal",
+ "traefik.http.routers.plain.tls": "false",
+ "traefik.http.routers.tlsonly.rule": "Host(`c.de`)",
+ "traefik.http.routers.tlsonly.service": "noop@internal",
+ "traefik.http.routers.tlsonly.tls": "true",
+ })
+ if err != nil {
+ t.Fatalf("unerwarteter Fehler: %v", err)
+ }
+ want := map[string]*Router{
+ "r": {
+ EntryPoints: []string{"web", "websecure"},
+ Middlewares: []string{"auth@file", "compress"},
+ Service: "api@internal",
+ Rule: "Host(`a.de`)",
+ Priority: 42,
+ TLS: &RouterTLS{Options: "modern@file"},
+ },
+ "plain": {Service: "noop@internal", Rule: "Host(`b.de`)"},
+ "tlsonly": {Service: "noop@internal", Rule: "Host(`c.de`)", TLS: &RouterTLS{}},
+ }
+ if !reflect.DeepEqual(got.HTTP.Routers, want) {
+ t.Errorf("Router weichen ab\n got: %s\nwant: %s", mustJSON(t, got.HTTP.Routers), mustJSON(t, want))
+ }
+ if got.HTTP.Services != nil {
+ t.Errorf("keine Services erwartet, bekommen %+v", got.HTTP.Services)
+ }
+}
+
+func TestFromLabels_ServiceAttributes(t *testing.T) {
+ tests := []struct {
+ name string
+ host string
+ labels map[string]string
+ want ServersLoadBalancer
+ }{
+ {
+ name: "Standardschema http",
+ host: "192.168.1.10",
+ labels: map[string]string{"traefik.http.services.s.loadbalancer.server.port": "80"},
+ want: ServersLoadBalancer{Servers: []Server{{URL: "http://192.168.1.10:80"}}},
+ },
+ {
+ name: "https und passHostHeader",
+ host: "100.64.1.2",
+ labels: map[string]string{
+ "traefik.http.services.s.loadbalancer.server.port": "8443",
+ "traefik.http.services.s.loadbalancer.server.scheme": "HTTPS",
+ "traefik.http.services.s.loadbalancer.passhostheader": "false",
+ },
+ want: ServersLoadBalancer{
+ Servers: []Server{{URL: "https://100.64.1.2:8443"}},
+ PassHostHeader: boolPtr(false),
+ },
+ },
+ {
+ name: "h2c",
+ host: "backend.lan",
+ labels: map[string]string{
+ "traefik.http.services.s.loadbalancer.server.port": "9000",
+ "traefik.http.services.s.loadbalancer.server.scheme": "h2c",
+ },
+ want: ServersLoadBalancer{Servers: []Server{{URL: "h2c://backend.lan:9000"}}},
+ },
+ {
+ name: "IPv6 in Klammern",
+ host: "fd00::1",
+ labels: map[string]string{"traefik.http.services.s.loadbalancer.server.port": "80"},
+ want: ServersLoadBalancer{Servers: []Server{{URL: "http://[fd00::1]:80"}}},
+ },
+ {
+ name: "Port mit führender Null normalisiert",
+ host: "10.0.0.1",
+ labels: map[string]string{"traefik.http.services.s.loadbalancer.server.port": "080"},
+ want: ServersLoadBalancer{Servers: []Server{{URL: "http://10.0.0.1:80"}}},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tt.labels["traefik.enable"] = "true"
+ got, err := FromLabels(tt.host, tt.labels)
+ if err != nil {
+ t.Fatalf("unerwarteter Fehler: %v", err)
+ }
+ if lb := got.HTTP.Services["s"].LoadBalancer; !reflect.DeepEqual(*lb, tt.want) {
+ t.Errorf("LoadBalancer = %+v, erwartet %+v", *lb, tt.want)
+ }
+ })
+ }
+}
+
+func TestFromLabels_DefaultServiceBinding(t *testing.T) {
+ got, err := FromLabels("10.0.0.1", map[string]string{
+ "traefik.enable": "true",
+ "traefik.http.routers.a.rule": "Host(`a.de`)",
+ "traefik.http.routers.b.rule": "Host(`b.de`)",
+ "traefik.http.services.app.loadbalancer.server.port": "3000",
+ })
+ if err != nil {
+ t.Fatalf("unerwarteter Fehler: %v", err)
+ }
+ for _, name := range []string{"a", "b"} {
+ if s := got.HTTP.Routers[name].Service; s != "app" {
+ t.Errorf("Router %s: Service = %q, erwartet app", name, s)
+ }
+ }
+}
+
+func TestFromLabels_Middlewares(t *testing.T) {
+ got, err := FromLabels("", map[string]string{
+ "traefik.enable": "true",
+ "traefik.http.middlewares.gzip.compress": "true",
+ "traefik.http.middlewares.hdr.headers.customrequestheaders.X-Forwarded-Proto": "https",
+ "traefik.http.middlewares.hdr.headers.customrequestheaders.X-Tenant": "kunde",
+ "traefik.http.middlewares.hdr.headers.stsseconds": "31536000",
+ "traefik.http.middlewares.allow.ipallowlist.sourcerange": "10.0.0.0/8, 192.168.0.0/16",
+ })
+ if err != nil {
+ t.Fatalf("unerwarteter Fehler: %v", err)
+ }
+ want := map[string]Middleware{
+ "gzip": {"compress": "true"},
+ "hdr": {"headers": map[string]any{
+ "customrequestheaders": map[string]any{
+ "X-Forwarded-Proto": "https",
+ "X-Tenant": "kunde",
+ },
+ "stsseconds": "31536000",
+ }},
+ "allow": {"ipallowlist": map[string]any{"sourcerange": "10.0.0.0/8, 192.168.0.0/16"}},
+ }
+ if !reflect.DeepEqual(got.HTTP.Middlewares, want) {
+ t.Errorf("Middlewares weichen ab\n got: %s\nwant: %s", mustJSON(t, got.HTTP.Middlewares), mustJSON(t, want))
+ }
+ if got.HTTP.Routers != nil || got.HTTP.Services != nil {
+ t.Errorf("nur Middlewares erwartet, bekommen %+v", got.HTTP)
+ }
+}
+
+func TestFromLabels_Errors(t *testing.T) {
+ tests := []struct {
+ name string
+ host string
+ labels map[string]string
+ want error
+ }{
+ {"TCP nicht unterstützt", "h", map[string]string{"traefik.tcp.routers.db.rule": "HostSNI(`*`)"}, ErrUnsupportedLabel},
+ {"UDP nicht unterstützt", "h", map[string]string{"traefik.udp.services.dns.loadbalancer.server.port": "53"}, ErrUnsupportedLabel},
+ {"unbekannte HTTP-Art", "h", map[string]string{"traefik.http.serverstransports.x.insecureskipverify": "true"}, ErrUnsupportedLabel},
+ {"Schlüssel zu kurz", "h", map[string]string{"traefik.http.routers.r": "x"}, ErrUnsupportedLabel},
+ {"nur Präfix", "h", map[string]string{"traefik.": "x"}, ErrUnsupportedLabel},
+ {"unbekanntes Router-Attribut", "h", map[string]string{"traefik.http.routers.r.rule": "x", "traefik.http.routers.r.service": "s@file", "traefik.http.routers.r.tls.domains[0].main": "a.de"}, ErrUnsupportedLabel},
+ {"unbekanntes Service-Attribut", "h", map[string]string{"traefik.http.services.s.loadbalancer.server.port": "80", "traefik.http.services.s.loadbalancer.sticky.cookie": "true"}, ErrUnsupportedLabel},
+ {"leerer Name", "h", map[string]string{"traefik.http.routers..rule": "x"}, ErrInvalidLabel},
+ {"Priority keine Zahl", "h", map[string]string{"traefik.http.routers.r.rule": "x", "traefik.http.routers.r.service": "s@file", "traefik.http.routers.r.priority": "hoch"}, ErrInvalidLabel},
+ {"TLS kein Bool", "h", map[string]string{"traefik.http.routers.r.rule": "x", "traefik.http.routers.r.service": "s@file", "traefik.http.routers.r.tls": "vielleicht"}, ErrInvalidLabel},
+ {"Port keine Zahl", "h", map[string]string{"traefik.http.services.s.loadbalancer.server.port": "http"}, ErrInvalidLabel},
+ {"Port 0", "h", map[string]string{"traefik.http.services.s.loadbalancer.server.port": "0"}, ErrInvalidLabel},
+ {"Port zu groß", "h", map[string]string{"traefik.http.services.s.loadbalancer.server.port": "65536"}, ErrInvalidLabel},
+ {"ungültiges Schema", "h", map[string]string{"traefik.http.services.s.loadbalancer.server.port": "80", "traefik.http.services.s.loadbalancer.server.scheme": "ftp"}, ErrInvalidLabel},
+ {"passHostHeader kein Bool", "h", map[string]string{"traefik.http.services.s.loadbalancer.server.port": "80", "traefik.http.services.s.loadbalancer.passhostheader": "ja"}, ErrInvalidLabel},
+ {"Middleware Wert dann Objekt", "h", map[string]string{"traefik.http.middlewares.m.headers": "x", "traefik.http.middlewares.m.headers.stsseconds": "1"}, ErrInvalidLabel},
+ {"Middleware leeres Segment", "h", map[string]string{"traefik.http.middlewares.m.headers..x": "1"}, ErrInvalidLabel},
+ {"Middleware ohne Typ", "h", map[string]string{"traefik.http.middlewares.m": "x"}, ErrUnsupportedLabel},
+ {"Rule fehlt", "h", map[string]string{"traefik.http.routers.r.service": "s@file"}, ErrIncomplete},
+ {"Port fehlt", "h", map[string]string{"traefik.http.services.s.loadbalancer.server.scheme": "https"}, ErrIncomplete},
+ {"Host fehlt", " ", map[string]string{"traefik.http.services.s.loadbalancer.server.port": "80"}, ErrIncomplete},
+ {"Service fehlt ohne Services", "h", map[string]string{"traefik.http.routers.r.rule": "x"}, ErrIncomplete},
+ {"Service mehrdeutig", "h", map[string]string{
+ "traefik.http.routers.r.rule": "x",
+ "traefik.http.services.a.loadbalancer.server.port": "80",
+ "traefik.http.services.b.loadbalancer.server.port": "81",
+ }, ErrIncomplete},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tt.labels["traefik.enable"] = "true"
+ got, err := FromLabels(tt.host, tt.labels)
+ if !errors.Is(err, tt.want) {
+ t.Fatalf("erwartet %v, bekommen %v", tt.want, err)
+ }
+ if got != nil {
+ t.Errorf("bei Fehler erwartet nil-Konfiguration, bekommen %+v", got)
+ }
+ })
+ }
+}
+
+func TestFromLabels_Middlewares_ObjectThenValue(t *testing.T) {
+ // Sortierte Verarbeitung: "...headers.a" kommt vor "...headers.a.b" – hier
+ // wird der umgekehrte Fall (erst Objekt, dann Wert am selben Pfad) geprüft.
+ m := Middleware{}
+ if err := setMiddlewareAttr(m, []string{"headers", "a", "b"}, "1"); err != nil {
+ t.Fatalf("unerwarteter Fehler: %v", err)
+ }
+ if err := setMiddlewareAttr(m, []string{"headers", "a"}, "2"); !errors.Is(err, ErrInvalidLabel) {
+ t.Errorf("erwartet ErrInvalidLabel, bekommen %v", err)
+ }
+}
+
+func TestFromLabels_CollectsAllErrors(t *testing.T) {
+ _, err := FromLabels("h", map[string]string{
+ "traefik.enable": "true",
+ "traefik.tcp.routers.db.rule": "HostSNI(`*`)",
+ "traefik.http.services.s.loadbalancer.server.port": "nope",
+ "traefik.http.routers.r.service": "s",
+ })
+ for _, want := range []error{ErrUnsupportedLabel, ErrInvalidLabel, ErrIncomplete} {
+ if !errors.Is(err, want) {
+ t.Errorf("Fehler enthält %v nicht: %v", want, err)
+ }
+ }
+}
+
+func TestSplitList(t *testing.T) {
+ tests := map[string][]string{
+ "": nil,
+ " , ,": nil,
+ "a": {"a"},
+ "a,b": {"a", "b"},
+ " a , b ,, c ": {"a", "b", "c"},
+ }
+ for in, want := range tests {
+ if got := splitList(in); !reflect.DeepEqual(got, want) {
+ t.Errorf("splitList(%q) = %#v, erwartet %#v", in, got, want)
+ }
+ }
+}
diff --git a/pkg/traefik/merge.go b/pkg/traefik/merge.go
new file mode 100644
index 0000000..3531288
--- /dev/null
+++ b/pkg/traefik/merge.go
@@ -0,0 +1,55 @@
+package traefik
+
+import (
+ "errors"
+ "fmt"
+ "reflect"
+)
+
+// ErrConflict meldet zwei unterschiedliche Definitionen unter demselben Namen.
+var ErrConflict = errors.New("Namenskonflikt")
+
+// Merge führt die Konfigurationen mehrerer Knoten zu einer zusammen.
+// nil-Einträge werden übersprungen. Identische Definitionen unter demselben
+// Namen sind erlaubt, unterschiedliche ergeben ErrConflict; das Vergeben
+// eindeutiger Namen (z. B. pro Mandant/Knoten) ist Aufgabe des Aufrufers.
+// Die Eingaben werden nicht verändert, das Ergebnis teilt sich aber die
+// Router-, Service- und Middleware-Objekte mit ihnen.
+func Merge(configs ...*Configuration) (*Configuration, error) {
+ out := &HTTPConfiguration{
+ Routers: map[string]*Router{},
+ Services: map[string]*Service{},
+ Middlewares: map[string]Middleware{},
+ }
+ var errs []error
+ for _, c := range configs {
+ if c == nil || c.HTTP == nil {
+ continue
+ }
+ errs = append(errs, mergeInto(out.Routers, c.HTTP.Routers, "router")...)
+ errs = append(errs, mergeInto(out.Services, c.HTTP.Services, "service")...)
+ errs = append(errs, mergeInto(out.Middlewares, c.HTTP.Middlewares, "middleware")...)
+ }
+ if len(errs) > 0 {
+ return nil, errors.Join(errs...)
+ }
+ if len(out.Routers) == 0 && len(out.Services) == 0 && len(out.Middlewares) == 0 {
+ return &Configuration{}, nil
+ }
+ return &Configuration{HTTP: out}, nil
+}
+
+func mergeInto[V any](dst, src map[string]V, kind string) []error {
+ var errs []error
+ for name, v := range src {
+ existing, ok := dst[name]
+ if !ok {
+ dst[name] = v
+ continue
+ }
+ if !reflect.DeepEqual(existing, v) {
+ errs = append(errs, fmt.Errorf("%s %q: %w", kind, name, ErrConflict))
+ }
+ }
+ return errs
+}
diff --git a/pkg/traefik/merge_test.go b/pkg/traefik/merge_test.go
new file mode 100644
index 0000000..b6fae6f
--- /dev/null
+++ b/pkg/traefik/merge_test.go
@@ -0,0 +1,90 @@
+package traefik
+
+import (
+ "errors"
+ "reflect"
+ "testing"
+)
+
+func nodeConfig(t *testing.T, host, name, domain string) *Configuration {
+ t.Helper()
+ c, err := FromLabels(host, map[string]string{
+ "traefik.enable": "true",
+ "traefik.http.routers." + name + ".rule": "Host(`" + domain + "`)",
+ "traefik.http.services." + name + ".loadbalancer.server.port": "80",
+ "traefik.http.middlewares." + name + "-gzip.compress": "true",
+ })
+ if err != nil {
+ t.Fatalf("FromLabels: %v", err)
+ }
+ return c
+}
+
+func TestMerge_Disjoint(t *testing.T) {
+ a := nodeConfig(t, "10.0.0.1", "a", "a.de")
+ b := nodeConfig(t, "10.0.0.2", "b", "b.de")
+
+ got, err := Merge(a, nil, &Configuration{}, b)
+ if err != nil {
+ t.Fatalf("unerwarteter Fehler: %v", err)
+ }
+ for _, name := range []string{"a", "b"} {
+ if got.HTTP.Routers[name] == nil || got.HTTP.Services[name] == nil || got.HTTP.Middlewares[name+"-gzip"] == nil {
+ t.Errorf("%s fehlt in %s", name, mustJSON(t, got))
+ }
+ }
+ if n := len(a.HTTP.Routers); n != 1 {
+ t.Errorf("Eingabe wurde verändert: %d Router in a", n)
+ }
+}
+
+func TestMerge_IdenticalDuplicateAllowed(t *testing.T) {
+ a := nodeConfig(t, "10.0.0.1", "a", "a.de")
+ again := nodeConfig(t, "10.0.0.1", "a", "a.de")
+
+ got, err := Merge(a, again)
+ if err != nil {
+ t.Fatalf("unerwarteter Fehler: %v", err)
+ }
+ if !reflect.DeepEqual(got, a) {
+ t.Errorf("erwartet %s, bekommen %s", mustJSON(t, a), mustJSON(t, got))
+ }
+}
+
+func TestMerge_Conflicts(t *testing.T) {
+ a := nodeConfig(t, "10.0.0.1", "a", "a.de")
+ sameNameOtherHost := nodeConfig(t, "10.0.0.2", "a", "a.de")
+ sameNameOtherRule := nodeConfig(t, "10.0.0.1", "a", "evil.de")
+ otherMiddleware := &Configuration{HTTP: &HTTPConfiguration{
+ Middlewares: map[string]Middleware{"a-gzip": {"compress": "false"}},
+ }}
+
+ tests := map[string]*Configuration{
+ "Service": sameNameOtherHost,
+ "Router": sameNameOtherRule,
+ "Middleware": otherMiddleware,
+ }
+ for name, other := range tests {
+ t.Run(name, func(t *testing.T) {
+ got, err := Merge(a, other)
+ if !errors.Is(err, ErrConflict) {
+ t.Fatalf("erwartet ErrConflict, bekommen %v", err)
+ }
+ if got != nil {
+ t.Errorf("bei Konflikt erwartet nil, bekommen %+v", got)
+ }
+ })
+ }
+}
+
+func TestMerge_Empty(t *testing.T) {
+ for _, in := range [][]*Configuration{nil, {nil}, {{}}, {{HTTP: &HTTPConfiguration{}}}} {
+ got, err := Merge(in...)
+ if err != nil {
+ t.Fatalf("unerwarteter Fehler: %v", err)
+ }
+ if got == nil || got.HTTP != nil {
+ t.Errorf("erwartet leere Konfiguration, bekommen %+v", got)
+ }
+ }
+}