erste module drin.

zwischenstand: Architektur aktualisiert, TOFU implementiert,
This commit is contained in:
br committed 2026-10-01 22:22:13 +02:00
1 parent 9d5789c5d7
commit c8df9d7651
41 files changed
+6455 -36

No files matched your search

+95 -26
View File
@@ -1,6 +1,3 @@
# CLAUDE.md
# Sultan: High-Performance Distributed Ingress & ZeroTrust Service Discovery # 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). 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):** 1. **Control Plane (Agent -> Server):**
* **Transport:** Ausgehende Verbindung vom Agenten zum Server über TCP `:8443` (TLS). * **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. * **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. * **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:** 3. **Ingress zu Traefik:**
* Traefik pollt den lokalen Sultan-Server via HTTP-Provider: `GET http://127.0.0.1:8080/v1/traefik-config`. * 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. * 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 `<name>@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) ### 3.1 Transport-Verschlüsselung (TLS ohne CA-Zirkus)
* **Server-Zertifikat:** Generiert sich beim ersten Start selbst (Self-Signed / Snakeoil ECDSA P-256). * **Grundsatz: Kein Agent-Verkehr im Klartext.** Durchgesetzt auf mehreren Ebenen:
* **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`. * Der Agent-Port `:8443` wird ausschließlich per TLS bedient, **nur TLS 1.3**.
* **Optional:** Unterstützung für explizit gemountete Root-CA-Zertifikate. * 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 `<data-dir>/server.key` (0600) und `server.crt`. Gültigkeit 20 Jahre, weil Agents den Fingerprint pinnen. Fehlt nur eine der beiden Dateien, startet der Server nicht, statt still ein neues Zertifikat zu erzeugen (das würde alle Pins brechen).
* **Fingerprint:** SHA-256 über das DER-Zertifikat, Base64url ohne Padding (43 Zeichen).
* **Vertrauensmodell: Pinning mit geprüftem Erstkontakt** (statt reinem TOFU, siehe 3.4):
* Der Agent speichert den Fingerprint je Server (`host:port`) in `known_servers` (Format wie SSH `known_hosts`: `<server> <fingerprint>`, 0600; Einträge werden nur angehängt, nie still geändert).
* **Erstkontakt:** Der Fingerprint muss zu dem im Enrollment-Token eingebetteten Wert passen (3.2), erst dann wird gepinnt.
* **Folgekontakte:** Das Zertifikat muss zum Pin passen. Der Pin gewinnt immer, auch gegen einen später gelieferten Token-Fingerprint.
* **Ohne Pin und ohne Token-Fingerprint** wird nicht verbunden (kein blindes TOFU).
* Die Prüfung erfolgt im TLS-Handshake, also bevor Token, Signatur oder Body gesendet werden.
* **Optional (geplant):** Unterstützung für explizit gemountete Root-CA-Zertifikate.
### 3.2 Authentifizierung & Enrollment (CSR-Handshake) ### 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>.<fingerprint>`
* `secret`: 256 Bit Zufall (Base64url). Der Server speichert nur den SHA-256 von `sltn_enroll_<secret>`, einmalig verwendbar, mit Ablaufzeit.
* `fingerprint`: Fingerprint des Server-Zertifikats (3.1). Nicht geheim; er sichert den Erstkontakt des Agenten ab.
* Der Admin überträgt den Token auf einem separaten Weg (z. B. Copy-Paste aus dem Server-Terminal) an den Agenten.
* **Enrollment:** * **Enrollment:**
1. Admin erzeugt kurzlebigen Einladungs-Token auf dem Server (`sltn_token_...`). 1. Admin erzeugt den Einladungs-Token auf dem Server (`sultan 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`). 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. Server speichert den Public Key des Agenten, weist ihm eine Mandanten-ID zu und signiert das initiale Berechtigungszertifikat (Capability Lease). 3. Agent generiert lokalen Key (`node.key`) und sendet `sltn_enroll_<secret>` 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. Der Einladungs-Token wird entwertet; alle künftigen Heartbeats werden kryptografisch vom Agenten mit `node.key` signiert. 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. * **Domain Spoofing Protection:** Beansprucht ein Agent ein Label `Host('kunde.de')`, prüft der Server, ob `kunde.de` im Feld `allowed_domains` der Mandanten-Lizenz hinterlegt ist. Falls nicht: Ablehnung mit Sicherheits-Audit-Log.
* **Route Limits:** Übersteigt die Anzahl der deklarierten Routen das Limit (`max_routes`), werden überzählige Routen verworfen. * **Route Limits:** Übersteigt die Anzahl der deklarierten Routen das Limit (`max_routes`), werden überzählige Routen verworfen.
### 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 `<data-dir>/server.key` erzeugt ein neues Zertifikat und sperrt alle Agents aus. Deshalb muss `server.key` gesichert werden. *(Geplant:)* geordneter Zertifikatswechsel, bei dem der Server das neue Zertifikat vorab über den gepinnten Kanal ankündigt.
* **Zweite Schicht (geplant):** Server→Agent-Events werden mit einem Server-Signaturschlüssel signiert, den der Agent beim Enrollment lernt. Die Echtheit von `node_command` hängt dann nicht mehr allein am TLS-Pin.
--- ---
## 4. Datenmodell (Data Specifications) ## 4. Datenmodell (Data Specifications)
@@ -125,7 +168,7 @@ Jeder Payload vom Agenten an den Server muss vom Server gegen die zugewiesene Po
```json ```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", "title": "SultanCapabilities",
"type": "object", "type": "object",
"properties": { "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) ### 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_id>`, `event: <type>`, `data: <JSON in einer Zeile>`, gefolgt von einer Leerzeile (max. 1 MiB). Agent→Server läuft über signierte HTTPS-Requests. `node_id` setzt der Server immer selbst aus der geprüften Identität.
```json ```json
{ {
@@ -172,7 +215,15 @@ Liefert ein verlängertes Krypto-Lease an den Agenten zurück.
### 4.3 SQLite Datenbankschema (Server State) ### 4.3 SQLite Datenbankschema (Server State)
Der Server persistiert Mandanten, Tokens und Richtlinien lokal in SQLite (`/var/lib/sultan/sultan.db`): Der Server persistiert Mandanten, Tokens und Richtlinien lokal in SQLite (`/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 ```sql
-- Mandanten & Abonnements -- Mandanten & Abonnements
@@ -184,9 +235,9 @@ CREATE TABLE tenants (
max_routes INTEGER NOT NULL DEFAULT 5, max_routes INTEGER NOT NULL DEFAULT 5,
allowed_domains TEXT NOT NULL, -- JSON-Array: ["*.firma.de", "shop.de"] allowed_domains TEXT NOT NULL, -- JSON-Array: ["*.firma.de", "shop.de"]
valid_until DATETIME NOT NULL, -- Ablaufdatum des Vertrags / Abos valid_until DATETIME NOT NULL, -- Ablaufdatum des Vertrags / Abos
status TEXT NOT NULL DEFAULT 'active', -- active, suspended_payment, quarantined status TEXT NOT NULL DEFAULT 'active', -- active, suspended_payment, quarantined, expired
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL, -- von der Anwendung gesetzt (UTC-Text)
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at DATETIME NOT NULL
); );
-- Registrierte Agenten / Knoten -- Registrierte Agenten / Knoten
@@ -197,9 +248,9 @@ CREATE TABLE nodes (
public_key TEXT NOT NULL UNIQUE, -- Ed25519 Public Key des Agenten public_key TEXT NOT NULL UNIQUE, -- Ed25519 Public Key des Agenten
last_seen DATETIME NOT NULL, last_seen DATETIME NOT NULL,
ip_address TEXT NOT NULL, ip_address TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'online', -- online, offline, drained status TEXT NOT NULL DEFAULT 'online' -- online, offline, drained
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
); );
CREATE INDEX nodes_tenant_id ON nodes(tenant_id);
-- Einmalige Enrollment-Tokens -- Einmalige Enrollment-Tokens
CREATE TABLE enrollment_tokens ( CREATE TABLE enrollment_tokens (
@@ -208,6 +259,7 @@ CREATE TABLE enrollment_tokens (
expires_at DATETIME NOT NULL, expires_at DATETIME NOT NULL,
used INTEGER NOT NULL DEFAULT 0 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 restart: unless-stopped
network_mode: host network_mode: host
command: 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" - "--providers.http.pollInterval=3s"
- "--entrypoints.web.address=:80" - "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entryPoint.to=websecure" - "--entrypoints.web.http.redirections.entryPoint.to=websecure"
@@ -329,7 +381,7 @@ services:
- "8443:8443" # Agent Inbound (TLS Stream) - "8443:8443" # Agent Inbound (TLS Stream)
- "127.0.0.1:8080:8080" # Traefik Config Provider & Checkmk Metrics - "127.0.0.1:8080:8080" # Traefik Config Provider & Checkmk Metrics
volumes: 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: command:
- "sultan" - "sultan"
- "agent" - "agent"
- "--server=[https://ingress.dein-netz.de:8443](https://ingress.dein-netz.de:8443)" - "--server=https://ingress.dein-netz.de:8443"
- "--token=sltn_enroll_01J8Z9..." - "--token=sltn_enroll_<secret>.<server-fingerprint>" # Ausgabe von `sultan token`
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- ./agent-data:/var/lib/sultan - ./agent-data:/var/lib/sultan # node.key (0600) und known_servers – persistent halten!
kunde-wordpress: kunde-wordpress:
image: wordpress:latest image: wordpress:latest
@@ -360,9 +412,26 @@ services:
- "traefik.http.routers.wp.tls=true" - "traefik.http.routers.wp.tls=true"
- "traefik.http.routers.wp.tls.certresolver=dns-resolver" - "traefik.http.routers.wp.tls.certresolver=dns-resolver"
- "traefik.http.middlewares.wp-www.redirectregex.regex=^https?://kunde\\.de/(.*)" - "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.middlewares.wp-www.redirectregex.permanent=true"
- "traefik.http.routers.wp.middlewares=wp-www" - "traefik.http.routers.wp.middlewares=wp-www"
- "traefik.http.services.wp.loadbalancer.server.port=80" - "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_<secret>.<fingerprint>`**, Pinning mit geprüftem Erstkontakt statt blindem TOFU. | Schließt die Man-in-the-Middle-Lücke beim Erstkontakt (3.4). Entscheidet zugleich den früheren Widerspruch `sltn_token_` vs. `sltn_enroll_`. |
| geplant | Signierte Server-Events; geordneter Zertifikatswechsel; optionale Root-CA. | Zweite Sicherheitsschicht neben dem TLS-Pin; Betrieb ohne Aussperren. |
+14 -10
View File
@@ -5,11 +5,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Instruktionen ## Instruktionen
Lies die `.ai/*.md` Files. Diese liefern den Context für die App, die hier entstehen soll. 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 ## 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_<secret>.<fingerprint>` (`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 ```bash
go vet ./... 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 - `sultan token` – CLI für Schlüssel, Enrollment-Tokens, Lizenzausstellung
**Ports / Endpunkte des Servers:** **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). - `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):** **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. - 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. - 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. - 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. **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: 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.
- 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.
+20
View File
@@ -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
)
+54
View File
@@ -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=
+296
View File
@@ -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)
}
}
+106
View File
@@ -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
}
+110
View File
@@ -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)
}
}
}
+44
View File
@@ -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)
}
+29
View File
@@ -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)
}
}
+109
View File
@@ -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
}
+87
View File
@@ -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)
}
}
+179
View File
@@ -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
}
}
+385
View File
@@ -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)
}
}
}
+191
View File
@@ -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_<secret>.<fingerprint>. 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,
}
}
+139
View File
@@ -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")
}
}
+142
View File
@@ -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 "<server> <fingerprint>", # für Kommentare. Einträge
// werden nur angehängt, nie stillschweigend geändert.
type KnownServers struct {
Path string
mu sync.Mutex
}
// Lookup liefert den Pin für server. Eine fehlende Datei bedeutet: kein Pin.
func (k *KnownServers) Lookup(server string) (Fingerprint, bool, error) {
k.mu.Lock()
defer k.mu.Unlock()
return k.lookup(server)
}
func (k *KnownServers) lookup(server string) (Fingerprint, bool, error) {
data, err := os.ReadFile(k.Path)
if errors.Is(err, os.ErrNotExist) {
return "", false, nil
}
if err != nil {
return "", false, err
}
sc := bufio.NewScanner(bytes.NewReader(data))
for line := 1; sc.Scan(); line++ {
text := strings.TrimSpace(sc.Text())
if text == "" || strings.HasPrefix(text, "#") {
continue
}
fields := strings.Fields(text)
if len(fields) != 2 {
return "", false, fmt.Errorf("%w: %s Zeile %d", ErrCorruptKnownServers, k.Path, line)
}
fp, err := ParseFingerprint(fields[1])
if err != nil {
return "", false, fmt.Errorf("%w: %s Zeile %d: %w", ErrCorruptKnownServers, k.Path, line, err)
}
if fields[0] == server {
return fp, true, nil
}
}
return "", false, sc.Err()
}
// Add pinnt fp für server. Ist bereits ein anderer Fingerprint gepinnt,
// liefert Add ErrFingerprintMismatch und ändert nichts.
func (k *KnownServers) Add(server string, fp Fingerprint) error {
if len(strings.Fields(server)) != 1 || strings.TrimSpace(server) != server {
return fmt.Errorf("crypto: ungültiger Servername %q", server)
}
if _, err := ParseFingerprint(string(fp)); err != nil {
return err
}
k.mu.Lock()
defer k.mu.Unlock()
pinned, ok, err := k.lookup(server)
if err != nil {
return err
}
if ok {
if pinned != fp {
return fmt.Errorf("%w: %s ist auf %s gepinnt, nicht %s", ErrFingerprintMismatch, server, pinned, fp)
}
return nil
}
f, err := os.OpenFile(k.Path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
if err != nil {
return err
}
_, err = fmt.Fprintf(f, "%s %s\n", server, fp)
if cerr := f.Close(); err == nil {
err = cerr
}
return err
}
// ClientTLSConfig liefert die TLS-Konfiguration des Agenten für server
// (host:port aus der --server-URL). Statt einer CA prüft sie den
// Fingerprint:
//
// - 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)
},
}
}
+193
View File
@@ -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)
}
}
+65
View File
@@ -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_<secret>.<fingerprint>.
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<secret>.<fingerprint>", 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
}
+92
View File
@@ -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)
}
}
+155
View File
@@ -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)
}
+226
View File
@@ -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")
}
+214
View File
@@ -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
}
+143
View File
@@ -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)
}
}
+200
View File
@@ -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)
}
+253
View File
@@ -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
}
+88
View File
@@ -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()
}
+188
View File
@@ -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)
}
}
+197
View File
@@ -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
}
}
+333
View File
@@ -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")
}
}
+80
View File
@@ -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
}
+110
View File
@@ -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)
}
}
+78
View File
@@ -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
}
+84
View File
@@ -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)
}
}
+271
View File
@@ -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
}
+406
View File
@@ -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)
}
}
+125
View File
@@ -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)
}
}
+146
View File
@@ -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)
}
}
+303
View File
@@ -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
}
+360
View File
@@ -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)
}
}
}
+55
View File
@@ -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
}
+90
View File
@@ -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)
}
}
}