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