erste module drin.
zwischenstand: Architektur aktualisiert, TOFU implementiert,
This commit is contained in:
1 parent
9d5789c5d7
commit
c8df9d7651
41 files changed
+6455
-36
No files matched your search
@@ -1,6 +1,3 @@
|
||||
# CLAUDE.md
|
||||
|
||||
|
||||
# Sultan: High-Performance Distributed Ingress & ZeroTrust Service Discovery
|
||||
|
||||
Dieses Dokument beschreibt das vollständige Architektur-, Daten-, Netzwerk- und Anwendungsmodell des Projekts **Sultan**. Es dient als Kontext und Referenz für Software-Architekten und AI-gestützte Coding-Agenten (z. B. via Continue in VSCodium).
|
||||
@@ -74,7 +71,8 @@ Klassische Ingress-Lösungen wie Nginx Proxy Manager (NPM) erfordern eine manuel
|
||||
1. **Control Plane (Agent -> Server):**
|
||||
* **Transport:** Ausgehende Verbindung vom Agenten zum Server über TCP `:8443` (TLS).
|
||||
* **Firewall-Freundlichkeit:** Reine Outbound-Verbindung; funktioniert hinter DS-Lite, Mobilfunk, Carrier-Grade-NAT und Unternehmens-Firewalls ohne Portweiterleitung.
|
||||
* **Keepalive-Mechanik:** Persistenter HTTP/2- oder SSE-Stream. Alle 45 Sekunden wird ein minimales Keepalive-Byte (`:\n\n`) ausgetauscht. Verhindert das Schließen von State-Tables in NAT-Routern (Conntrack Timeout).
|
||||
* **Keepalive-Mechanik:** Persistenter **SSE-Stream** (Server-Sent Events, `text/event-stream`) vom Server zum Agenten. Alle 45 Sekunden ohne Event wird die SSE-Kommentarzeile `:\n\n` gesendet. Verhindert das Schließen von State-Tables in NAT-Routern (Conntrack Timeout). Der Agent gilt als getrennt, wenn 90 s lang nichts ankommt (auch kein Keepalive), und verbindet sich mit exponentiellem Backoff (1 s … 60 s) neu.
|
||||
* **Rückkanal (Agent -> Server):** Nicht über den Stream, sondern als eigene, signierte HTTPS-Requests (z. B. `service_state`).
|
||||
* **Event-Driven Push:** Änderungen an Containern (Start/Stop) oder Notfall-Sperren werden verzögerungsfrei (Latenz < 5 ms) über den bestehenden Stream übertragen.
|
||||
|
||||
|
||||
@@ -86,6 +84,8 @@ Klassische Ingress-Lösungen wie Nginx Proxy Manager (NPM) erfordern eine manuel
|
||||
3. **Ingress zu Traefik:**
|
||||
* Traefik pollt den lokalen Sultan-Server via HTTP-Provider: `GET http://127.0.0.1:8080/v1/traefik-config`.
|
||||
* Intervall: 2–5 Sekunden. Traefik verarbeitet die In-Memory-Konfiguration ohne Neustart und ohne Verbindungsabbrüche.
|
||||
* **Pull, nicht Push:** Traefik holt die Konfiguration ab; Sultan schreibt nie in Traefik und emuliert keinen Docker-Socket. Freigeschaltet wird das einmalig in Traefiks statischer Konfiguration (`--providers.http.endpoint`). Router aus Sultan heißen in Traefik `<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)
|
||||
|
||||
* **Server-Zertifikat:** Generiert sich beim ersten Start selbst (Self-Signed / Snakeoil ECDSA P-256).
|
||||
* **Vertrauensmodell:** Trust-on-First-Use (TOFU) analog zu SSH. Der Agent verifiziert beim ersten Connect den SHA256-Fingerprint des Servers und speichert ihn in `known_servers`.
|
||||
* **Optional:** Unterstützung für explizit gemountete Root-CA-Zertifikate.
|
||||
* **Grundsatz: Kein Agent-Verkehr im Klartext.** Durchgesetzt auf mehreren Ebenen:
|
||||
* Der Agent-Port `:8443` wird ausschließlich per TLS bedient, **nur TLS 1.3**.
|
||||
* Der Agent signiert nur Requests an `https`-URLs; an eine `http`-URL geht weder Signatur noch Body.
|
||||
* Der Server authentifiziert nur Requests, die über TLS angekommen sind (sonst `426 Upgrade Required`).
|
||||
* **Server-Zertifikat:** Wird beim ersten Start selbst erzeugt (selbstsigniert, ECDSA P-256, **keine CA**) und liegt dauerhaft unter `<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)
|
||||
|
||||
* 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:**
|
||||
1. Admin erzeugt kurzlebigen Einladungs-Token auf dem Server (`sltn_token_...`).
|
||||
2. Agent generiert lokalen Key (`node.key`) und sendet seinen Public Key (`node.pub`) zusammen mit dem Einladungs-Token an den Server (`POST /api/v1/enroll`).
|
||||
3. Server speichert den Public Key des Agenten, weist ihm eine Mandanten-ID zu und signiert das initiale Berechtigungszertifikat (Capability Lease).
|
||||
4. Der Einladungs-Token wird entwertet; alle künftigen Heartbeats werden kryptografisch vom Agenten mit `node.key` signiert.
|
||||
1. Admin erzeugt den Einladungs-Token auf dem Server (`sultan token`).
|
||||
2. Agent zerlegt den Token, verbindet sich per TLS und prüft dabei den Server-Fingerprint gegen den Token (3.1). Erst danach pinnt er den Server.
|
||||
3. Agent generiert lokalen Key (`node.key`) und sendet `sltn_enroll_<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. Server prüft die Signatur, löst den Token ein und legt den Knoten **in einer Transaktion** an. Der Mandant ergibt sich ausschließlich aus dem Token. Schlägt das Anlegen fehl, bleibt der Token gültig. Paralleles Einlösen desselben Tokens gelingt genau einmal.
|
||||
5. *(Geplant)* Server signiert das initiale Berechtigungszertifikat (Capability Lease).
|
||||
6. Alle künftigen Requests werden vom Agenten mit `node.key` signiert.
|
||||
|
||||
* **Request-Signatur** (jeder Agent-Request inkl. Stream-Aufbau):
|
||||
* Header: `X-Sultan-Key` (Public Key, Base64), `X-Sultan-Timestamp` (Unix-Sekunden), `X-Sultan-Nonce` (16–64 Zeichen, pro Request neu), `X-Sultan-Signature` (Base64).
|
||||
* Signiert wird (zeilengetrennt): `SULTAN-ED25519-V1`, Methode, Pfad+Query, Timestamp, Nonce, SHA-256-Hex des Bodys.
|
||||
* **Server-Prüfreihenfolge:**
|
||||
1. TLS
|
||||
2. Signatur (ohne Datenbankzugriff)
|
||||
3. Zeitfenster ±5 Minuten
|
||||
4. Nonce (Replay-Schutz)
|
||||
5. erst dann Nachschlagen des Knotens per Public Key
|
||||
|
||||
Gefälschte Requests kosten so keinen DB-Zugriff und können keine Nonces verbrauchen.
|
||||
* Die **Knoten-ID** wird ausschließlich aus dem geprüften Schlüssel abgeleitet, nie aus einer Angabe des Clients.
|
||||
* Body-Limit 1 MiB.
|
||||
* Fehler-Mapping: 401 (nicht authentifiziert, Replay, Zeitabweichung), 403 (gesperrt), 413 (Body zu groß), 426 (kein TLS); Infrastrukturfehler 500.
|
||||
* **Sperren:** Die Authentifizierung sperrt nur Mandanten im Status `quarantined`. Zahlungsverzug, Ablauf und gedrainte Knoten regelt die Policy-Engine über das Routing (5.2). Der Agent behält den Kanal, z. B. für `lease_renew` oder eine Reaktivierung. Für Entzug zur Laufzeit trennt der Server bestehende Streams aktiv (`Hub.Disconnect`).
|
||||
|
||||
|
||||
|
||||
@@ -117,6 +150,16 @@ Jeder Payload vom Agenten an den Server muss vom Server gegen die zugewiesene Po
|
||||
* **Domain Spoofing Protection:** Beansprucht ein Agent ein Label `Host('kunde.de')`, prüft der Server, ob `kunde.de` im Feld `allowed_domains` der Mandanten-Lizenz hinterlegt ist. Falls nicht: Ablehnung mit Sicherheits-Audit-Log.
|
||||
* **Route Limits:** Übersteigt die Anzahl der deklarierten Routen das Limit (`max_routes`), werden überzählige Routen verworfen.
|
||||
|
||||
### 3.4 Bedrohungsmodell: Erstkontakt (TOFU) und Server-Events
|
||||
|
||||
* **Schwäche von reinem TOFU:** Beim ersten Kontakt wird ungeprüft vertraut. Ein Angreifer im Netzwerkweg (ARP-Spoofing im LAN, DNS-Manipulation, kompromittierter Router, offenes WLAN) würde dauerhaft gepinnt. Er könnte damit:
|
||||
* den Enrollment-Token abgreifen und mit eigenem Schlüssel zuerst einlösen,
|
||||
* sich dauerhaft dazwischenschalten (der echte Server gilt danach als „Fälschung“),
|
||||
* unsignierte Server-Events (`node_command`, `lease_renew`) einschleusen.
|
||||
* **Gegenmaßnahme (umgesetzt):** Der Fingerprint ist im Enrollment-Token eingebettet (3.1/3.2). Damit gibt es keinen ungeprüften Erstkontakt mehr. Ein Angreifer scheitert im TLS-Handshake und sieht weder Token noch Signatur; der Token bleibt beim echten Server einlösbar.
|
||||
* **Betriebsrisiko:** Ein Verlust von `<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)
|
||||
@@ -125,7 +168,7 @@ Jeder Payload vom Agenten an den Server muss vom Server gegen die zugewiesene Po
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "[http://json-schema.org/draft-07/schema#](http://json-schema.org/draft-07/schema#)",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "SultanCapabilities",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -150,7 +193,7 @@ Jeder Payload vom Agenten an den Server muss vom Server gegen die zugewiesene Po
|
||||
|
||||
### 4.2 Stream Event Protocol (Agent <-> Server)
|
||||
|
||||
Das bidirektionale Streaming-Format nutzt standardisierte JSON-Events:
|
||||
Beide Richtungen nutzen dasselbe JSON-Event-Format. Server→Agent läuft über den SSE-Stream; jedes Event ist ein SSE-Rahmen `id: <event_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
|
||||
{
|
||||
@@ -172,7 +215,15 @@ Liefert ein verlängertes Krypto-Lease an den Agenten zurück.
|
||||
|
||||
### 4.3 SQLite Datenbankschema (Server State)
|
||||
|
||||
Der Server persistiert Mandanten, Tokens und Richtlinien lokal in SQLite (`/var/lib/sultan/sultan.db`):
|
||||
Der Server persistiert Mandanten, Tokens und Richtlinien lokal in SQLite (`/var/lib/sultan/sultan.db`).
|
||||
|
||||
* **Treiber:** `modernc.org/sqlite` (reines Go, kein cgo → statisches Single Binary).
|
||||
* **Pragmas:** `foreign_keys=ON` (sonst greift `ON DELETE CASCADE` nicht), WAL, `busy_timeout`, Schreib-Transaktionen `IMMEDIATE`.
|
||||
* **Migrationen:** Versioniert über `PRAGMA user_version`, nur anhängen. Eine Datenbank mit neuerer Version als das Programm wird abgelehnt.
|
||||
* **Zeitstempel:** Die Anwendung setzt sie selbst (keine `CURRENT_TIMESTAMP`-Defaults), als UTC-Text fester Breite (`2006-01-02T15:04:05.000000000Z`). Dadurch funktionieren Vergleiche direkt in SQL.
|
||||
* **Tokens:** Gespeichert wird nur der SHA-256-Hash.
|
||||
|
||||
Schema (Stand Migration v1):
|
||||
|
||||
```sql
|
||||
-- Mandanten & Abonnements
|
||||
@@ -184,9 +235,9 @@ CREATE TABLE tenants (
|
||||
max_routes INTEGER NOT NULL DEFAULT 5,
|
||||
allowed_domains TEXT NOT NULL, -- JSON-Array: ["*.firma.de", "shop.de"]
|
||||
valid_until DATETIME NOT NULL, -- Ablaufdatum des Vertrags / Abos
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active, suspended_payment, quarantined
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active, suspended_payment, quarantined, expired
|
||||
created_at DATETIME NOT NULL, -- von der Anwendung gesetzt (UTC-Text)
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- Registrierte Agenten / Knoten
|
||||
@@ -197,9 +248,9 @@ CREATE TABLE nodes (
|
||||
public_key TEXT NOT NULL UNIQUE, -- Ed25519 Public Key des Agenten
|
||||
last_seen DATETIME NOT NULL,
|
||||
ip_address TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'online', -- online, offline, drained
|
||||
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
|
||||
status TEXT NOT NULL DEFAULT 'online' -- online, offline, drained
|
||||
);
|
||||
CREATE INDEX nodes_tenant_id ON nodes(tenant_id);
|
||||
|
||||
-- Einmalige Enrollment-Tokens
|
||||
CREATE TABLE enrollment_tokens (
|
||||
@@ -208,6 +259,7 @@ CREATE TABLE enrollment_tokens (
|
||||
expires_at DATETIME NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX enrollment_tokens_tenant_id ON enrollment_tokens(tenant_id);
|
||||
|
||||
```
|
||||
|
||||
@@ -309,7 +361,7 @@ services:
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
command:
|
||||
- "--providers.http.endpoint=[http://127.0.0.1:8080/v1/traefik-config](http://127.0.0.1:8080/v1/traefik-config)"
|
||||
- "--providers.http.endpoint=http://127.0.0.1:8080/v1/traefik-config"
|
||||
- "--providers.http.pollInterval=3s"
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
|
||||
@@ -329,7 +381,7 @@ services:
|
||||
- "8443:8443" # Agent Inbound (TLS Stream)
|
||||
- "127.0.0.1:8080:8080" # Traefik Config Provider & Checkmk Metrics
|
||||
volumes:
|
||||
- ./sultan-data:/var/lib/sultan
|
||||
- ./sultan-data:/var/lib/sultan # sultan.db, server.key (0600), server.crt – server.key sichern!
|
||||
|
||||
```
|
||||
|
||||
@@ -344,11 +396,11 @@ services:
|
||||
command:
|
||||
- "sultan"
|
||||
- "agent"
|
||||
- "--server=[https://ingress.dein-netz.de:8443](https://ingress.dein-netz.de:8443)"
|
||||
- "--token=sltn_enroll_01J8Z9..."
|
||||
- "--server=https://ingress.dein-netz.de:8443"
|
||||
- "--token=sltn_enroll_<secret>.<server-fingerprint>" # Ausgabe von `sultan token`
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./agent-data:/var/lib/sultan
|
||||
- ./agent-data:/var/lib/sultan # node.key (0600) und known_servers – persistent halten!
|
||||
|
||||
kunde-wordpress:
|
||||
image: wordpress:latest
|
||||
@@ -360,9 +412,26 @@ services:
|
||||
- "traefik.http.routers.wp.tls=true"
|
||||
- "traefik.http.routers.wp.tls.certresolver=dns-resolver"
|
||||
- "traefik.http.middlewares.wp-www.redirectregex.regex=^https?://kunde\\.de/(.*)"
|
||||
- "traefik.http.middlewares.wp-www.redirectregex.replacement=[https://www.kunde.de/$$](https://www.kunde.de/$$){1}"
|
||||
- "traefik.http.middlewares.wp-www.redirectregex.replacement=https://www.kunde.de/$${1}"
|
||||
- "traefik.http.middlewares.wp-www.redirectregex.permanent=true"
|
||||
- "traefik.http.routers.wp.middlewares=wp-www"
|
||||
- "traefik.http.services.wp.loadbalancer.server.port=80"
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Architekturentscheidungen (Entscheidungslog)
|
||||
|
||||
| Datum | Entscheidung | Begründung |
|
||||
|---|---|---|
|
||||
| 2026-09-30 | Traefik bezieht die Konfiguration per **HTTP-Provider (Pull)** von `/v1/traefik-config`; kein Docker-Socket-Nachbau. | Gleichwertiger Traefik-Provider; ein Socket-Nachbau wäre fragil. Remote-Docker-Provider scheidet aus (Root-Zugriff auf Kunden-Hosts, eingehende Ports, nur ein Endpunkt). |
|
||||
| 2026-09-30 | Labels → Traefik-Konfiguration: Router/Services typisiert, **Middleware-Optionen als unveränderte Label-Strings** durchgereicht; nicht unterstützte Labels (TCP/UDP, unbekannte Attribute) werden abgelehnt statt ignoriert. Nur mit `traefik.enable=true`. | Traefik parst Middleware-Werte beim Einlesen wie Labels; Sultan muss nicht jeden Middleware-Typ kennen. |
|
||||
| 2026-09-30 | Server→Agent-Stream als **SSE**; Agent→Server über eigene signierte Requests. | Das Keepalive `:\n\n` ist SSE-Syntax; SSE ist proxy- und HTTP/1.1-tauglich. |
|
||||
| 2026-10-01 | Tenant-Status **`expired`** als vierter gültiger Wert. | Abschnitt 6.2 setzt ihn bei Ablauf. |
|
||||
| 2026-10-01 | SQLite über **`modernc.org/sqlite`**. | Kein cgo, statisches Single Binary. |
|
||||
| 2026-10-01 | **Ed25519-Request-Signatur in jedem Tier**, auch `community`. | Kein ungeschützter Homelab-Modus; einheitliches Sicherheitsniveau. |
|
||||
| 2026-10-01 | Authentifizierung sperrt nur `quarantined`; übrige Sperren über Routing-Policy. | Agent braucht den Kanal weiterhin (Lease, Reaktivierung). |
|
||||
| 2026-10-01 | **Kein Klartext für Agent-Verkehr**: TLS 1.3 only, Signer nur für `https`, Verifier nur mit TLS. | Signaturen und Labels dürfen nie unverschlüsselt übertragen werden. |
|
||||
| 2026-10-01 | **Token-Format `sltn_enroll_<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. |
|
||||
@@ -5,11 +5,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## Instruktionen
|
||||
|
||||
Lies die `.ai/*.md` Files. Diese liefern den Context für die App, die hier entstehen soll.
|
||||
Maßgeblich ist aktuell [.ai/sultan-system-architecture.md](.ai/sultan-system-architecture.md) (Netzwerk-, Sicherheits-, Daten- und Anwendungsmodell inkl. SQL-Schema, Event-Format und Referenz-Compose-Files). Bei Widersprüchen zwischen diesem File und der Spec gilt die Spec.
|
||||
Maßgeblich ist aktuell [.ai/sultan-system-architecture.md](.ai/sultan-system-architecture.md) (Netzwerk-, Sicherheits-, Daten- und Anwendungsmodell inkl. SQL-Schema, Event-Format und Referenz-Compose-Files). Bei Widersprüchen zwischen diesem File und der Spec gilt die Spec. **Architekturentscheidungen werden in der Spec nachgepflegt** (betroffene Abschnitte + Entscheidungslog in Abschnitt 8) – das gehört zu jeder Änderung, die Architektur festlegt.
|
||||
|
||||
## Projektstatus
|
||||
|
||||
Frühe Phase: Go-Modul `sultan` (Go 1.25), bisher nur [pkg/traefik](pkg/traefik) – übersetzt flache `traefik.*`-Labels (`FromLabels`) in Traefiks dynamische Konfiguration, führt Knoten-Configs zusammen (`Merge`) und rendert JSON/YAML (`Marshal`) für `/v1/traefik-config`. Noch kein `main`/Subcommand.
|
||||
Frühe Phase: Go-Modul `sultan` (Go 1.25), noch kein `main`/Subcommand. Packages:
|
||||
- [pkg/traefik](pkg/traefik) – übersetzt flache `traefik.*`-Labels (`FromLabels`) in Traefiks dynamische Konfiguration, führt Knoten-Configs zusammen (`Merge`) und rendert JSON/YAML (`Marshal`) für `/v1/traefik-config`. Traefik *pollt* diesen Endpunkt über seinen HTTP-Provider; Sultan schreibt nie in Traefik und emuliert keinen Docker-Socket.
|
||||
- [pkg/stream](pkg/stream) – Server→Agent-Kanal als **SSE**: `Hub` (Server, ein Stream pro Knoten, Keepalive `:\n\n`, Neuverbindung ersetzt alte mit `ErrReplaced`, `OnConnect`/`OnDisconnect` nur bei echtem Online/Offline), `Client` (Agent, Idle-Timeout 90s, `Run` mit exponentiellem Backoff, `Prepare` zum Signieren), `Decoder`. Authentifizierung macht der Aufrufer vor `Hub.Serve`. Agent→Server (`service_state`) läuft *nicht* über diesen Stream, sondern über eigene Requests.
|
||||
- [pkg/store](pkg/store) – SQLite-Persistenz (Schema Spec 4.3) über `modernc.org/sqlite` (reines Go, kein cgo → statisches Binary). Migrationen sind eine append-only Liste in `store.go`, Version in `PRAGMA user_version`; neuere DB-Version → `ErrSchemaTooNew`. Zeitstempel werden aus Go gesetzt (injizierbare Uhr `Store.now`) und als UTC-Text fester Breite gespeichert, damit SQL-Vergleiche funktionieren – keine `CURRENT_TIMESTAMP`-Defaults verwenden. Enrollment-Tokens nur als SHA-256; `Enroll` verbraucht Token und legt Knoten atomar an. Fehler: `ErrNotFound`/`ErrExists`/`ErrInvalid`/`ErrTokenUsed`/`ErrTokenExpired`.
|
||||
- [pkg/auth](pkg/auth) – Ed25519-Request-Signatur, **in jedem Tier Pflicht** (kein Bypass, auch nicht für `community`/Homelab). `Signer.Sign` (passt als `stream.Client.Prepare`) signiert Methode, Pfad+Query, Zeitstempel, Nonce und Body-Hash (`X-Sultan-*`-Header). `Verifier.Verify` prüft in der Reihenfolge TLS → Signatur → Zeitfenster (±5min) → Nonce → Store, liefert `Identity{Node, Tenant}`; die Knoten-ID für `Hub.Serve` kommt *nur* daraus. `VerifyUnregistered` für Enrollment (Besitznachweis des Schlüssels). Gesperrt (`ErrForbidden`) wird nur bei Quarantäne; Zahlungsverzug/Ablauf/Drain regelt die Policy über das Routing. `StatusCode(err)` für HTTP-Antworten. Schlüsseldateien: PKCS#8-PEM, 0600, werden nie überschrieben.
|
||||
- **Kein Klartext:** `Signer` signiert nur `https`-URLs, `Verifier` lehnt Requests ohne `r.TLS` ab (`ErrInsecureTransport`, 426). Tests für Agent↔Server laufen daher über TLS-Testserver.
|
||||
- [pkg/crypto](pkg/crypto) – Transport: `LoadOrCreateServerCert(dataDir)` (selbstsigniert ECDSA P-256, keine CA, 20 Jahre, `server.key` 0600; fehlt eine der Dateien → Fehler statt Neuerzeugung), `ServerTLSConfig` (nur TLS 1.3), `ClientTLSConfig(server, *KnownServers, expectedFP)` – Pinning im Handshake: Pin gewinnt; ohne Pin nur mit Token-Fingerprint; ohne beides `ErrUnknownServer` (**kein blindes TOFU**). Enrollment-Token `sltn_enroll_<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
|
||||
go vet ./...
|
||||
@@ -30,11 +38,11 @@ Sultan ersetzt manuell gepflegtes Ingress-Routing (z. B. Nginx Proxy Manager): B
|
||||
- `sultan token` – CLI für Schlüssel, Enrollment-Tokens, Lizenzausstellung
|
||||
|
||||
**Ports / Endpunkte des Servers:**
|
||||
- `:8443` (TLS) – Control Plane, ausschließlich *ausgehend* vom Agent (NAT/CGNAT/DS-Lite-tauglich). Persistenter Stream (HTTP/2 oder SSE), 45s-Keepalive, Events werden sofort gepusht. Enrollment: `POST /api/v1/enroll`.
|
||||
- `:8443` (TLS) – Control Plane, ausschließlich *ausgehend* vom Agent (NAT/CGNAT/DS-Lite-tauglich). Persistenter SSE-Stream (entschieden, siehe `pkg/stream`), 45s-Keepalive, Events werden sofort gepusht. Enrollment: `POST /api/v1/enroll`.
|
||||
- `127.0.0.1:8080` – `GET /v1/traefik-config` (Traefik pollt alle 2–5s; flache Labels aller aktiven Nodes → verschachteltes Traefik-JSON) und `GET /metrics/checkmk` (Checkmk Local-Check-Format).
|
||||
|
||||
**Sicherheitsmodell (Kerninvarianten):**
|
||||
- Server-TLS-Zertifikat self-signed (ECDSA P-256), Agent pinnt den SHA256-Fingerprint per TOFU in `known_servers`.
|
||||
- Server-TLS-Zertifikat self-signed (ECDSA P-256, TLS 1.3), Agent pinnt den SHA256-Fingerprint in `known_servers`; der Erstkontakt wird gegen den Fingerprint im Enrollment-Token geprüft (kein blindes TOFU). Kein Agent-Verkehr im Klartext.
|
||||
- Agent-Keys (Ed25519) werden nur lokal erzeugt und verlassen den Host nie; Enrollment tauscht Public Key + einmaligen Token gegen Mandanten-Zuordnung und signierten Capability-Lease. Danach ist jeder Heartbeat mit `node.key` signiert.
|
||||
- Jeder Agent-Payload wird serverseitig gegen die Mandanten-Policy geprüft: Hosts in `Host(...)`-Regeln müssen in `allowed_domains` stehen (sonst ablehnen + Audit-Log), Routen über `max_routes` werden verworfen.
|
||||
- Lizenzen (`license.json`) sind mit dem Ed25519-Server-Master-Key signiert.
|
||||
@@ -45,10 +53,6 @@ Sultan ersetzt manuell gepflegtes Ingress-Routing (z. B. Nginx Proxy Manager): B
|
||||
|
||||
**Policy/Lizenzablauf:** Mandanten-Status steuert das generierte Routing – Zahlungsverzug → Router auf Bezahlseite (402), Backend down/`service_down` → Statusseite 503 mit `Retry-After: 60`. Checkmk-Schwellen: WARN bei 30, CRIT bei 14 Tagen Restlaufzeit, bei Ablauf Umleitung auf Landingpage.
|
||||
|
||||
## Bekannte Unstimmigkeiten in der Spec
|
||||
## Unstimmigkeiten in der Spec
|
||||
|
||||
Vor der Implementierung mit dem User klären statt raten:
|
||||
- Token-Präfix: `sltn_token_...` (Abschnitt 3.2) vs. `sltn_enroll_...` (Compose-Beispiel 7.2).
|
||||
- Tenant-Status: Schema kennt `active`/`suspended_payment`/`quarantined`, Abschnitt 6.2 setzt `expired`.
|
||||
- Stream-Transport ist offen: HTTP/2 *oder* SSE (SSE wäre nur Server→Agent; bidirektionale Events brauchen dann einen Rückkanal).
|
||||
- Einige URLs in JSON/YAML-Blöcken der Spec sind durch Copy-Paste als Markdown-Links verstümmelt (`"[http://...](http://...)"`) – die nackte URL ist gemeint.
|
||||
Bisherige Widersprüche sind aufgelöst und im Entscheidungslog der Spec (Abschnitt 8) dokumentiert. Neue Unklarheiten vor der Implementierung mit dem User klären statt raten.
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user