diff --git a/.ai/sultan-system-architecture.md b/.ai/sultan-system-architecture.md index 599cb47..bf12689 100644 --- a/.ai/sultan-system-architecture.md +++ b/.ai/sultan-system-architecture.md @@ -101,22 +101,34 @@ Klassische Ingress-Lösungen wie Nginx Proxy Manager (NPM) erfordern eine manuel * Der Server authentifiziert nur Requests, die über TLS angekommen sind (sonst `426 Upgrade Required`). * **Server-Zertifikat:** Wird beim ersten Start selbst erzeugt (selbstsigniert, ECDSA P-256, **keine CA**) und liegt dauerhaft unter `/server.key` (0600) und `server.crt`. Gültigkeit 20 Jahre, weil Agents den Fingerprint pinnen. Fehlt nur eine der beiden Dateien, startet der Server nicht, statt still ein neues Zertifikat zu erzeugen (das würde alle Pins brechen). * **Fingerprint:** SHA-256 über das DER-Zertifikat, Base64url ohne Padding (43 Zeichen). -* **Vertrauensmodell: Pinning mit geprüftem Erstkontakt** (statt reinem TOFU, siehe 3.4): - * Der Agent speichert den Fingerprint je Server (`host:port`) in `known_servers` (Format wie SSH `known_hosts`: ` `, 0600; Einträge werden nur angehängt, nie still geändert). - * **Erstkontakt:** Der Fingerprint muss zu dem im Enrollment-Token eingebetteten Wert passen (3.2), erst dann wird gepinnt. - * **Folgekontakte:** Das Zertifikat muss zum Pin passen. Der Pin gewinnt immer, auch gegen einen später gelieferten Token-Fingerprint. - * **Ohne Pin und ohne Token-Fingerprint** wird nicht verbunden (kein blindes TOFU). - * Die Prüfung erfolgt im TLS-Handshake, also bevor Token, Signatur oder Body gesendet werden. +* **Vertrauensmodell: Pinning am Fingerprint, unabhängig von der Adresse** (statt reinem TOFU, siehe 3.4): + * **Erstkontakt:** Der Fingerprint kommt aus der Einladung (3.2). Passt das Zertifikat nicht, wird abgebrochen. + * **Danach:** Der Fingerprint steht als `server_fingerprint` in `node.json` und wird bei **jeder** Verbindung geprüft, über welche Adresse auch immer (DNS, interne IP, NetBird). Derselbe Server hat überall denselben Fingerprint, deshalb ist ein Adresswechsel gefahrlos. Ein **anderer** Fingerprint bedeutet einen anderen Server und erfordert ein neues Enrollment. + * **Ohne Fingerprint** wird nicht verbunden (kein blindes TOFU). + * Die Prüfung erfolgt im TLS-Handshake (`crypto.PinnedTLSConfig`), also bevor Token, Signatur oder Body gesendet werden. Hostname und CA werden bewusst nicht geprüft; das Pinning ist strenger. * **Optional (geplant):** Unterstützung für explizit gemountete Root-CA-Zertifikate. ### 3.2 Authentifizierung & Enrollment (CSR-Handshake) * Private Keys werden **ausschließlich lokal auf dem Ziel-Host** generiert (Ed25519) und verlassen diesen niemals. `node.key` liegt als PKCS#8-PEM mit Rechten 0600 vor, wird nie überschrieben und bei offeneren Rechten nicht geladen. * **Authentifizierung ist in jedem Tier Pflicht** – auch im `community`-/Homelab-Betrieb gibt es keinen Zugang ohne Ed25519-Signatur und keinen Schalter, der das abstellt. -* **Enrollment-Token:** `sltn_enroll_.` - * `secret`: 256 Bit Zufall (Base64url). Der Server speichert nur den SHA-256 von `sltn_enroll_`, einmalig verwendbar, mit Ablaufzeit. - * `fingerprint`: Fingerprint des Server-Zertifikats (3.1). Nicht geheim; er sichert den Erstkontakt des Agenten ab. - * Der Admin überträgt den Token auf einem separaten Weg (z. B. Copy-Paste aus dem Server-Terminal) an den Agenten. +* **Enrollment-Token:** `sltn_enroll_.[.]`. Alle Teile sind Base64url; der Token ist ohne Quoting shell-sicher (~130 Zeichen mit URL). + * `secret`: 256 Bit Zufall. Der Server speichert nur den SHA-256 von `sltn_enroll_`, einmalig verwendbar, mit Ablaufzeit. + * `fingerprint`: Fingerprint des Server-Zertifikats (3.1). Nicht geheim; er sichert den Erstkontakt ab. + * `server-url` (optional, nicht geheim): Adresse, unter der der Agent den Server erreicht. + * Nur `https://host[:port]`: kein Pfad, keine Query, keine Zugangsdaten, max. 255 Zeichen. + * Woher sie kommt: `sultan server --public-url` (wird im Store gemerkt) oder pro Einladung `sultan token invite --server-url`. So kann ein Knoten z. B. eine NetBird-Adresse bekommen. + * Ohne Adresse entsteht der zweiteilige Token; der Agent braucht dann `--server`. + * Eine manipulierte Adresse führt dank Pinning höchstens zu einem Verbindungsfehler, nie zum Versand des Secrets an einen fremden Server. + * Der Admin überträgt den Token auf einem vertrauenswürdigen Weg (z. B. Copy-Paste aus dem Server-Terminal). Wer den Token manipulieren kann, könnte auch den Fingerprint tauschen. + * **Server-Adresse beim Agent:** Beim Enrollment gilt `--server`/`SULTAN_SERVER`, sonst die URL aus der Einladung. Danach versucht der Agent in dieser Reihenfolge: + 1. `--server` + 2. die zuletzt funktionierende Adresse (`node.json` `server`) + 3. die Adressliste des Servers (`server_urls`) + + Nach jedem Verbindungsfehler wechselt er zur nächsten Adresse; eine erfolgreich genutzte wird als `server` gespeichert. Das ist gefahrlos, weil der Pin am Fingerprint hängt (Schritt 4). + * **Umgebungsvariablen** `SULTAN_TOKEN`/`SULTAN_SERVER` statt Argumenten, z. B. für `curl … | sh` oder Compose. Argumente wären über `ps` für andere Benutzer sichtbar. + * Eine in Compose stehen gebliebene, bereits eingelöste Einladung **desselben** Servers ist harmlos und wird ignoriert. Eine Einladung eines **anderen** Servers bei bestehender Registrierung wird abgelehnt. * **Enrollment:** 1. Admin erzeugt den Einladungs-Token auf dem Server (`sultan token`). 2. Agent zerlegt den Token, verbindet sich per TLS und prüft dabei den Server-Fingerprint gegen den Token (3.1). Erst danach pinnt er den Server. @@ -184,7 +196,7 @@ Umsetzung in `pkg/policy`. Jeder Verstoß entfernt nur den betroffenen Router un * unsignierte Server-Events (`node_command`, `lease_renew`) einschleusen. * **Gegenmaßnahme (umgesetzt):** Der Fingerprint ist im Enrollment-Token eingebettet (3.1/3.2). Damit gibt es keinen ungeprüften Erstkontakt mehr. Ein Angreifer scheitert im TLS-Handshake und sieht weder Token noch Signatur; der Token bleibt beim echten Server einlösbar. * **Betriebsrisiko:** Ein Verlust von `/server.key` erzeugt ein neues Zertifikat und sperrt alle Agents aus. Deshalb muss `server.key` gesichert werden. *(Geplant:)* geordneter Zertifikatswechsel, bei dem der Server das neue Zertifikat vorab über den gepinnten Kanal ankündigt. -* **Zweite Schicht (geplant):** Server→Agent-Events werden mit einem Server-Signaturschlüssel signiert, den der Agent beim Enrollment lernt. Die Echtheit von `node_command` hängt dann nicht mehr allein am TLS-Pin. +* **Zweite Schicht (umgesetzt, Schritt 2):** Jedes Server→Agent-Event ist mit dem Master-Key signiert (`signature`, Präfix `SULTAN-EVENT-V1`, über alle übrigen Felder inkl. `event_id`, `timestamp`, `node_id`). Der Agent kennt den Public Key seit dem Enrollment (`node.json`) und verwirft Events ohne gültige Signatur. Die Echtheit von `node_command` hängt damit nicht mehr allein am TLS-Pin. --- @@ -237,34 +249,62 @@ Umsetzung in `pkg/policy`. Jeder Verstoß entfernt nur den betroffenen Router un * **Community:** Laufzeit **max. 14 Tage**, Erneuerung nach **ca. 7 Tagen mit zufälliger Streuung** (damit nicht alle Agents gleichzeitig erneuern). Der Server stellt Lizenz und Lease selbständig neu aus (re-license). Es gibt kein Mahnwesen. * Eine Wartung des Servers darf damit bis zu 7 Tage dauern: Bis die Lease abläuft, wird nichts invalidiert, keine Authentifizierung, keine Policy, keine Route. * Ist das Backend in dieser Zeit nicht erreichbar, zeigt der Lander eine 50x-Seite („geht gerade nicht …“) mit Hinweis auf den Pro-Service. +* **Umsetzung (Schritt 2):** + * Lease-Dokument: Lizenzfelder plus `node_id`, `issued_at`, `renew_after`, `valid_until`, signiert mit dem Master-Key unter dem Präfix `SULTAN-LEASE-V1` (gilt nie als Lizenz und umgekehrt). + * Community: 14 Tage, Erneuerung nach 7 Tagen ± 12 h Streuung. Kommerziell: 7 Tage (10 bei Wartung), Erneuerung nach 2/3, gekappt am Vertragsende; nach Vertragsende keine Lease. + * Der Server stellt beim Verbinden aus bzw. erneut zu und erneuert bei jedem Refresh ab `renew_after` (`lease_renew`). + * Der Agent prüft die Signatur, legt `lease.json` ab und protokolliert. Weitere Folgen hat das beim Agent vorerst nicht; ein Checkmk-Check auf Kundenseite ist vorgesehen. + * **Wirkung serverseitig:** Ein getrennter Knoten behält seine Routen als Wartungsseite, bis die Lease abläuft. + * `sultan server --lease-duration` (Konfigurationsfeld `LeaseDuration`) erzwingt eine feste Laufzeit, für Tests und Sonderfälle. * **Unterbrechung jederzeit serverseitig möglich:** Agent und Traefik pollen bzw. halten den Stream, also wirkt eine Unterbrechung sofort (`Hub.Disconnect`, keine Erneuerung, Policy-Stufe). * Gründe: Security-Breach, DDoS, Domain-Verstoß. * Nur kommerziell zusätzlich: Verstoß gegen Nutzungsrichtlinien (z. B. strafbare Inhalte), Zahlungsausfall, unterbrochenes Abo. * **Eskalation (nur kommerziell), analog zu Mahnstufen:** Karenzzeit mit Wasserzeichen → Firemark → Paywall-Landingpage („Kauf mich!“), siehe Stufen in 5.2. -### 4.1b Agent-Konfiguration `/etc/sultan.d/*.yaml` (Entscheidung 2026-10-01) +### 4.1b Agent-Konfiguration `/etc/sultan.d/*.yaml` (umgesetzt 2026-10-02) -* **Format: YAML.** Eine Datei beschreibt einen oder mehrere Dienste mit allem, was Traefik-Labels hergeben würden, plus Komfortschaltern. Beispiel (Schema noch auszuarbeiten): +* **Format: YAML**, eine oder mehrere Dateien `*.yaml`/`*.yml` (Verzeichnis per `--config-dir`, Default `/etc/sultan.d`, `none` = aus). Unbekannte Felder sind Fehler. ```yaml services: - - name: kunde-a - url: http://127.0.0.1:8080 # lokaler Dienst (oder: interface + port, s. u.) - hosts: [kunde-a.de, www.kunde-a.de] - https: true - redirect_http_to_https: true + - name: kunde-a # a–z, 0–9, - und _ (Router-/Service-Name) + url: https://127.0.0.1:8443/ # lokaler Dienst; sein Port gilt auch für den Ingress + hosts: [kunde-a.de] # keine Wildcards + https: true # Router auf websecure mit TLS + certresolver: dns-resolver # optional + entrypoints: [websecure] # optional, Default websecure (https) bzw. web + redirect_http_to_https: true # zweiter Router auf web mit redirectScheme (nur mit https) redirect_www: to_non_www # none | to_www | to_non_www - healthcheck: - interval: 30s - expect_status: 200 - expect_title: "KundeA - Willkommen in KundeA's Online-Welt" # optional - expect_contains: { tag: h2, text: "Impressum" } # optional - labels: # optional: rohe traefik.*-Labels für alles Weitere - traefik.http.middlewares.x.headers.stsseconds: "31536000" + maintenance: false # true = Dienst in Wartung (Wartungsseite) + maintenance_public_reason: "" # optional, ersetzt den Standardtext + healthcheck: # optional + url: https://127.0.0.1:8443/ # Default: url des Dienstes + interval: 30s # Default 30s + timeout: 5s # Default 5s + failures: 2 # Fehlschläge in Folge bis "down", Default 2 + expect_status: 200 # Default 200 + expect_title: "KundeA - Willkommen" # optional, exakter (Leerraum normalisiert) + expect_contains: { tag: h2, text: Impressum } # optional, Element enthält Text + labels: # optional: rohe traefik.*-Labels, überschreiben die erzeugten + traefik.http.middlewares.kunde-a-www.redirectregex.permanent: "false" ``` -* **Healthcheck → `service_down`:** Der Agent ruft den lokalen Dienst ab und erwartet `200 OK`, optional auch einfache Inhaltsprüfungen (`<title>`, Tag mit Text). Ein Fehlschlag meldet `service_down`, die Policy setzt `maintenance`. -* **Backend-Adresse/NetBird:** Interface (z. B. `wt0`) und Port werden **konfiguriert**; die IP wird über das Interface automatisch ermittelt. Ports lassen sich nicht zuverlässig erraten. Als Vorschlag dienen typische Ports: tcp/8080, tcp/80, tcp/443, tcp/3000, tcp/8000, tcp/8443. +* **Übersetzung in Traefik-Labels** (danach gelten dieselben Regeln wie für Docker: `FromLabels`, Policy, Namensraum): + * Workload-Name `sultan.d/<name>`; Router und Service heißen `<name>`. + * Backend: `<backend-host>:<port der url>`; `https`-URL → `loadbalancer.server.scheme=https`. Ohne Port 80/443. + * `redirect_www`: + * Middleware `<name>-www` (redirectRegex, permanent), z. B. `^https?://www\.(kunde-a\.de)(.*)` → `https://${1}${2}`. + * Die jeweils fehlende Variante (`www.`/ohne) wird **automatisch** in die Hosts aufgenommen, sonst käme die Anfrage nie beim Router an. + * Die Policy prüft beide Varianten gegen `allowed_domains`. + * `redirect_http_to_https`: Router `<name>-http` auf `web` mit Middleware `<name>-https` (redirectScheme `https`, permanent). +* **Healthcheck → Wartungsseite:** + * Der Agent prüft regelmäßig Status, optional `<title>` und ein Element mit Text. Das ist ein einfacher Textabgleich ohne HTML-Bibliothek. + * Nach `failures` Fehlschlägen in Folge gilt der Dienst als `down`, nach einem Erfolg wieder als `up` (gegen Flattern). + * Ausgefallene Dienste stehen in `down_services`; die Policy leitet ihre Router auf `maintenance` um (503 über den Interceptor). +* **Änderungen:** Das Verzeichnis wird per **Polling** geprüft (Default alle 5 s, Inhaltsvergleich), nicht per inotify. Polling ist zuverlässig auch bei Docker-Bind-Mounts und atomar ersetzten Dateien und braucht keine Abhängigkeit. + * Bei Änderungen werden die Dienste neu geladen, Healthchecks neu gestartet und der Zustand gesendet. + * Eine fehlerhafte Datei oder ein doppelter Dienstname wird mit Warnung übersprungen; die übrigen Dateien bleiben aktiv. +* **Backend-Adresse/NetBird:** wie bei Docker (`--backend-host`, `--backend-interface`, sonst automatisch). ### 4.2 Stream Event Protocol (Agent <-> Server) @@ -283,8 +323,13 @@ Beide Richtungen nutzen dasselbe JSON-Event-Format. Server→Agent läuft über * **Typ `service_state` (Agent -> Server):** Enthält die vollständige Liste der aktuell aktiven Traefik-Labels des Knotens. -* **Typ `node_command` (Server -> Agent):** -Enthält Aktionen wie `re_enroll`, `drain`, `maintenance`. +* **Typ `node_command` (Server -> Agent):** Payload `{command, reason}`, feste Liste: + * `resync`: Agent sendet sofort seinen Zustand. + * `rotate_key`: Agent legt `node.key.new` an und meldet ihn (`POST /api/v1/rotate-key`). Erst nach Erfolg wird umbenannt. Ein Abbruch wird beim nächsten Start sicher abgeschlossen: Lehnt der Server den alten Schlüssel ab, wird mit dem neuen wiederholt. + * `drain`, `maintenance`, `resume`: Hinweise, der Agent protokolliert nur. + * `config`: Adressliste des Servers (`server_urls`), wird beim Verbinden direkt gesendet (nicht über die Warteschlange). Der Agent übernimmt nur gültige `https`-Adressen nach `node.json`. + + Die CLI reiht Befehle in `node_commands` ein, der Server stellt sie beim Verbinden und bei jedem Refresh zu. * **Typ `lease_renew` (Server -> Agent):** Liefert ein verlängertes Krypto-Lease an den Agenten zurück. @@ -297,6 +342,14 @@ Der Server persistiert Mandanten, Tokens und Richtlinien lokal in SQLite (`<data * **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. +* **Migration v2:** Tabelle `settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)` für Server-Einstellungen, die auch `sultan token` braucht (derzeit `public_url`). +* **Migration v3:** Wartung pro Knoten – `nodes.maintenance_since`, `maintenance_until` (NULL = unbefristet), `maintenance_reason`, `maintenance_public_reason`. +* **Migration v5:** Tabelle `notifications (key, sent_at)` für „jedes Ereignis genau einmal“. +* **Migration v4:** + * Tabelle `leases (node_id, issued_at, renew_after, valid_until, document)` + * Tabelle `node_commands (id, node_id, command, reason, created_at, sent_at)` + * `tenants.features` (JSON) und `tenants.payment_overdue_since`; daraus Lizenz-Features bzw. Mahnstufe + * `enrollment_tokens.used_at` Schema (Stand Migration v1): @@ -362,8 +415,21 @@ sultan |---|---|---| | `--data-dir` | `./suldata` | `sultan.db`, `server.key`/`server.crt`, `master.key` | | `--agent-addr` | `:8443` | Agent-Port, nur TLS 1.3 | +| `--public-url` (mehrfach) | – | Adressen, unter denen Agents den Server erreichen (z. B. öffentlich und NetBird); werden im Store gemerkt (`settings.public_urls`). Die **erste** steckt in Einladungen; die ganze Liste bekommen Agents beim Verbinden per `config`-Befehl | | `--provider-addr` | `127.0.0.1:8080` | Traefik-Provider, Klartext. Bewusst Loopback, weil die Konfiguration Backend-IPs aller Mandanten enthält. Im Container `:8080` setzen und nur auf `127.0.0.1` veröffentlichen. | -| `--interceptor-url` | `http://127.0.0.1:8081` | Sultan-Interceptor/Lander | +| `--lander-addr` | `127.0.0.1:8081` | eingebauter Lander; `none` = aus (externer Lander) | +| `--interceptor-url` | Adresse des Landers | URL, unter der Traefik den Lander erreicht | +| `--hoster-name`, `--hoster-url`, `--pro-url` | – | Texte und Links der Lander-Seiten | +| `--notify-slack`, `--notify-ntfy` (mehrfach), `--notify-ntfy-token` | – | Benachrichtigungsziele | +| `--offline-alert-after` | `15m` | Alarm/CRIT für getrennte Knoten | +| `--lease-duration` | `0` (je Tier) | feste Lease-Laufzeit (Tests, Sonderfälle) | +| `--enroll-limit` | `10` | Enrollment-Versuche pro Minute und Client-IP, gleitendes Fenster; darüber `429` mit `Retry-After`; `-1` = unbegrenzt. Geprüft **vor** der Signaturprüfung | + +**Umgebungsvariablen:** Jedes Flag von `server`, `agent` und `token` ist auch als `SULTAN_<FLAG>` setzbar (`--public-url` → `SULTAN_PUBLIC_URL`, `--notify-ntfy-token` → `SULTAN_NOTIFY_NTFY_TOKEN`, `--data-dir` → `SULTAN_DATA_DIR`). +* Listen sind kommagetrennt. +* **Flags gewinnen**; bei Listen ersetzt ein Flag den Wert aus der Umgebung, statt ihn zu ergänzen. +* Die Hilfe (`--help`) nennt zu jedem Flag die Variable; fehlerhafte Werte werden mit dem Variablennamen gemeldet. +* Geheimnisse wie `SULTAN_NOTIFY_NTFY_TOKEN` oder `SULTAN_TOKEN` gehören in die Umgebung statt auf die Befehlszeile (dort per `ps` sichtbar). | `--refresh` | `1m` | Neubewertung aller Knoten (zeitabhängige Stufen, Mandantenänderungen) | **HTTP-Routen des Servers:** @@ -372,23 +438,78 @@ sultan |---|---|---| | Agent (TLS) | `POST /api/v1/enroll` | Body `{token, node_id, hostname}`, signiert mit neuem Schlüssel → 201 `{node_id, tenant_id, master_public_key}`; Token ungültig/verbraucht/abgelaufen → 403 ohne Details; ID/Schlüssel vergeben → 409 | | Agent (TLS) | `GET /api/v1/stream` | SSE-Stream (signiert); Stream offen = Knoten `online` | +| Agent (TLS) | `POST /api/v1/maintenance` | Body `{enabled, duration ("" = 4h, "0" = unbefristet), reason, public_reason}` → 204; wirkt sofort | +| Agent (TLS) | `POST /api/v1/rotate-key` | Body `{public_key, proof}`, signiert mit dem **bisherigen** Schlüssel; `proof` = Signatur des neuen Schlüssels über `SULTAN-ROTATE-V1\n<node_id>\n<public_key>` (an die Knoten-ID gebunden). Idempotent: ist der neue Schlüssel schon aktiv, ebenfalls 204 | | Agent (TLS) | `POST /api/v1/state` | vollständiger Schnappschuss `{workloads: [{name, host, labels}], down_services}`; nur bei offenem Stream (sonst 409) → 204 | | Provider | `GET /v1/traefik-config` | `?format=json\|yaml` | | Provider | `GET /healthz` | Lebenszeichen | +| Provider | `GET /metrics/checkmk` | Checkmk Local Checks (6.1) | + +**`sultan token` – Verwaltung (Community-Weg; kommerziell zusätzlich Admin-API, geplant):** arbeitet direkt auf dem Datenverzeichnis des Servers, auch während er läuft (SQLite/WAL). + +| Befehl | Wirkung | +|---|---| +| `tenant add --id --name --email --domains [--tier community] [--max-routes 5] [--valid-until +1 Jahr] [--features …]` | Mandant anlegen; Domains werden wie in der Lizenz normalisiert und geprüft; Features fließen in Lizenz, Lease und Policy | +| `tenant list` / `tenant set-status --id --status` / `tenant delete --id` | Anzeigen, Status setzen (wirkt beim nächsten Refresh), Löschen inkl. Knoten und Einladungen | +| `invite --tenant [--ttl 24h\|7d] [--server-url]` | Einladung `sltn_enroll_<secret>.<fingerprint>[.<server-url>]`; Adresse aus `--server-url` oder der gemerkten `--public-url`; stdout enthält **nur** den Token, Hinweise auf stderr | +| `node list [--tenant]` | Knoten mit Status und Wartung (`bis …`, `unbefristet`, `abgelaufen`) | +| `node maintenance --id [--for 4h\|0] [--reason] [--public-reason]` | Wartung anmelden (wirkt beim nächsten Refresh, ≤ 1 min) | +| `node drain --id` / `node resume --id` / `node delete --id` | drain; `resume` beendet Wartung **und** drain (`online` setzt erst der Stream). drain/maintenance/resume schicken dem Agenten zusätzlich einen Hinweis | +| `node command --id --cmd resync\|rotate_key [--reason]` | Befehl in die Warteschlange; Zustellung beim nächsten Refresh bzw. Verbinden | +| `license issue --tenant` | signierte `license.json` (Master-Key) auf stdout | +| `purge` | verbrauchte und abgelaufene Einladungen löschen | + +* Ein **nicht initialisiertes Datenverzeichnis wird nie angelegt**. Ein Tippfehler bei `--data-dir` würde sonst eine neue Server-Identität erzeugen, und alle damit ausgestellten Einladungen hätten einen falschen Fingerprint. +* Exit-Codes: 0 ok, 1 Fehler, 2 Bedienfehler. + +**Wartung vom Knoten aus:** `sultan agent maintenance on|off [--for 4h|0] [--reason] [--public-reason]` meldet die Wartung signiert beim Server an (z. B. in Patch- oder Reboot-Skripten). Der laufende Agent wird dafür nicht benötigt. **Lebenszyklus eines Knotens:** * Enrollment legt ihn `offline` an. * Stream verbunden → `online`. -* Stream getrennt → Routen sofort entfernt, `offline`. +* Stream verbunden → Lease ausstellen bzw. zustellen, ausstehende Befehle zustellen. +* Stream getrennt → `offline`. Die Routen **bleiben als Wartungsseite** stehen, solange eine Wartung angemeldet ist **oder die Lease gilt**; danach werden sie entfernt (Prüfung bei jedem Refresh). `drain` entfernt sie sofort. * `drained` bleibt bei beidem unverändert. Der Agent muss deshalb nach jedem (Wieder-)Verbinden seinen Zustand erneut senden. ### 5.1 Agenten-Subsysteme +**Umsetzung (`pkg/agent`, `pkg/docker`, `sultan agent`), Teil 1:** + +| Flag | Default | Bedeutung | +|---|---|---| +| `--server` / `SULTAN_SERVER` | – | Override der Server-Adresse; sonst aus der Einladung bzw. `node.json` | +| `--token` / `SULTAN_TOKEN` | – | Einladung, nur beim ersten Start | +| `--data-dir` | `./suldata` | `node.key` (0600), `node.json` (inkl. `server_fingerprint`) | +| `--node-id` | aus dem Hostnamen | z. B. `Proxmox-VM.42` → `proxmox-vm-42` | +| `--docker-socket` | `/var/run/docker.sock` | `none` = ohne Docker | +| `--backend-host` / `--backend-interface` | automatisch | siehe Backend-Adresse | + +* **Enrollment:** + 1. Der Agent erzeugt `node.key` und prüft den Server gegen den Fingerprint aus der Einladung. + 2. Er löst die Einladung ein und speichert `node.json` (Adresse, `server_fingerprint`, Knoten-ID, Mandant, Master-Public-Key). + 3. Danach ist kein Token mehr nötig. + 4. Adresswechsel desselben Servers sind erlaubt und werden gespeichert. Ein anderer Server erfordert ein neues Enrollment (Datenverzeichnis leeren). +* **Zustand senden:** vollständiger Schnappschuss an `POST /api/v1/state`. + * Ausgelöst wird er **nach jedem (Wieder-)Verbinden** des Streams (der Server hat die Routen beim Trennen entfernt) und bei jedem Docker-Event. + * Mehrere Auslöser werden über ein kurzes Sammelintervall gebündelt (Default 500 ms). + * Schlägt die Docker-API fehl, wird **nichts** gesendet, sonst würden alle Routen entfernt. Ohne Docker-Socket (Bare-Metal) ist der Zustand leer. +* **Port-Übersetzung:** Der Ingress sitzt auf einem anderen Host. Deshalb wird `loadbalancer.server.port` (Port im Container) auf den **veröffentlichten Host-Port** umgerechnet. + * Fehlt das Port-Label, gilt wie bei Traefiks Docker-Provider der einzige veröffentlichte Port. + * Ohne Service-Labels entsteht ein Service mit dem Containernamen. + * Container, deren Port von außen nicht erreichbar ist (nicht veröffentlicht, nur auf `127.0.0.1`/`::1`, nur UDP), werden mit Warnung weggelassen. + * Es werden nur Container mit `traefik.enable=true` gemeldet. +* **Backend-Adresse**, in dieser Reihenfolge: + 1. `--backend-host` + 2. IP von `--backend-interface` (z. B. `wt0`) + 3. automatisch die lokale IP, über die der Server erreicht wird +* **Docker:** minimaler Client für die Engine API über den Unix-Socket, **ohne Docker-SDK** (nur `GET /containers/json` und `GET /events`). Events werden mit Backoff neu abonniert; nach jedem Neuaufbau wird der Zustand neu gesendet. +* **Server-Events** (`node_command`, `lease_renew`) werden bisher nur protokolliert. + 1. **Watcher Engine:** * **Docker Engine:** Lauscht am `/var/run/docker.sock` via Docker Events API. Filtert nach Labels mit Präfix `traefik.`. -* **File Provider:** Überwacht Verzeichnis `/etc/sultan.d/*.conf` via Inotify auf Konfigurationsdateien im INI/YAML-Format. +* **File Provider:** liest `/etc/sultan.d/*.yaml` (Format 4.1b) per Polling, inkl. Healthchecks – umgesetzt (Teil 2). 2. **State Serializer:** @@ -430,7 +551,7 @@ Der Agent muss deshalb nach jedem (Wieder-)Verbinden seinen Zustand erneut sende | Mahnstufe 1 | `watermark` | Hinweisseite, dann Weiterleitung zum Original (kurze Wartezeit) | | Healthcheck meldet Dienst down | `maintenance` (je Router) | 503, `Retry-After: 60` | -* **Sultan-Interceptor / Web-Lander** (eigener Dienst, nur Loopback, z. B. `127.0.0.1:8081`): Bei jeder Stufe außer `normal` leitet die Policy den Router auf den Service `sultan__interceptor` um. +* **Sultan-Interceptor / Web-Lander** (`pkg/lander`, läuft im Server-Prozess auf `--lander-addr`, Default `127.0.0.1:8081`): Bei jeder Stufe außer `normal` leitet die Policy den Router auf den Service `sultan__interceptor` um. Die Interceptor-URL folgt automatisch der gebundenen Lander-Adresse; mit `--lander-addr=none` und `--interceptor-url` lässt sich ein externer Lander nutzen. * Regel, TLS und die Middlewares des Mandanten bleiben erhalten. * Ein `headers`-Middleware setzt `X-Sultan-Stage`, `X-Sultan-Tenant` und `X-Sultan-Tier`. Traefik überschreibt damit vom Besucher gesendete Werte. * Der Lander liefert nur eine eigene HTML-Seite je Stufe. Er verändert keine Originalseiten und braucht deshalb kein Upstream. @@ -442,12 +563,41 @@ Der Agent muss deshalb nach jedem (Wieder-)Verbinden seinen Zustand erneut sende 5. Eine bestätigte niedrigere Stufe gilt nicht für eine höhere. 6. Das Cookie ist bewusst kein Sicherheitsmerkmal: Wer es selbst setzt, überspringt nur den Hinweis. `paywall`, `landing` und `maintenance` haben keinen Durchlass. * **Eskalation in der Praxis:** Bei Stufe 1 und 2 alarmiert Checkmk. Zwei Alarme genügen; spätestens mit Stufe 3 ist der Kunde offline. -* **Lander-Inhalte:** - * **Eingebaut:** je Stufe eine minimale statische HTML-Seite, ohne CSS. Kurzer, nicht technischer Text in **de_DE und en_US**, was gerade funktioniert und was nicht. - * **Eigene Vorlagen (optional):** Liegen in einem vereinbarten, per Docker gemounteten Verzeichnis Vorlagen, werden diese gerendert und ausgeliefert. - * Format: Jinja2-artig; in Go umgesetzt über eine kompatible Template-Engine oder `html/template`, noch festzulegen. - * Pro Lander-Seite höchstens **1× HTML-Vorlage, 1× CSS, 1× Grafik (WebP oder SVG)**. - * Bewusst flach: kein allgemeiner Webserver. +* **Lander-Seiten (umgesetzt):** + + | Stufe | Status | Inhalt | + |---|---|---| + | `watermark` / `firemark` | 200 | Hinweis, `meta refresh` nach 5 / 10 s, Cookie `sultan_ack=<stufe>` (60 min, HttpOnly, SameSite=Lax, Secure hinter HTTPS) | + | `paywall` | 402 | Kunde offline, freundliche Einladung des Hosters (`--hoster-name`, `--hoster-url`) | + | `landing` | 402 | Hosting abgelaufen, Einladung des Hosters | + | `maintenance` | 503, `Retry-After: 60` | „Gleich wieder da“; Community zusätzlich mit Pro-Hinweis (`--pro-url`) | + + * **Eingebaut:** je Stufe eine minimale HTML-Seite ohne CSS, Text auf **Deutsch und Englisch** auf derselben Seite, `noindex`, `Cache-Control: no-store`. Alle Methoden (auch POST) bekommen die Seite. + * **Weiterleitungsziel nur lokal:** Pfad+Query des Requests. Ziele wie `//fremd.example` oder mit `\` werden zu `/`; es gibt keine Hosts und keine Schemata. Grund: `html/template` lässt im `content` von `meta refresh` auch `javascript:` durch, und `//host` wäre eine offene Weiterleitung. + * **Eigene Vorlagen:** `<data-dir>/landing/<stufe>.html` (Go `html/template`), optional `<stufe>.css` und `<stufe>.svg`/`.webp` (je ≤ 512 KiB). + * CSS und Grafik werden **eingebettet** (`{{.CSS}}`, `{{.Image}}` als `data:`-URI). Bei Mahnstufe 1/2 ist das Cookie schon gesetzt, ein separater Asset-Request ginge über den Durchlass-Router ans Kunden-Backend. + * Änderungen wirken sofort (Neuladen bei geänderter Größe/Zeitstempel). + * Eine fehlerhafte Vorlage führt zurück zur eingebauten Seite und wird protokolliert. + * **`sultan dump-landing [--out ./suldata/landing] [--force]`** schreibt die eingebauten Seiten plus `README.txt` (alle Template-Werte) als Ausgangspunkt. +* **Begriffe – `maintenance` vs. `drain` (Festlegung 2026-10-02):** + + | | **maintenance** – „bin gleich wieder da“ | **drain** – „gebe meine Arbeit ab“ | + |---|---|---| + | Routen bei Traefik | **bleiben**, zeigen 503 (Lander) | **werden entfernt** | + | Besucher sehen | Wartungsseite mit `Retry-After` | anderen Knoten, falls einer die Domain bedient, sonst 404 | + | Dauer | befristet (Default 4 h, `--for`) | bis `resume` | + | Anlass | Patchen, Reboot, Migration, Sicherheitsupdate | Umzug, Abbau, Lastverteilung | + | Knoten | darf sich trennen (Reboot) – Routen bleiben als Wartungsseite stehen | bleibt verbunden, bekommt weiter Events und Leases | + | Auslöser | Admin (`sultan token node maintenance`) **oder** Knoten selbst (`sultan agent maintenance on`), pro Dienst per YAML/Label | Admin | + + * Wie in der Cloud (`kubectl drain` vor der Wartung) ist die Kombination möglich: erst drain (Last auf andere Knoten), dann Wartung am leeren Knoten. + * **Einschränkung:** Echte Lastverteilung *einer* Domain über mehrere Knoten gibt es noch nicht (Roadmap). Bis dahin ist bei Seiten mit nur einem Backend **maintenance** das Mittel der Wahl; drain bedeutet dort „Seite offline (404)“. +* **Wartung (umgesetzt, Schritt 1):** + * Quellen: Knoten (`node maintenance` bzw. `agent maintenance on`), Dienst (Label `sultan.maintenance=true` + optional `sultan.maintenance.public_reason`, in YAML `maintenance`/`maintenance_public_reason`), Healthcheck-Ausfall. + * Vorrang: Sperre/drain (keine Routen) > Ablauf/Paywall > **Wartung** > Mahnhinweis > normal. Wartung unterdrückt also auch den Cookie-Durchlass der Mahnstufen. + * Header an den Lander: `X-Sultan-Until` (RFC 3339, nur befristet) und `X-Sultan-Public-Reason` (URL-kodiert). + * Lander: `public_reason` (max. 300 Zeichen, einzeilig) ersetzt den Standardtext; das Ende wird in der Zeitzone des Servers angezeigt; `Retry-After` = Sekunden bis zum Ende (60 s … 1 Tag). + * `reason` (max. 500 Zeichen) erscheint nur im Log und in `node list`, nie öffentlich. * **Mahnstufen-Zeitplan (`policy.DunningSchedule`):** Der Server berechnet die Stufe aus dem Fälligkeitsdatum der offenen Zahlung, angelehnt an Bank-Zahlungsfristen: **Stufe 1 ab 8 Tagen, Stufe 2 ab 25, Stufe 3 ab 32 Tagen** Verzug. Die Werte sind konfigurierbar. @@ -474,10 +624,31 @@ Das Ausgabeformat entspricht der nativen Checkmk Local-Check-Syntax: ``` +**Umsetzung (Schritt 3, `pkg/checkmk`):** `GET /metrics/checkmk` auf dem Provider-Port. + +| Service | Metrik | Zustand | +|---|---|---| +| `Sultan_Tenant_<id>` (nur kommerziell) | `days_left=N;30;14` | OK; WARN ≤ 30 Tage; CRIT ≤ 14 Tage, abgelaufen oder Quarantäne | +| `Sultan_Payment_<id>` (nur kommerziell) | `dunning_level=N;1;2` | OK ohne offene Zahlung; Mahnstufe 1 WARN; 2 und 3 CRIT (3 = „Kunde offline“) | +| `Sultan_Node_<id>` | `lease_hours_left=N` | online/drained OK; Wartung WARN (mit internem Grund, auch offline); offline WARN, ab `--offline-alert-after` (Default 15 min) CRIT | +| `Sultan_Policy_Violations` | `violations_24h=N;1` | OK ohne Verstöße; ab 1 WARN (gezählt im Speicher, nach Neustart neu) | + +* Texte in ASCII (Umlaute umschrieben), einzeilig, sortiert. Kurze Ausfälle in Sekunden („offline seit 45 s“), sonst in Minuten. Das Wartungsende steht in der Zeitzone des Servers mit Kürzel (z. B. `CEST`), wie auf der Lander-Seite. +* Checkmk-Agent-Plugin auf dem Ingress-Host: `/usr/lib/check_mk_agent/local/sultan` mit dem Inhalt `#!/bin/sh` und `curl -fsS http://127.0.0.1:8080/metrics/checkmk`. + ### 6.2 Schwellenwerte & Alarmierungskaskade * **30 Tage vor Ablauf:** Warnung im Checkmk-Dashboard (Monitoring-Status: `WARN`). * **14 Tage vor Ablauf:** Kritischer Alert (`CRIT`). Automatischer E-Mail-Dispatch / Ticket-Erstellung an Kunden und Admin: *„Abo-Verlängerung erforderlich – bitte Rechnung begleichen“*. +* **Umsetzung (Schritt 3):** Der Server-Job läuft bei jedem Refresh. + * **Vertragsablauf:** Kommerzielle Mandanten werden ab `valid_until` dauerhaft auf `expired` gesetzt. **Community läuft nie über `valid_until` ab** (5c), nur über den expliziten Status. + * **Benachrichtigungen** genau einmal pro Ereignis. Die Schlüssel enthalten das Bezugsdatum, damit nach einer Verlängerung bzw. bei einer neuen offenen Zahlung ein neuer Zyklus beginnt (Tabelle `notifications`). + * Ereignisse: Vertrag 30 / 14 / 0 Tage, Mahnstufe 1 / 2 / 3, Knoten länger als `--offline-alert-after` getrennt (nicht bei angemeldeter Wartung oder drain), neue Policy-Verstöße (sofort, asynchron). + * **„Wieder online“** (Info) beim erneuten Verbinden, nur wenn zuvor ein Offline-Alarm verschickt wurde, je Ausfall einmal, mit Ausfalldauer. + * Schlägt der Versand fehl, wird beim nächsten Durchlauf erneut versucht. Bei mehreren Zielen können dann einzelne Ziele eine Nachricht doppelt bekommen. + * Ziele: `--notify-slack` (Slack-kompatibel: `{"text": …}`, Slack-Markdown, Emoji je Schweregrad) und `--notify-ntfy` (+ `--notify-ntfy-token`; Titel, Priorität default/high/urgent, Tags, Markdown), jeweils mehrfach angebbar. Ohne Ziel wird nichts vermerkt. + * **Aufräumen** nach 10 Tagen: eingelöste/abgelaufene Einladungen, abgelaufene Leases, zugestellte Befehle. + * **Zahlungsstatus** bis zur Admin-API per CLI: `sultan token tenant payment --id … --overdue-since JJJJ-MM-TT | --paid`; `tenant list` zeigt die Spalte `PAYMENT`. * **Tag 0 (Ablaufzeitpunkt):** * Status wechselt in SQLite auf `expired`. * Traefik leitet Domain auf Informations-Landingpage um. @@ -489,6 +660,8 @@ Das Ausgabeformat entspricht der nativen Checkmk Local-Check-Syntax: ## 7. Referenzkonfigurationen +Die lauffähigen Vorlagen liegen unter `deploy/compose/{server,agent}` (mit `.env.example`); die Ausschnitte hier zeigen das Prinzip. Image: ein distroless-Image für Server und Agent (`Dockerfile`, nonroot-User `65532`, kein `ENTRYPOINT`, Arbeitsverzeichnis `/`, Default-Datenverzeichnis also `/suldata`). Ein Bind-Mount für `/suldata` muss vorher `65532:65532` gehören. + ### 7.1 Compose-Setup: Ingress-Knoten ```yaml @@ -515,10 +688,12 @@ services: container_name: sultan-server restart: unless-stopped # Im Container auf allen Interfaces lauschen; nach außen nur 127.0.0.1 veröffentlichen. - command: ["sultan", "server", "--data-dir=/suldata", "--provider-addr=:8080"] + command: ["sultan", "server", "--data-dir=/suldata", "--provider-addr=:8080", "--lander-addr=:8081", + "--hoster-name=Mein Hosting", "--hoster-url=https://hosting.example"] ports: - "8443:8443" # Agent Inbound (TLS Stream) - "127.0.0.1:8080:8080" # Traefik Config Provider & Checkmk Metrics – Klartext, nie öffentlich + - "127.0.0.1:8081:8081" # Lander (Hinweis-/Wartungsseiten) – Traefik ruft ihn über 127.0.0.1 auf volumes: - ./suldata:/suldata # sultan.db, server.key/.crt, master.key – server.key und master.key sichern! @@ -532,14 +707,19 @@ services: image: sultan:latest container_name: sultan-agent restart: unless-stopped + network_mode: host # Host-IP als Backend-Adresse, Hostname als Knoten-ID (Bridge: Container-IP/-ID) + group_add: ["${DOCKER_GID}"] # Lesezugriff auf den Docker-Socket für den nonroot-User command: - "sultan" - "agent" - - "--server=https://ingress.dein-netz.de:8443" - - "--token=sltn_enroll_<secret>.<server-fingerprint>" # Ausgabe von `sultan token` + - "--data-dir=/suldata" + environment: + # Ausgabe von `sultan token invite` – enthält Fingerprint und (mit --public-url) die Server-Adresse. + # Nach dem ersten Start nicht mehr nötig; stehen lassen ist harmlos. + SULTAN_TOKEN: "sltn_enroll_<secret>.<fingerprint>.<server-url>" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - - ./suldata:/suldata # node.key (0600) und known_servers – persistent halten! (Agent mit --data-dir=/suldata) + - ./suldata:/suldata # node.key (0600) und node.json – persistent halten! kunde-wordpress: image: wordpress:latest @@ -591,7 +771,25 @@ services: | 2026-10-01 | **Lander-Vorlagen mit Go `html/template`**; die eingebauten Seiten lassen sich als Beispiel exportieren (`dump-landing`). | Eingebaut, ohne Abhängigkeit, schützt automatisch gegen eingeschleustes HTML; Syntax erlernbar. | | 2026-10-01 | **Datenverzeichnis `./suldata`** für Server und Agent; Ports `:8443`/`8080` als konfigurierbare Defaults, Provider-Port auf Loopback. | Einheitlich, einfach zu mounten; Klartext-Port nicht versehentlich öffentlich. | | 2026-10-01 | **`sultan server`**: Knoten-Lebendigkeit = offener Stream; Routen beim Trennen sofort entfernt; `POST /api/v1/state` nur bei offenem Stream. | Keine verwaisten Routen; einfaches, eindeutiges Modell. | -| geplant | Signierte Server-Events; geordneter Zertifikatswechsel; optionale Root-CA. | Zweite Sicherheitsschicht neben dem TLS-Pin; Betrieb ohne Aussperren. | +| 2026-10-01 | **`sultan token`** arbeitet direkt auf dem Datenverzeichnis; legt es nie selbst an; Einladungs-Token allein auf stdout. | Kein versehentlicher Identitätswechsel durch Tippfehler; skriptfreundlich (`TOKEN=$(sultan token invite …)`). | +| 2026-10-02 | **Agent Teil 1:** Docker-Anbindung ohne SDK; Port-Übersetzung Container-Port → veröffentlichter Host-Port; Zustand nach jedem Verbinden neu senden; bei Docker-Fehler nichts senden. | Ingress erreicht nur veröffentlichte Ports; leichte Abhängigkeiten; keine versehentlich gelöschten Routen. | +| 2026-10-02 | Hub registriert einen Knoten **vor** dem Senden der Antwort-Header. | Sonst Wettlauf: Agent sendet seinen Zustand direkt nach den Headern und bekäme 409. | +| 2026-10-02 | **Agent Teil 2:** `/etc/sultan.d`-YAML wird in Traefik-Labels übersetzt (gleicher Weg wie Docker); Polling statt inotify; Healthcheck mit Schwelle (Default 2 Fehlschläge) → `down_services`. | Ein Prüfweg für alle Quellen; robust bei Bind-Mounts; kein Flattern der Wartungsseite. | +| 2026-10-02 | **Lander im Server-Prozess** (`--lander-addr`); `landing` wie `paywall` mit **402**; Weiterleitungsziel nur lokaler Pfad; Assets eigener Vorlagen eingebettet; Vorlagen in `<data-dir>/landing`. | Ein Dienst weniger im Betrieb; keine offene Weiterleitung; Assets funktionieren auch mit gesetztem Durchlass-Cookie. | +| 2026-10-02 | Polling in `/etc/sultan.d` bestätigt: lokal, nur Inhaltsvergleich weniger KB alle 5 s. | inotify ist bei Bind-Mounts und atomar ersetzten Dateien unzuverlässig; immer das Verzeichnis mounten, nie einzelne Dateien. | +| 2026-10-02 | **Server-Adresse in der Einladung** (optionaler dritter Teil, Base64url); Quelle `--public-url` (gemerkt) bzw. `invite --server-url`; beim Agent `--server`/`SULTAN_SERVER` als Override, `SULTAN_TOKEN` als Alternative zum Argument. | Onboarding mit einem einzigen kopierbaren Token (`curl \| sh`); der Server kennt seine erreichbare Adresse nicht selbst. | +| 2026-10-02 | **Pin am Fingerprint statt an der Adresse** (`server_fingerprint` in `node.json`, `known_servers` entfällt); Adresswechsel desselben Servers erlaubt und gespeichert. | Adressen ändern sich (DNS, NetBird, intern/extern); die Identität des Servers nicht. Sicherheitsniveau unverändert. | +| 2026-10-02 | **Reihenfolge der Roadmap:** Wartung → Leases/`node_command`/signierte Events (Migration v3) → Checkmk/Fristen/Benachrichtigungen → mehrere Adressen → Betrieb. | Jeder Schritt baut auf den Daten des vorherigen auf. | +| 2026-10-02 | **maintenance vs. drain** trennscharf: Wartung = Routen bleiben mit 503, befristet (Default 4 h), Admin **und** Knoten können sie auslösen; drain = Routen weg, bis `resume`, nur Admin. Grund zweigeteilt (`reason` intern, `public_reason` ersetzt den Standardtext der Wartungsseite). | Beides ist aus Cloud-Umgebungen bekannt; unterschiedliche Wirkung für Besucher. | +| 2026-10-02 | **Lease** bestimmt, wie lange Routen eines getrennten Knotens als Wartungsseite stehen bleiben; Agent loggt nur. `node_command` als feste Liste ohne Fernsteuerung; lokale Hooks geparkt. Server-Events werden signiert. | Klarer Zweck der Lease; keine Remote-Code-Ausführung. | +| 2026-10-02 | **Fristen & Benachrichtigung:** `expired` wird gespeichert; Aufräumen nach 10 Tagen; Offline-Alarm nach 15 min; Webhooks zuerst Slack-kompatibel und ntfy, Matrix geplant. | Nachvollziehbar, wenig Aufwand, gängige Ziele. | +| 2026-10-02 | **Wartung umgesetzt:** eigene Dimension je Knoten (nicht Teil von `status`); Vorrang Ablauf/Paywall > Wartung > Mahnhinweis; getrennter Knoten in Wartung behält seine Routen bis Wartungsende; `undrain` durch `resume` ersetzt (beendet Wartung und drain). | Ein Knoten in Wartung kann verbunden sein oder nicht; Reboot ohne 404 für Besucher. | +| 2026-10-02 | **Leases und Befehle umgesetzt:** Lease mit eigenem Signaturpräfix; ungeplant getrennter Knoten zeigt die Wartungsseite bis Lease-Ende; Befehle über eine DB-Warteschlange (CLI ist eigener Prozess); alle Server-Events signiert; `rotate_key` mit Besitznachweis und abbruchsicherer Wiederaufnahme. | Kein 404 bei kurzen Ausfällen; keine Fernsteuerung ohne Server-Signatur; Schlüsselwechsel ohne Aussperren. | +| 2026-10-02 | **Schritt 3 umgesetzt:** Checkmk-Ausgabe als reine Funktion (`pkg/checkmk`); Job bei jedem Refresh (Ablauf, Benachrichtigung, Aufräumen); Benachrichtigungen Slack-kompatibel + ntfy mit Wiederholung bis zur erfolgreichen Zustellung; Community läuft nicht über `valid_until` ab. | Testbar, ohne zusätzlichen Prozess; keine verlorenen Alarme. | +| 2026-10-02 | **Mehrere Server-Adressen:** `--public-url` mehrfach; die Einladung trägt nur die erste; die Liste kommt per signiertem `config`-Befehl bei jedem Verbinden; der Agent weicht der Reihe nach aus und merkt sich die funktionierende Adresse. | Kurze Einladung; Ausfall einer Adresse (DNS, Weiterleitung) ohne Eingriff überbrückt. | +| 2026-10-02 | **Build & Auslieferung:** ein distroless-Image (nonroot) für Server und Agent, Multi-Arch amd64+arm64 per buildx mit Go-Cross-Compile (kein QEMU); Registry ist die Container-Registry von Gitea (`git.everdc.eu/chmln-app-dev/sultan`), ein Vanity-Name (`pods.chmln.de/<image>`) entsteht später per Traefik-Proxy mit `replacePathRegex` auf eine feste Gitea-Organisation (kein Redirect: Docker verwirft den `Authorization`-Header bei Host-Wechsel). Binaries + `SHA256SUMS` statisch unter `sultan/releases/<version>/`, `releases/stable` als Zeiger. Agent-Container mit `network_mode: host`. Vorerst manuell über `deploy/*.sh`, später Gitea Actions bei Tags. | Kein Go-Stack beim Nutzer; ein Image für alle Architekturen; Agent meldet sonst die Container-IP als Backend; Registry ohne zusätzlichen Dienst. | +| 2026-10-02 | **Kleinere Punkte:** Rate-Limit `/enroll` 10/min/IP vor der Signaturprüfung (`-1` = aus); `SULTAN_<FLAG>` für alle Flags, Flags gewinnen; „wieder online“ nur nach Offline-Alarm; Checkmk-Zeiten in Server-Zeitzone. Ein Agent, dessen Enrollment mit 429 scheitert, beendet sich; der Neustart erfolgt über systemd bzw. Docker `restart`. | Schutz vor Durchprobieren; Container-/systemd-freundliche Konfiguration; vollständige Alarmkette. | +| geplant | Geordneter Zertifikatswechsel; optionale Root-CA. (Signierte Server-Events: umgesetzt.) | Zweite Sicherheitsschicht neben dem TLS-Pin; Betrieb ohne Aussperren. | --- @@ -610,36 +808,75 @@ services: | `pkg/policy` | Regel-Parser, Domain-/Routen-/Feature-/Referenz-Prüfung, Namensraum, Stufen & Interceptor-Umleitung, Mahnstufen-Zeitplan, Andockstelle Middleware-Ziele | | `pkg/registry` | Aggregator: Zustand je Knoten → Labels → Policy → Merge; `/v1/traefik-config`-Handler; Audit-Callback | | `pkg/server` | `sultan server`: Datenverzeichnis, Master-Key, HTTP-Routen beider Ports, Knotenstatus, Refresh-Schleife, Graceful Shutdown, Audit-Log über `slog` | -| `pkg/cli`, `main.go` | Befehle `server`, `version`, `help` (`agent`/`token` als Platzhalter) | +| `pkg/cli`, `main.go` | Befehle `server`, `agent`, `token` (Mandanten, Einladungen, Knoten, Lizenzen), `version`, `help` | +| `pkg/docker` | minimaler Docker-Engine-Client (Container, Events) über Unix-Socket | +| `pkg/lander` | Hinweis-, Wartungs- und Einladungsseiten je Stufe; eigene Vorlagen; `Dump` | +| `pkg/agent` | Agent: Enrollment, Stream, Zustand senden, Docker-Watcher, Port-Übersetzung, Backend-Adresse; `/etc/sultan.d`-YAML mit Komfortschaltern und Healthchecks | -**Offen – Bausteine:** +**Offen – Bausteine in dieser Reihenfolge (festgelegt 2026-10-02):** -1. **Sultan-Interceptor / Web-Lander** (5.2): eigener Dienst auf Loopback; je `X-Sultan-Stage` eine HTML-Seite (eingebaut de_DE/en_US oder eigene `html/template`-Vorlage aus dem Datenverzeichnis; Befehl **`dump-landing`** exportiert die eingebauten Seiten als Vorlage zum Anpassen): Mahnhinweis mit `meta refresh` (5/10 s) + Cookie `sultan_ack` (60 min), Paywall/Hoster-Einladung (402), Landing, Wartung (503, `Retry-After: 60`; Community mit Pro-Hinweis). Audit-Log für Policy-Violations (Callback der Registry). -2. **Server-Ergänzungen:** `GET /metrics/checkmk`; Rate-Limit für `/enroll`; Gnadenfrist vor dem Entfernen von Routen beim Trennen (derzeit sofort); Konfiguration auch per Umgebungsvariablen. -3. *(entfällt – umgesetzt in `pkg/server`)* -4. **Leases:** - * Lease bei Enrollment ausstellen und per `lease_renew` erneuern (Master-Key und Übergabe des Public Keys beim Enrollment sind umgesetzt). - * Persistenz → Migration v2: `features` und offene Zahlung (`payment_overdue_since`, daraus die Mahnstufe per `DunningSchedule`) fehlen im Schema; Leases (aktuelle Lease je Knoten). - * Lease-Laufzeiten (kommerziell 7/10 Tage, Community 14 Tage mit Erneuerung ~7 Tage + Streuung), Bindung an Abo-Zyklus (4.1a). -5. **Agent:** - * Docker-Watcher (Events API über `/var/run/docker.sock`) - * File-Provider `/etc/sultan.d/*.yaml` per inotify (Schema 4.1b ausarbeiten, Übersetzung in Labels) - * Healthchecks (Status, `<title>`, Tag mit Text) → `service_down` - * State-Serializer: Ziel-IP LAN oder NetBird `100.64.x.y` - * Senden von `service_state`; Behandlung von `node_command` -6. **Lizenzfristen & Benachrichtigung:** periodischer Job (Status → `expired` an Tag 0, Mahnstufen), Checkmk-Ausgabe (6.1); Community: Webhook; kommerziell: SMTP an Mandanten + Zammad (Webhook/API). -7. **Admin-API (nur kommerziell):** Mandanten, Lizenzen, Zahlungsstatus für Rechnungssystem und Ansible; Authentifizierung per API-Token, TLS über ACME. -8. **CLI:** - * `sultan agent` (Default `--data-dir=./suldata`) - * `sultan token`: Mandant anlegen, Token erzeugen, Lizenz ausstellen, Knoten auflisten/drainen/löschen. Arbeitet direkt auf der SQLite-DB des Servers (WAL erlaubt parallelen Zugriff). - * `sultan dump-landing`: eingebaute Lander-Seiten als Vorlage exportieren. -9. **Betrieb:** - * Strukturiertes Logging (`log/slog`), Graceful Shutdown, Versionsinfo - * Dockerfile (statisch, distroless), systemd-Unit - * CI (`go vet`, `go test -race`, Linter), Releases -10. **Sicherheit (geplant, 3.4):** signierte Server-Events, geordneter Zertifikatswechsel, optionale Root-CA. +**Schritt 1 – Wartung pro Knoten und Dienst** (3a–3e) – ✅ umgesetzt 2026-10-02 (Migration v3) +* Knotenstatus `maintenance` mit Ende: `sultan token node maintenance --id … [--for 4h] [--reason …] [--public-reason …]`, Ende per `node resume`. Standard-Dauer **4 h**; `--for 0` = unbefristet. +* Auch der Knoten selbst kann Wartung an- und abmelden: `sultan agent maintenance on|off [--for] [--reason] [--public-reason]` (signierter Request, z. B. aus Patch-/Reboot-Skripten). +* Pro Dienst: YAML-Feld `maintenance: true` bzw. Container-Label `sultan.maintenance=true`. +* **Grund zweigeteilt:** + * `reason`: intern (Log, Monitoring), nie öffentlich. + * `public_reason`: optional. Wenn angegeben, **ersetzt** er auf der Wartungsseite den Standardtext („bitte versuchen Sie es bald wieder“), z. B. „Wir spielen ein Sicherheitsupdate ein (CVE-1234) – Sicherheit nehmen wir ernst.“ + * Die Wartungsseite zeigt das voraussichtliche Ende; `Retry-After` wird daraus abgeleitet. +* Trennt sich ein Knoten **in Wartung**, bleiben seine Routen als Wartungsseite stehen, bis die Wartung endet. Für ungeplantes Trennen gilt künftig die Lease (Schritt 2). + +**Schritt 2 – Leases, `node_command`, signierte Server-Events** (4a–4e) – ✅ umgesetzt 2026-10-02 (Migration v4) +* **Lease:** pro Knoten, signiert mit dem Master-Key. Der **Server** verlängert per `lease_renew`, solange der Knoten verbunden und der Mandant in Ordnung ist. Laufzeiten wie 4.1a. +* **Wirkung serverseitig:** Ein getrennter Knoten behält seine letzten Routen als **Wartungsseite, bis seine Lease abläuft**; erst dann werden sie entfernt. Sofort entfernt werden sie nur durch `drain`. +* **Beim Agent:** nur Logging, vorerst ohne Konsequenz. Später auch für einen Checkmk-Check auf Kundenseite („Lease läuft bald ab“). +* **`node_command`**, feste Liste ohne beliebige Befehle: + + | Befehl | Agent tut | + |---|---| + | `resync` | sendet sofort seinen Zustand | + | `rotate_key` | neuer `node.key`, mit dem alten signiert gemeldet | + | `drain`, `maintenance`, `resume` | nur Logging (Hinweis) | + | `config` | übernimmt Server-Adressen (Schritt 4) | + + Lokale Hooks (`/etc/sultan.d/hooks/<befehl>`) sind **geparkt**. +* **Server-Events signiert** mit dem Master-Key (Agent kennt den Public Key seit dem Enrollment). Das ist die bisher „geplante zweite Schicht“ aus 3.4. +* Migration v4 wie in 4.3 beschrieben. (Die Wartungsfelder kamen bereits mit v3.) + +**Schritt 3 – Checkmk, Fristen-Job, Benachrichtigungen** (5a–5e) – ✅ umgesetzt 2026-10-02 (Migration v5) +* `GET /metrics/checkmk` (Provider-Port, lokal), Local-Check-Format: + * pro **kommerziellem** Mandanten: Vertragsende (WARN 30 / CRIT 14 Tage) und Mahnstufe + * pro Knoten: online / offline / Wartung, Lease-Restlaufzeit + * Server: Policy-Verstöße der letzten 24 h + * Community-Mandanten bekommen **keine** Vertragsfristen (automatisch neu lizenziert). +* **Zwei getrennte Uhren:** Vertragsende (`valid_until`) für Checkmk-Fristen; überfällige Zahlung (`payment_overdue_since`) für Mahnstufen 8/25/32. Bis zur Admin-API setzt die CLI den Zahlungsstatus: `sultan token tenant payment --id … --overdue-since DATUM | --paid`. +* **Job:** + * setzt `expired` dauerhaft als Status (Nachvollziehbarkeit, Anzeige) + * räumt auf: Einladungen **10 Tage** nach Ablauf bzw. Einlösung, abgelaufene Leases + * verschickt Benachrichtigungen +* **Benachrichtigungen** einmal pro Ereignis, Versand in der DB vermerkt: + * Ereignisse: Fristen 30/14/0 Tage, Mahnstufen 1/2/3, Knoten länger als **15 min** offline (entspricht 900 s DNS-TTL volatiler Subdomains), neue Policy-Verstöße + * Formate zuerst **Slack-kompatibel** (`{"text": …}`, Slack-Markdown; funktioniert auch mit Mattermost/Rocket.Chat/Discord) und **ntfy**. Danach **Matrix** (geplant), SMTP und Zammad (kommerziell). + +**Schritt 4 – Mehrere Server-Adressen** – ✅ umgesetzt 2026-10-02 +* Die Einladung behält **eine** Adresse. +* Der Server verteilt die vollständige Liste per `config`-Event (`--public-url` mehrfach angebbar). +* Der Agent probiert sie der Reihe nach und merkt sich die zuletzt funktionierende. Gefahrlos, weil das Pinning am Fingerprint hängt. + +**Schritt 5 – Betrieb und Installationsskript** (teilweise umgesetzt: `Dockerfile`, `deploy/build.sh`, `deploy/push-image.sh`, `deploy/publish-static.sh`, `deploy/compose/`) +* Strukturiertes Logging (`log/slog`, umgesetzt), Graceful Shutdown (umgesetzt), Versionsinfo +* ✅ Dockerfile (statisch, distroless, Multi-Arch), Build-/Deploy-Skripte, Compose-Vorlagen +* offen: systemd-Unit, `install.sh`, Signatur von `SHA256SUMS` (Ed25519, Schlüssel nur im Runner), Gitea Actions (Tests bei Push, Release bei Tag `v*`), Image-Digest in der veröffentlichten Compose-Vorlage, Vanity-Proxy `pods.chmln.de`, optional Spiegel auf ghcr.io +* Installationsskript für `curl | sh`: Herkunft mit öffentlichem Zertifikat und/oder Prüfsumme bzw. Signatur. Die eigentliche Vertrauensfrage ist das Skript, nicht der Token. + +**Weitere offene Bausteine (ohne feste Reihenfolge):** +* **Lastverteilung einer Domain über mehrere Knoten** (gemeinsamer Load-Balancer statt getrennter Router) – Voraussetzung dafür, dass `drain` seine Stärke ausspielt. +* Admin-API (nur kommerziell): Mandanten, Lizenzen, Zahlungsstatus für Rechnungssystem und Ansible; Authentifizierung per API-Token, TLS über ACME. +* *(umgesetzt 2026-10-02: Rate-Limit für `/enroll`, Umgebungsvariablen für alle Flags, „wieder online“-Meldung, Checkmk mit Sekunden und Zeitzone)* +* Agent: Healthchecks auch für Docker-Container (Docker-Health-Status oder Labels `sultan.healthcheck.*`). +* CLI: `tenant update` (Domains/Tier/Laufzeit ändern), JSON-Ausgabe für Skripte. +* Sicherheit (3.4): geordneter Zertifikatswechsel, optionale Root-CA. +* **Ausblick Monitoring:** Die Zahl der Policy-Verstöße der letzten 24 h (`Sultan_Policy_Violations`) zählt der Server derzeit im Speicher; nach einem Neustart beginnt sie bei 0. Für Checkmk reicht das. Wird sie dauerhaft gebraucht (Auswertung, Audit), ist das eine kleine Erweiterung: Verstöße mit Zeitpunkt in einer Tabelle ablegen und im Aufräum-Job nach der Frist löschen. **Offene Fachfragen (vor Umsetzung klären):** -* Lander-Vorlagen: genauer Unterordner im Datenverzeichnis (z. B. `./suldata/landing/`) und Namensschema der Dateien. * Middleware-Zielbegrenzung: genaue Grenze (Pfadsegment? Zonen-Redirects?), Label-Name, Wechsel vom Beobachtungs- in den Sperrmodus. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..db1a7ca --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +# Nie ins Image bzw. in den Build-Kontext: Schlüssel, private Doku, Build-Ausgaben +suldata/ +**/suldata/ +dist/ +.git/ +.ai/ +Doku/ +CLAUDE.md +.env +*.env +/sultan +deploy/ diff --git a/.gitignore b/.gitignore index 899f360..10e7a93 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ go.work.sum /suldata/ suldata/ /sultan + +# Build-Ausgaben (deploy/build.sh) +/dist/ diff --git a/CLAUDE.md b/CLAUDE.md index c23234e..a6fd126 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,21 +9,36 @@ Maßgeblich ist aktuell [.ai/sultan-system-architecture.md](.ai/sultan-system-ar ## Projektstatus -Go-Modul `sultan` (Go 1.25). `main.go` ist nur eine Hülle um `pkg/cli`; umgesetzt sind `sultan server|version|help`, `agent`/`token` sind Platzhalter. Packages: +Go-Modul `sultan` (Go 1.26). `main.go` ist nur eine Hülle um `pkg/cli`; umgesetzt sind `sultan server|agent|token|dump-landing|version|help`. Packages: - [pkg/traefik](pkg/traefik) – übersetzt flache `traefik.*`-Labels (`FromLabels`) in Traefiks dynamische Konfiguration, führt Knoten-Configs zusammen (`Merge`) und rendert JSON/YAML (`Marshal`) für `/v1/traefik-config`. Traefik *pollt* diesen Endpunkt über seinen HTTP-Provider; Sultan schreibt nie in Traefik und emuliert keinen Docker-Socket. - [pkg/stream](pkg/stream) – Server→Agent-Kanal als **SSE**: `Hub` (Server, ein Stream pro Knoten, Keepalive `:\n\n`, Neuverbindung ersetzt alte mit `ErrReplaced`, `OnConnect`/`OnDisconnect` nur bei echtem Online/Offline), `Client` (Agent, Idle-Timeout 90s, `Run` mit exponentiellem Backoff, `Prepare` zum Signieren), `Decoder`. Authentifizierung macht der Aufrufer vor `Hub.Serve`. Agent→Server (`service_state`) läuft *nicht* über diesen Stream, sondern über eigene Requests. - [pkg/store](pkg/store) – SQLite-Persistenz (Schema Spec 4.3) über `modernc.org/sqlite` (reines Go, kein cgo → statisches Binary). Migrationen sind eine append-only Liste in `store.go`, Version in `PRAGMA user_version`; neuere DB-Version → `ErrSchemaTooNew`. Zeitstempel werden aus Go gesetzt (injizierbare Uhr `Store.now`) und als UTC-Text fester Breite gespeichert, damit SQL-Vergleiche funktionieren – keine `CURRENT_TIMESTAMP`-Defaults verwenden. Enrollment-Tokens nur als SHA-256; `Enroll` verbraucht Token und legt Knoten atomar an. Fehler: `ErrNotFound`/`ErrExists`/`ErrInvalid`/`ErrTokenUsed`/`ErrTokenExpired`. - [pkg/auth](pkg/auth) – Ed25519-Request-Signatur, **in jedem Tier Pflicht** (kein Bypass, auch nicht für `community`/Homelab). `Signer.Sign` (passt als `stream.Client.Prepare`) signiert Methode, Pfad+Query, Zeitstempel, Nonce und Body-Hash (`X-Sultan-*`-Header). `Verifier.Verify` prüft in der Reihenfolge TLS → Signatur → Zeitfenster (±5min) → Nonce → Store, liefert `Identity{Node, Tenant}`; die Knoten-ID für `Hub.Serve` kommt *nur* daraus. `VerifyUnregistered` für Enrollment (Besitznachweis des Schlüssels). Gesperrt (`ErrForbidden`) wird nur bei Quarantäne; Zahlungsverzug/Ablauf/Drain regelt die Policy über das Routing. `StatusCode(err)` für HTTP-Antworten. Schlüsseldateien: PKCS#8-PEM, 0600, werden nie überschrieben. - **Kein Klartext:** `Signer` signiert nur `https`-URLs, `Verifier` lehnt Requests ohne `r.TLS` ab (`ErrInsecureTransport`, 426). Tests für Agent↔Server laufen daher über TLS-Testserver. -- [pkg/crypto](pkg/crypto) – Transport: `LoadOrCreateServerCert(dataDir)` (selbstsigniert ECDSA P-256, keine CA, 20 Jahre, `server.key` 0600; fehlt eine der Dateien → Fehler statt Neuerzeugung), `ServerTLSConfig` (nur TLS 1.3), `ClientTLSConfig(server, *KnownServers, expectedFP)` – Pinning im Handshake: Pin gewinnt; ohne Pin nur mit Token-Fingerprint; ohne beides `ErrUnknownServer` (**kein blindes TOFU**). Enrollment-Token `sltn_enroll_<secret>.<fingerprint>` (`NewEnrollmentToken`/`ParseEnrollmentToken`); in den Store kommt nur `Token.Secret`. Paketname kollidiert mit Stdlib-`crypto` – bei Bedarf beim Import aliasen. +- [pkg/crypto](pkg/crypto) – Transport: `LoadOrCreateServerCert(dataDir)` (selbstsigniert ECDSA P-256, keine CA, 20 Jahre, `server.key` 0600; fehlt eine der Dateien → Fehler statt Neuerzeugung), `ServerTLSConfig` (nur TLS 1.3), `PinnedTLSConfig(fingerprint)` – Pinning im Handshake am Fingerprint, **unabhängig von der Adresse**; ohne Fingerprint `ErrUnknownServer` (kein blindes TOFU). Enrollment-Token `sltn_enroll_<secret>.<fingerprint>[.<base64url(server-url)>]` (`NewEnrollmentToken(fp, url)`/`ParseEnrollmentToken`, `ValidateServerURL`); in den Store kommt nur `Token.Secret`. Paketname kollidiert mit Stdlib-`crypto` – bei Bedarf beim Import aliasen. - [pkg/license](pkg/license) – Lizenz nach Spec 4.1: `Issuer.Issue` (normalisiert + signiert mit Master-Key, Präfix `SULTAN-LICENSE-V1`), `Parse` (strikt, unbekannte Felder abgelehnt) / `Verify`, `AllowsDomain` (`*.x.de` = alle Subdomains, nicht Apex; `*.<tld>` verboten), `AllowsRoutes` (-1 = unbegrenzt), `HasFeature`, `Expired`/`DaysLeft`. -- [pkg/policy](pkg/policy) – `HostsFromRule` (echter Parser für Traefik-Regeln; jeder `||`-Zweig muss Host-beschränkt sein, `HostRegexp` verboten), `StageFor` (blocked/landing/paywall/firemark/watermark, Mahnwesen nur kommerziell) und `Engine.Apply`: prüft Router (Domain, Referenzen, Middleware-Gates, Routenlimit), Namensraum `<tenant>__<node>__<name>`, leitet bei Stufe ≠ normal oder Healthcheck-Ausfall zu `sultan__interceptor` um (Header `X-Sultan-Stage/Tenant/Tier`); bei watermark/firemark zusätzlich Durchlass-Router `sultan__<tenant>__<node>__<router>` per Cookie `sultan_ack=<stufe>`. `DunningSchedule` (8/25/32 Tage) berechnet die Mahnstufe. Andockstelle `MiddlewareTargetCheck` (+ `EnforceMiddlewareTargets`) für die geplante Middleware-Zielbegrenzung; nil = dulden. -- [pkg/registry](pkg/registry) – Aggregator: `Update(nodeID, NodeState)` (vollständiger Schnappschuss: Workloads mit Host+Labels, `DownServices`), `Remove`, `Refresh` (zeitabhängige Stufen, Directory-Änderungen; fail-static bei Fehlern, `ErrUnknownNode` entfernt), `Config`, `Handler` für `/v1/traefik-config`. `OnViolation` meldet jeden Verstoß nur beim ersten Auftreten. Mandant/Lizenz kommen über das `Directory`-Interface (Server-Implementierung: `pkg/server/directory.go`). Verstöße entfernen nur den Router und landen in `Result.Violations`. Ergebnisse mehrerer Knoten mit `traefik.Merge` kombinieren. +- [pkg/policy](pkg/policy) – `HostsFromRule` (echter Parser für Traefik-Regeln; jeder `||`-Zweig muss Host-beschränkt sein, `HostRegexp` verboten), `StageFor` (blocked/landing/paywall/firemark/watermark, Mahnwesen nur kommerziell) und `Engine.Apply`: prüft Router (Domain, Referenzen, Middleware-Gates, Routenlimit), Namensraum `<tenant>__<node>__<name>`, leitet bei Stufe ≠ normal oder Healthcheck-Ausfall zu `sultan__interceptor` um (Header `X-Sultan-Stage/Tenant/Tier`); bei watermark/firemark zusätzlich Durchlass-Router `sultan__<tenant>__<node>__<router>` per Cookie `sultan_ack=<stufe>`. `DunningSchedule` (8/25/32 Tage) berechnet die Mahnstufe. Wartung: `Input.Maintenance` (Knoten) und `Input.ServiceMaintenance` (Label `sultan.maintenance`), Vorrang Ablauf/Paywall > Wartung > Mahnhinweis, Header `X-Sultan-Until`/`X-Sultan-Public-Reason` (URL-kodiert). Andockstelle `MiddlewareTargetCheck` (+ `EnforceMiddlewareTargets`) für die geplante Middleware-Zielbegrenzung; nil = dulden. +- [pkg/registry](pkg/registry) – Aggregator: `Update(nodeID, NodeState)` (vollständiger Schnappschuss: Workloads mit Host+Labels, `DownServices`), `Remove` (der Server ruft es beim Trennen nur ohne aktive Wartung auf; `pruneDisconnected` räumt nach Wartungsende), `Refresh` (zeitabhängige Stufen, Directory-Änderungen; fail-static bei Fehlern, `ErrUnknownNode` entfernt), `Config`, `Handler` für `/v1/traefik-config`. `OnViolation` meldet jeden Verstoß nur beim ersten Auftreten. Mandant/Lizenz kommen über das `Directory`-Interface (Server-Implementierung: `pkg/server/directory.go`). Verstöße entfernen nur den Router und landen in `Result.Violations`. Ergebnisse mehrerer Knoten mit `traefik.Merge` kombinieren. - [pkg/server](pkg/server) – `sultan server`: `New` (Datenverzeichnis 0700, DB, Zertifikat, `master.key`), `Start`/`Run`/`Shutdown` (Hub zuerst schließen, sonst hängen SSE-Requests), Routen beider Ports (`handlers.go`), Knotenstatus über Hub-Callbacks, Refresh-Schleife, Audit-Log „Policy-Verstoß“ per `slog`. Tests laufen gegen echte Ports (`127.0.0.1:0`) mit Test-Agent (Pinning + Signatur). -- [pkg/cli](pkg/cli) – `Run(ctx, args, stdout, stderr) int`; Flags/Defaults von `sultan server`; Version per `-ldflags "-X sultan/pkg/cli.Version=…"`. +- [pkg/cli](pkg/cli) – `Run(ctx, args, stdout, stderr) int`; Flags/Defaults von `sultan server`; `sultan token` in `token.go` (Befehlstabelle `tokenCmds`, `usageError` → Exit 2, `openDataDir` verweigert nicht initialisierte Verzeichnisse). Version per `-ldflags "-X sultan/pkg/cli.Version=…"`. -Was noch fehlt (Lander, Agent, `sultan token`, Leases, Checkmk, Betrieb) und offene Fachfragen: Spec Abschnitt 9. +- [pkg/docker](pkg/docker) – minimaler Docker-Engine-Client über Unix-Socket (`List`, `Events`), bewusst ohne SDK. +- [pkg/agent](pkg/agent) – `sultan agent`: `Enroll` (node.key, Pinning über Einladungs-Fingerprint, node.json mit `server_fingerprint`; Adresse: `--server`/`SULTAN_SERVER` > URL in der Einladung > node.json, neue Adresse wird nach erfolgreichem Verbinden übernommen), `Run` (Stream mit `OnConnect` → Zustand neu senden, Docker-Watcher, Sammelintervall, kein Senden bei Docker-API-Fehler), `Workloads` (Container-Port → veröffentlichter Host-Port, Default-Service, unerreichbare Ports weglassen), `backendHost` (explizit → Interface → Route zum Server). Dazu `/etc/sultan.d`-YAML (`sultand.go`: `ParseServices`, `LoadServiceDir`, `FileWorkloads` → Traefik-Labels; Polling über `dirFingerprint`) und Healthchecks (`health.go`: `checkHealth`, `healthTracker` mit Fehlerschwelle) → `DownServices`. Tests laufen gegen echten `pkg/server` mit gefälschtem Docker-Socket. + +- [pkg/lander](pkg/lander) – Interceptor-Seiten je `X-Sultan-Stage` (eingebettete `templates/*.html`, zweisprachig), Cookie `sultan_ack` bei watermark/firemark, `localTarget` erlaubt nur lokale Pfade (html/template filtert `meta refresh` **nicht**!), eigene Vorlagen aus `<data-dir>/landing` mit eingebettetem CSS/Bild, `Dump`. Läuft im Server auf `--lander-addr`; die Interceptor-URL der Policy folgt der gebundenen Adresse. Test-Server immer mit `LanderAddr: "127.0.0.1:0"` starten (sonst Portkonflikt auf 8081). + +**Leases & Befehle (Schritt 2):** `license.TermsFor`/`IssueLease`/`ParseLease` (Präfix `SULTAN-LEASE-V1`); der Server stellt Leases beim Verbinden und im Refresh aus (`pkg/server/leases.go`: `ensureLease`, `dispatchCommands`, `leaseValid`), getrennte Knoten behalten Routen als Wartungsseite bis Lease-Ende (`Disconnected` → Policy-Stufe maintenance), drain entfernt sofort. Hub signiert alle Events (`Hub.SigningKey`, `stream.SignEvent`/`VerifyEvent`); der Agent verwirft unsignierte (`pkg/agent/events.go`). Befehle: Warteschlange `node_commands` (CLI reiht ein), feste Liste `stream.Cmd*`; `rotate_key` über `node.key.new` + `POST /api/v1/rotate-key` (abbruchsicher). Signer im Agent nur über `currentSigner()` lesen (wird im Betrieb getauscht). Tests mit kurzen Leases über `Config.LeaseDuration`. + +**Monitoring & Jobs (Schritt 3):** [pkg/checkmk](pkg/checkmk) (`Render(State, now)`, reine Funktion, ASCII) hinter `GET /metrics/checkmk`; [pkg/notify](pkg/notify) (`Slack`, `Ntfy`, `Multi`); `pkg/server/jobs.go`: `runJobs` bei jedem Refresh (kommerzielle Verträge → `expired`, Benachrichtigungen über `sendOnce` mit Schlüssel inkl. Bezugsdatum in Tabelle `notifications`, Aufräumen nach 10 Tagen). Policy-Verstöße werden asynchron gemeldet (OnViolation läuft unter der Registry-Sperre). Tests nutzen `Config.Notify` mit einem aufzeichnenden Ziel. + +**Mehrere Adressen (Schritt 4):** `Config.PublicURLs` (gemerkt als `settings.public_urls`, `server.LoadPublicURLs`), Versand per `config`-Befehl in `nodeOnline`; Agent: `Identity.ServerURLs`, `serverCandidates`/`nextServerURL` (Wechsel in `OnError` des Stream-Clients), `a.serverURL` nur über `currentServerURL()` lesen. + +- [deploy/](deploy) – Auslieferung: `Dockerfile` im Root (distroless, nonroot 65532, Multi-Arch, kein `ENTRYPOINT`, `WORKDIR /`), `build.sh` (Binaries amd64/arm64 + `SHA256SUMS` nach `dist/<version>/`), `push-image.sh` (buildx → Gitea-Registry `git.everdc.eu/chmln-app-dev/sultan`), `publish-static.sh` (rsync nach `sultan/releases/<version>/`), `compose/{server,agent}` (Agent mit `network_mode: host`). Version überall aus `git describe`; `-dirty` wird nicht veröffentlicht. `.dockerignore` hält `suldata/`, `.ai/`, `CLAUDE.md` aus dem Build-Kontext. + +**CLI-Umgebung:** `pkg/cli/env.go` – `annotateEnv`/`applyEnv` machen jedes Flag als `SULTAN_<FLAG>` setzbar (nach `fs.Parse`, nur für nicht gesetzte Flags; Listen-Flags `stringList` kommagetrennt). Neue Flags bekommen das automatisch. Rate-Limit: `pkg/server/ratelimit.go` (`EnrollLimit`, `-1` = aus). + +Was noch fehlt: Betrieb (Spec Abschnitt 9, Schritt 5) und offene Fachfragen: Spec Abschnitt 9. Tests werden zuerst als Vorgaben geschrieben, dann implementiert. Zeitabhängige Tests nutzen kurze Intervalle über die Konfigurationsfelder bzw. das injizierbare `Client.sleep`; mit `-race -count=30` auf Stabilität prüfen. @@ -51,7 +66,7 @@ Sultan ersetzt manuell gepflegtes Ingress-Routing (z. B. Nginx Proxy Manager): B - `127.0.0.1:8080` – `GET /v1/traefik-config` (Traefik pollt alle 2–5s; flache Labels aller aktiven Nodes → verschachteltes Traefik-JSON) und `GET /metrics/checkmk` (Checkmk Local-Check-Format). **Sicherheitsmodell (Kerninvarianten):** -- Server-TLS-Zertifikat self-signed (ECDSA P-256, TLS 1.3), Agent pinnt den SHA256-Fingerprint in `known_servers`; der Erstkontakt wird gegen den Fingerprint im Enrollment-Token geprüft (kein blindes TOFU). Kein Agent-Verkehr im Klartext. +- Server-TLS-Zertifikat self-signed (ECDSA P-256, TLS 1.3), Agent pinnt den SHA256-Fingerprint (aus der Einladung, danach `server_fingerprint` in `node.json`) – adressunabhängig, kein blindes TOFU. Kein Agent-Verkehr im Klartext. - Agent-Keys (Ed25519) werden nur lokal erzeugt und verlassen den Host nie; Enrollment tauscht Public Key + einmaligen Token gegen Mandanten-Zuordnung und signierten Capability-Lease. Danach ist jeder Heartbeat mit `node.key` signiert. - Jeder Agent-Payload wird serverseitig gegen die Mandanten-Policy geprüft: Hosts in `Host(...)`-Regeln müssen in `allowed_domains` stehen (sonst ablehnen + Audit-Log), Routen über `max_routes` werden verworfen. - Lizenzen (`license.json`) sind mit dem Ed25519-Server-Master-Key signiert. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4fa25cd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Ein Image für sultan server und sultan agent, Multi-Arch (amd64 + arm64): +# +# docker buildx build --platform linux/amd64,linux/arm64 -t <name> --push . +# docker build -t sultan:latest . # nur die eigene Architektur, lokal +# +# Go kompiliert auf der Build-Plattform für die Zielarchitektur (kein QEMU +# nötig); das Laufzeit-Image enthält nur das statische Binary. + +FROM --platform=$BUILDPLATFORM golang:1.26 AS build +ARG TARGETOS TARGETARCH +ARG VERSION=dev +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \ + go build -trimpath -ldflags "-s -w -X sultan/pkg/cli.Version=${VERSION}" -o /out/sultan . \ + && mkdir -p /out/suldata + +FROM gcr.io/distroless/static-debian12:nonroot +ARG VERSION=dev +LABEL org.opencontainers.image.title="sultan" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.source="https://git.everdc.eu/chmln-app-dev/sultan" +COPY --from=build /out/sultan /usr/local/bin/sultan +# Datenverzeichnis gehört dem nonroot-User (65532); ein Named Volume übernimmt +# das, ein Bind-Mount muss vorher per chown 65532:65532 vorbereitet werden. +COPY --from=build --chown=65532:65532 /out/suldata /suldata +# WORKDIR / → der Default ./suldata ist /suldata; `sultan token …` braucht kein --data-dir. +WORKDIR / +# Kein ENTRYPOINT: Compose ruft "sultan server …" bzw. "sultan agent …" auf +# (Spec 7), `docker compose exec sultan-server sultan token …` funktioniert so ebenfalls. +CMD ["sultan", "help"] diff --git a/README.md b/README.md index 9fd6a2a..d76ee54 100644 --- a/README.md +++ b/README.md @@ -36,28 +36,79 @@ Die ausführliche Architektur steht in [.ai/sultan-system-architecture.md](.ai/s ### Voraussetzungen -- Go (aktuelle stabile Version) +- Zum Bauen: Go (Version laut `go.mod`) bzw. Docker mit buildx - Für den Ingress-Knoten: Traefik v3 - Für Docker-Hosts: Zugriff auf `/var/run/docker.sock` -### Binary bauen +### 1. Quellen holen ```bash git clone ssh://git@git.everdc.eu:10022/chmln-app-dev/sultan.git cd sultan -go build -o sultan . -./sultan version -sudo install -m 0755 sultan /usr/local/bin/sultan ``` +### 2. Binary bauen (amd64 + arm64) + +```bash +go build -o sultan . && ./sultan version # schnell, nur für die eigene Architektur +deploy/build.sh # Release-Binaries für linux/amd64 + linux/arm64 +# → dist/<version>/sultan_linux_amd64, sultan_linux_arm64, SHA256SUMS +sudo install -m 0755 dist/<version>/sultan_linux_amd64 /usr/local/bin/sultan +``` + +Die Version kommt aus `git describe` (Tag, sonst Commit-Hash; `-dirty` bei uncommitteten Änderungen) und steht in `sultan version`. + +### 3. Docker-Image bauen (ein Image für Server *und* Agent, amd64 + arm64) + +```bash +docker build -t sultan:latest . # lokal, eigene Architektur +docker run --rm sultan:latest sultan version +``` + +Das Image ist distroless (nur das statische Binary, ~17 MB) und läuft als nonroot-User `65532`. Die Rolle wählt der Befehl: `sultan server …` bzw. `sultan agent …`. + +### 4. Image in die Gitea-Registry schieben + +```bash +docker login git.everdc.eu # einmalig: Gitea-User + Token mit Scope write:package +deploy/push-image.sh # → git.everdc.eu/chmln-app-dev/sultan:<version> +LATEST=1 deploy/push-image.sh # zusätzlich :latest (nur für Releases) +``` + +Baut per `docker buildx` gleichzeitig für `linux/amd64` und `linux/arm64` (Go kompiliert quer, kein QEMU nötig) und verweigert Versionen mit `-dirty`. Anpassbar über `REGISTRY`, `OWNER`, `PLATFORMS`. + +### 5. Binaries auf den statischen Webhost legen + +```bash +TARGET=deploy@webdocker11:/srv/inst/htdocs deploy/publish-static.sh +STABLE=1 TARGET=… deploy/publish-static.sh # zusätzlich sultan/releases/stable setzen +``` + +Ablage per `rsync` unter `sultan/releases/<version>/` (Binaries + `SHA256SUMS`); ältere Versionen bleiben herunterladbar. Grundlage für das spätere `curl … | bash`. + +### 6. Mit Docker Compose betreiben + +Vorlagen: [deploy/compose/server](deploy/compose/server) (Ingress-Knoten neben Traefik) und [deploy/compose/agent](deploy/compose/agent) (Docker-Host). + +```bash +cp -r deploy/compose/server ~/sultan-server && cd ~/sultan-server +cp .env.example .env && $EDITOR .env # SULTAN_IMAGE, SULTAN_PUBLIC_URL +sudo install -d -m 0700 -o 65532 -g 65532 suldata # Datenverzeichnis für den nonroot-User +docker compose up -d +docker compose exec sultan-server sultan token tenant add --id=… --domains=… +docker compose exec sultan-server sultan token invite --tenant=… +``` + +Der Agent läuft mit `network_mode: host`. Nur so meldet er die Host-IP als Backend-Adresse (statt der Container-IP) und den Hostnamen als Knoten-ID. Für den Docker-Socket braucht er `DOCKER_GID` in der `.env` (`getent group docker | cut -d: -f3`). + ### Server starten (Ingress-Knoten) ```bash -sultan server # Daten landen in ./suldata +sultan server --public-url=https://ingress.example.com:8443 # Daten landen in ./suldata sultan server --help # alle Optionen ``` -Der Server lauscht auf `:8443` für Agents und auf `127.0.0.1:8080` für Traefik (beides per `--agent-addr` / `--provider-addr` änderbar). Im Verzeichnis `./suldata` liegen Datenbank und Schlüssel – bitte sichern 🔑 und nie einchecken. Traefik bindest du über den HTTP-Provider an: +Alle Optionen gehen auch als Umgebungsvariable, z. B. `SULTAN_PUBLIC_URL=… sultan server` (praktisch für Docker und systemd). Der Server lauscht auf `:8443` für Agents und auf `127.0.0.1:8080` für Traefik (beides per `--agent-addr` / `--provider-addr` änderbar). Im Verzeichnis `./suldata` liegen Datenbank und Schlüssel – bitte sichern 🔑 und nie einchecken. Traefik bindest du über den HTTP-Provider an: ```bash --providers.http.endpoint=http://127.0.0.1:8080/v1/traefik-config @@ -66,13 +117,66 @@ Der Server lauscht auf `:8443` für Agents und auf `127.0.0.1:8080` für Traefik ### Agent verbinden (Backend-Host) -1. Auf dem Server mit `sultan token` einen Einladungs-Token erzeugen 🎟️ +1. Auf dem Server einen Mandanten anlegen und eine Einladung erzeugen 🎟️ + +```bash +sultan token tenant add --id=homelab --name="Mein Homelab" --email=ich@example.com --domains="example.com,*.example.com" +sultan token invite --tenant=homelab # gibt die Einladung aus, 24 h gültig +sultan token --help # alle Verwaltungsbefehle +``` + 2. Auf dem Backend-Host den Agent starten: ```bash -sultan agent --server=https://ingress.example.com:8443 --token=<einladungs-token> +SULTAN_TOKEN=<einladung> sultan agent # Adresse steckt in der Einladung (sultan server --public-url=…) +sultan agent --server=https://ingress.example.com:8443 --token=<einladung> # sonst Adresse angeben +# danach genügt: +sultan agent +``` + +Der Agent liest die Container mit `traefik.enable=true` aus Docker und meldet sie an den Server. Wichtig: Der Ingress erreicht nur **veröffentlichte** Ports (`ports: "8081:80"`), nicht solche, die nur auf `127.0.0.1` veröffentlicht sind. Ohne Docker: `--docker-socket=none`. Über welche Adresse der Ingress den Host erreicht, ermittelt der Agent automatisch; für NetBird z. B. `--backend-interface=wt0`. + +**Ohne Docker (Bare-Metal, LAMP …)** 🖥️ beschreibst du deine Dienste in `/etc/sultan.d/*.yaml`: + +```yaml +services: + - name: kunde-a + url: http://127.0.0.1:8080 + hosts: [kunde-a.de] + https: true + redirect_http_to_https: true + redirect_www: to_non_www + healthcheck: { expect_title: "KundeA - Willkommen" } # bei Ausfall: Wartungsseite statt Fehler ``` Beim ersten Verbinden merkt sich der Agent den Fingerprint des Servers – ab dann läuft alles automatisch. 🎉 -Fertige Docker-Compose-Beispiele für Ingress und Backend-Host findest du in der [Architektur-Doku](.ai/sultan-system-architecture.md#7-referenzkonfigurationen). +### Wartung 🔧 + +```bash +sultan agent maintenance on --for 2h --public-reason "Wir spielen ein Sicherheitsupdate ein." # auf dem Knoten +sultan token node maintenance --id=pi --for 2h # oder auf dem Server +sultan token node resume --id=pi # beenden +``` + +Besucher sehen währenddessen die Wartungsseite mit Grund und voraussichtlichem Ende – auch, wenn der Knoten gerade neu startet. + +### Monitoring & Benachrichtigungen 📊 + +```bash +sultan server --notify-slack=https://hooks.slack.com/… --notify-ntfy=https://ntfy.sh/mein-sultan +curl -s http://127.0.0.1:8080/metrics/checkmk # Checkmk Local Checks: Verträge, Zahlungen, Knoten +``` + +Sultan meldet ablaufende Verträge, Mahnstufen, ausgefallene Knoten und Policy-Verstöße – jedes Ereignis genau einmal. + +### Hinweis- und Wartungsseiten 🚧 + +Fällt ein Dienst aus oder läuft ein Abo ab, zeigt Sultan statt eines Fehlers eine freundliche Seite (Deutsch und Englisch). Eigene Gestaltung: + +```bash +sultan dump-landing # Vorlagen nach ./suldata/landing – anpassen, wirkt sofort +sultan server --hoster-name="Mein Hosting" --hoster-url=https://hosting.example +``` + +Fertige Docker-Compose-Vorlagen für Ingress und Backend-Host liegen unter [deploy/compose](deploy/compose). diff --git a/deploy/build.sh b/deploy/build.sh new file mode 100755 index 0000000..e13ffcf --- /dev/null +++ b/deploy/build.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Baut die Release-Binaries für Linux (amd64 + arm64) samt SHA256SUMS. +# +# deploy/build.sh # Version aus `git describe` +# VERSION=v0.1.0 deploy/build.sh +# +# Ausgabe: dist/<version>/sultan_linux_{amd64,arm64} und dist/<version>/SHA256SUMS +set -euo pipefail + +cd "$(dirname "$0")/.." +VERSION="${VERSION:-$(git describe --tags --always --dirty)}" +ARCHS="${ARCHS:-amd64 arm64}" +OUT="dist/${VERSION}" + +rm -rf "$OUT" +mkdir -p "$OUT" +for arch in $ARCHS; do + echo "==> linux/${arch} (${VERSION})" + CGO_ENABLED=0 GOOS=linux GOARCH="$arch" \ + go build -trimpath -ldflags "-s -w -X sultan/pkg/cli.Version=${VERSION}" \ + -o "${OUT}/sultan_linux_${arch}" . +done +(cd "$OUT" && sha256sum sultan_linux_* > SHA256SUMS) + +echo "==> fertig: ${OUT}" +cat "${OUT}/SHA256SUMS" diff --git a/deploy/compose/agent/.env.example b/deploy/compose/agent/.env.example new file mode 100644 index 0000000..0130925 --- /dev/null +++ b/deploy/compose/agent/.env.example @@ -0,0 +1,9 @@ +# Image: lokal gebaut (docker build -t sultan:latest .) oder aus der Registry +SULTAN_IMAGE=sultan:latest +# SULTAN_IMAGE=git.everdc.eu/chmln-app-dev/sultan:v0.1.0 + +# Einladung vom Server: docker compose exec sultan-server sultan token invite --tenant=… +SULTAN_TOKEN= + +# Gruppe des Docker-Sockets: getent group docker | cut -d: -f3 +DOCKER_GID= diff --git a/deploy/compose/agent/compose.yaml b/deploy/compose/agent/compose.yaml new file mode 100644 index 0000000..105faed --- /dev/null +++ b/deploy/compose/agent/compose.yaml @@ -0,0 +1,30 @@ +# sultan agent auf einem Docker-Host (Backend). +# +# cp .env.example .env && $EDITOR .env # SULTAN_TOKEN, DOCKER_GID +# install -d -m 0700 -o 65532 -g 65532 suldata +# docker compose up -d +# +# network_mode: host ist Absicht: +# - Backend-Adresse: der Agent meldet die Host-IP (Route zum Server bzw. +# --backend-interface=wt0), nicht die Container-IP aus dem Bridge-Netz. +# - Knoten-ID: Default ist der Hostname – mit Host-Netz der des Hosts statt +# der zufälligen Container-ID. +services: + sultan-agent: + image: ${SULTAN_IMAGE:-sultan:latest} + container_name: sultan-agent + restart: unless-stopped + network_mode: host + command: ["sultan", "agent", "--data-dir=/suldata", "--config-dir=none"] + environment: + # Ausgabe von `sultan token invite`; nach dem ersten Start nicht mehr nötig, + # stehen lassen ist harmlos. + SULTAN_TOKEN: ${SULTAN_TOKEN:-} + # Lesezugriff auf den Docker-Socket für den nonroot-User (UID 65532). + group_add: + - "${DOCKER_GID:?DOCKER_GID setzen: getent group docker | cut -d: -f3}" + volumes: + # :ro schützt nur die Socket-Datei, nicht die API – der Agent nutzt + # ausschließlich lesende Aufrufe (Container-Liste, Events). + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./suldata:/suldata # node.key (0600) und node.json – persistent halten! diff --git a/deploy/compose/server/.env.example b/deploy/compose/server/.env.example new file mode 100644 index 0000000..88e87aa --- /dev/null +++ b/deploy/compose/server/.env.example @@ -0,0 +1,6 @@ +# Image: lokal gebaut (docker build -t sultan:latest .) oder aus der Registry +SULTAN_IMAGE=sultan:latest +# SULTAN_IMAGE=git.everdc.eu/chmln-app-dev/sultan:v0.1.0 + +# Adresse, unter der Agents den Server erreichen – steckt danach in jeder Einladung +SULTAN_PUBLIC_URL=https://webproxy11.example.de:8443 diff --git a/deploy/compose/server/compose.yaml b/deploy/compose/server/compose.yaml new file mode 100644 index 0000000..0d92bd2 --- /dev/null +++ b/deploy/compose/server/compose.yaml @@ -0,0 +1,27 @@ +# sultan server auf dem Ingress-Knoten (neben Traefik). +# +# cp .env.example .env && $EDITOR .env +# install -d -m 0700 -o 65532 -g 65532 suldata # Datenverzeichnis für den nonroot-User +# docker compose up -d +# docker compose exec sultan-server sultan token tenant add … +# +# Traefik (Host-Netz oder Binary) pollt http://127.0.0.1:8080/v1/traefik-config. +services: + sultan-server: + image: ${SULTAN_IMAGE:-sultan:latest} + container_name: sultan-server + restart: unless-stopped + # Im Container auf allen Interfaces lauschen; nach außen nur 127.0.0.1 veröffentlichen. + command: + - "sultan" + - "server" + - "--data-dir=/suldata" + - "--provider-addr=:8080" + - "--lander-addr=:8081" + - "--public-url=${SULTAN_PUBLIC_URL}" + ports: + - "8443:8443" # Agents (TLS 1.3) + - "127.0.0.1:8080:8080" # Traefik-Provider & Checkmk – Klartext, nie öffentlich + - "127.0.0.1:8081:8081" # Lander (Hinweis-/Wartungsseiten) für Traefik + volumes: + - ./suldata:/suldata # sultan.db, server.key/.crt, master.key – sichern! diff --git a/deploy/publish-static.sh b/deploy/publish-static.sh new file mode 100755 index 0000000..2fd8709 --- /dev/null +++ b/deploy/publish-static.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Baut die Binaries (deploy/build.sh) und lädt sie per rsync auf den statischen +# Webhost (z. B. inst.chmln.de): +# +# TARGET=deploy@webdocker11:/srv/inst/htdocs deploy/publish-static.sh +# STABLE=1 TARGET=… deploy/publish-static.sh # zusätzlich als "stable" markieren +# +# Ablage unter ${TARGET}/sultan/: +# releases/<version>/sultan_linux_{amd64,arm64} +# releases/<version>/SHA256SUMS +# releases/stable → enthält die stabile Version (nur mit STABLE=1) +# +# Ältere Versionen bleiben liegen und damit herunterladbar. +set -euo pipefail + +cd "$(dirname "$0")/.." +: "${TARGET:?TARGET setzen, z. B. TARGET=deploy@webdocker11:/srv/inst/htdocs}" +VERSION="${VERSION:-$(git describe --tags --always --dirty)}" +export VERSION + +case "$VERSION" in +*-dirty) + echo "Arbeitsverzeichnis hat uncommittete Änderungen (${VERSION}) – erst committen." >&2 + exit 1 + ;; +esac + +deploy/build.sh + +stage="$(mktemp -d)" +trap 'rm -rf "$stage"' EXIT +mkdir -p "${stage}/sultan/releases/${VERSION}" +cp "dist/${VERSION}"/* "${stage}/sultan/releases/${VERSION}/" +if [ "${STABLE:-0}" = 1 ]; then + echo "$VERSION" > "${stage}/sultan/releases/stable" +fi +chmod -R a+rX "$stage" + +# Ohne --delete: bereits veröffentlichte Versionen bleiben erhalten. +rsync -rlv --chmod=Da+rx,Fa+r "${stage}/" "${TARGET}/" + +echo "==> veröffentlicht: sultan/releases/${VERSION}" diff --git a/deploy/push-image.sh b/deploy/push-image.sh new file mode 100755 index 0000000..29893a1 --- /dev/null +++ b/deploy/push-image.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Baut das Multi-Arch-Image (amd64 + arm64) und schiebt es in die +# Container-Registry von Gitea. +# +# docker login git.everdc.eu # einmalig, Gitea-User + Token mit write:package +# deploy/push-image.sh # Tag aus `git describe` +# LATEST=1 deploy/push-image.sh # zusätzlich :latest setzen (nur für Releases) +# +# Ergebnis: ${REGISTRY}/${OWNER}/sultan:<version> (und ggf. :latest) +set -euo pipefail + +cd "$(dirname "$0")/.." +REGISTRY="${REGISTRY:-git.everdc.eu}" +OWNER="${OWNER:-chmln-app-dev}" +IMAGE="${REGISTRY}/${OWNER}/sultan" +VERSION="${VERSION:-$(git describe --tags --always --dirty)}" +PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}" + +case "$VERSION" in +*-dirty) + echo "Arbeitsverzeichnis hat uncommittete Änderungen (${VERSION}) – erst committen." >&2 + exit 1 + ;; +esac + +tags=(-t "${IMAGE}:${VERSION}") +if [ "${LATEST:-0}" = 1 ]; then + tags+=(-t "${IMAGE}:latest") +fi + +# Multi-Arch-Builds brauchen einen buildx-Builder mit docker-container-Treiber. +if ! docker buildx inspect sultan-builder >/dev/null 2>&1; then + docker buildx create --name sultan-builder --driver docker-container >/dev/null +fi + +docker buildx build --builder sultan-builder \ + --platform "$PLATFORMS" \ + --build-arg VERSION="$VERSION" \ + "${tags[@]}" \ + --push . + +echo "==> gepusht: ${IMAGE}:${VERSION}" +docker buildx imagetools inspect "${IMAGE}:${VERSION}" | head -n 3 diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go new file mode 100644 index 0000000..7397f61 --- /dev/null +++ b/pkg/agent/agent.go @@ -0,0 +1,411 @@ +// Package agent ist `sultan agent`: Er registriert den Host beim Server +// (Enrollment mit Einladung und Pinning), hält den SSE-Stream offen, +// beobachtet Docker und meldet seinen vollständigen Zustand +// (service_state) – nach jedem (Wieder-)Verbinden und bei jeder Änderung. +// +// Im Datenverzeichnis (Default ./suldata) liegen node.key (0600) und +// node.json (inkl. server_fingerprint, dem Pin des Servers). +package agent + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "sultan/pkg/auth" + "sultan/pkg/crypto" + "sultan/pkg/docker" + "sultan/pkg/policy" + "sultan/pkg/registry" + "sultan/pkg/stream" +) + +// NoDocker als DockerSocket bzw. ConfigDir schaltet die jeweilige Quelle ab. +const NoDocker = "none" + +// Config ist die Konfiguration des Agenten; Nullwerte bekommen Defaults. +type Config struct { + DataDir string // Default ./suldata + ServerURL string // https://ingress:8443 – Override; sonst aus der Einladung bzw. node.json + Token string // Einladung, nur beim ersten Start + NodeID string // Default aus dem Hostnamen + Hostname string // Default os.Hostname() + + DockerSocket string // Default /var/run/docker.sock; "none" schaltet Docker ab + BackendHost string // Adresse, unter der der Ingress die Dienste erreicht + BackendInterface string // alternativ: IP dieses Interfaces (z. B. wt0) + + ConfigDir string // Default /etc/sultan.d (*.yaml, Spec 4.1b); "none" schaltet ab + ConfigPoll time.Duration // Default 5s: Prüfintervall für Änderungen in ConfigDir + + Logger *slog.Logger + Debounce time.Duration // Default 500ms: Änderungen sammeln, bevor gesendet wird + RetryInterval time.Duration // Default 5s: erneuter Sendeversuch nach Fehler + ReconnectMin time.Duration // Default 1s: kleinste Wartezeit vor Neuverbindung +} + +// Agent ist ein startbereiter Agent. +type Agent struct { + cfg Config + log *slog.Logger + nodeIDErr error + + signer *auth.Signer + id Identity + serverURL string // tatsächlich genutzte Adresse (Override oder node.json) + + mu sync.Mutex + fileSpecs []ServiceSpec + health healthTracker + + keyMu sync.RWMutex // schützt signer (rotate_key tauscht ihn im Betrieb) + kick func() // Zustand neu senden (in Run gesetzt) + runCtx context.Context + hc *http.Client +} + +// httpClient liefert den gepinnten Client für den Server. +func (a *Agent) httpClient() *http.Client { + if a.hc == nil { + a.hc = &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(a.id.ServerFingerprint)}} + } + return a.hc +} + +// New prüft die Konfiguration und setzt Defaults; Dateien werden erst von +// Enroll/Run angefasst. +func New(cfg Config) (*Agent, error) { + if cfg.DataDir == "" { + cfg.DataDir = "./suldata" + } + if cfg.DockerSocket == "" { + cfg.DockerSocket = "/var/run/docker.sock" + } + if cfg.ConfigDir == "" { + cfg.ConfigDir = "/etc/sultan.d" + } + if cfg.ConfigPoll <= 0 { + cfg.ConfigPoll = 5 * time.Second + } + if cfg.Logger == nil { + cfg.Logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) + } + if cfg.Debounce <= 0 { + cfg.Debounce = 500 * time.Millisecond + } + if cfg.RetryInterval <= 0 { + cfg.RetryInterval = 5 * time.Second + } + if cfg.ReconnectMin <= 0 { + cfg.ReconnectMin = time.Second + } + if cfg.ServerURL != "" { + u, err := crypto.ValidateServerURL(cfg.ServerURL) + if err != nil { + return nil, fmt.Errorf("agent: --server: %w", err) + } + cfg.ServerURL = u + } + if cfg.Hostname == "" { + cfg.Hostname, _ = os.Hostname() + } + a := &Agent{cfg: cfg, log: cfg.Logger} + if cfg.NodeID != "" { + if !policy.ValidID(cfg.NodeID) { + return nil, fmt.Errorf("agent: Knoten-ID %q ungültig (a–z, 0–9, - und _, kein \"__\")", cfg.NodeID) + } + } else { + a.cfg.NodeID, a.nodeIDErr = DefaultNodeID(cfg.Hostname) + } + return a, nil +} + +// errNotConnected: Der Server nimmt den Zustand erst bei offenem Stream an. +var errNotConnected = errors.New("Stream noch nicht verbunden") + +// Run registriert den Agenten (falls nötig) und arbeitet, bis ctx endet. +func (a *Agent) Run(ctx context.Context) error { + if err := a.Enroll(ctx); err != nil { + return err + } + host, err := backendHost(a.cfg.BackendHost, a.cfg.BackendInterface, a.serverURL) + if err != nil { + return err + } + hc := a.httpClient() + a.runCtx = ctx + // Abgebrochener Schlüsselwechsel? Vor dem Verbinden abschließen. + if _, err := os.Stat(a.path("node.key.new")); err == nil { + if err := a.completeRotation(ctx); err != nil { + a.log.Error("Begonnenen Schlüsselwechsel abschließen", "err", err) + } + } + a.log.Info("Agent gestartet", "node", a.id.NodeID, "server", a.serverURL, "backend", host, + "docker", a.cfg.DockerSocket, "config_dir", a.cfg.ConfigDir) + + trigger := make(chan struct{}, 1) + kick := func() { + select { + case trigger <- struct{}{}: + default: + } + } + a.kick = kick + var sc *stream.Client + sc = &stream.Client{ + URL: a.currentServerURL() + "/api/v1/stream", + HTTPClient: hc, + Prepare: func(r *http.Request) error { return a.currentSigner().Sign(r) }, + MinBackoff: a.cfg.ReconnectMin, + OnConnect: func() { + a.log.Info("Mit Server verbunden") + a.rememberServerURL() + kick() // Server hat die Routen beim Trennen entfernt: Zustand neu senden + }, + OnError: func(err error, retryIn time.Duration) { + // Ausweichweg: nächster Versuch über die nächste bekannte Adresse. + next := a.nextServerURL() + sc.URL = next + "/api/v1/stream" + a.log.Warn("Verbindung zum Server unterbrochen", "err", err, "retry_in", retryIn, "next", next) + }, + } + + var wg sync.WaitGroup + wg.Go(func() { sc.Run(ctx, a.handleEvent) }) + if a.cfg.DockerSocket != NoDocker { + wg.Go(func() { a.watchDocker(ctx, kick) }) + } + if a.cfg.ConfigDir != NoDocker { + wg.Go(func() { a.watchServiceDir(ctx, kick) }) + } + wg.Go(func() { a.sendLoop(ctx, hc, host, trigger, kick) }) + <-ctx.Done() + wg.Wait() + a.log.Info("Agent beendet") + return nil +} + +// sendLoop sendet den Zustand, sobald ausgelöst – gesammelt über Debounce. +func (a *Agent) sendLoop(ctx context.Context, hc *http.Client, host string, trigger <-chan struct{}, kick func()) { + for { + select { + case <-ctx.Done(): + return + case <-trigger: + } + select { + case <-ctx.Done(): + return + case <-time.After(a.cfg.Debounce): + } + + state, err := a.collect(ctx, host) + if err == nil { + err = a.postState(ctx, hc, state) + } + switch { + case err == nil: + a.log.Debug("Zustand gesendet", "workloads", len(state.Workloads)) + case ctx.Err() != nil: + return + case errors.Is(err, errNotConnected): + // Kein erneuter Versuch nötig: OnConnect löst ohnehin aus. + default: + a.log.Warn("Zustand senden fehlgeschlagen", "err", err, "retry_in", a.cfg.RetryInterval) + time.AfterFunc(a.cfg.RetryInterval, kick) + } + } +} + +// collect ermittelt den aktuellen Zustand: Docker-Container und Dienste aus +// ConfigDir samt Healthcheck-Ausfällen. Ohne Docker-Socket (Bare-Metal) +// entfallen die Container; schlägt die Docker-API fehl, wird nichts +// gesendet (sonst würden alle Container-Routen entfernt). +func (a *Agent) collect(ctx context.Context, host string) (registry.NodeState, error) { + state := registry.NodeState{Workloads: []registry.Workload{}} + if a.dockerAvailable() { + containers, err := (&docker.Client{Socket: a.cfg.DockerSocket}).List(ctx) + if err != nil { + return state, err + } + ws, warnings := Workloads(containers, host) + for _, w := range warnings { + a.log.Warn("Container nicht übernommen", "grund", w) + } + state.Workloads = append(state.Workloads, ws...) + } + a.mu.Lock() + specs := a.fileSpecs + a.mu.Unlock() + state.Workloads = append(state.Workloads, FileWorkloads(specs, host)...) + state.DownServices = a.health.down() + return state, nil +} + +func (a *Agent) dockerAvailable() bool { + if a.cfg.DockerSocket == NoDocker { + return false + } + _, err := os.Stat(a.cfg.DockerSocket) + return !errors.Is(err, os.ErrNotExist) +} + +// watchServiceDir prüft ConfigDir regelmäßig auf Änderungen (Polling statt +// inotify: zuverlässig auch bei Bind-Mounts und atomar ersetzten Dateien). +// Bei einer Änderung werden die Dienste neu geladen, die Healthchecks neu +// gestartet und der Zustand gesendet. +func (a *Agent) watchServiceDir(ctx context.Context, kick func()) { + var last string + var stopHealth context.CancelFunc = func() {} + var healthWG sync.WaitGroup + defer func() { stopHealth(); healthWG.Wait() }() + + ticker := time.NewTicker(a.cfg.ConfigPoll) + defer ticker.Stop() + for { + if fp := dirFingerprint(a.cfg.ConfigDir); fp != last { + last = fp + specs, warnings := LoadServiceDir(a.cfg.ConfigDir) + for _, w := range warnings { + a.log.Warn("Dienstdatei fehlerhaft", "grund", w) + } + a.log.Info("Dienste aus Konfigurationsverzeichnis geladen", "dir", a.cfg.ConfigDir, "anzahl", len(specs)) + a.mu.Lock() + a.fileSpecs = specs + a.mu.Unlock() + + stopHealth() + healthWG.Wait() + hctx, cancel := context.WithCancel(ctx) + stopHealth = cancel + keep := map[string]bool{} + for _, s := range specs { + if s.Healthcheck != nil { + keep[s.Name] = true + healthWG.Go(func() { a.healthLoop(hctx, s.Name, *s.Healthcheck, kick) }) + } + } + a.health.retain(keep) + kick() + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func (a *Agent) healthLoop(ctx context.Context, name string, spec HealthSpec, kick func()) { + client := &http.Client{} + ticker := time.NewTicker(spec.Interval.Duration) + defer ticker.Stop() + for { + err := checkHealth(ctx, client, spec) + if ctx.Err() != nil { + return + } + if a.health.report(name, spec.Failures, err == nil) { + if err != nil { + a.log.Warn("Dienst ausgefallen – Wartungsseite aktiv", "service", name, "grund", err) + } else { + a.log.Info("Dienst wieder erreichbar", "service", name) + } + kick() + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +// dirFingerprint fasst Namen und Inhalte aller YAML-Dateien zusammen. +func dirFingerprint(dir string) string { + entries, err := os.ReadDir(dir) + if err != nil { + return "fehlt" + } + h := sha256.New() + for _, e := range entries { + if ext := filepath.Ext(e.Name()); e.IsDir() || (ext != ".yaml" && ext != ".yml") { + continue + } + data, _ := os.ReadFile(filepath.Join(dir, e.Name())) + fmt.Fprintf(h, "%s\x00%d\x00", e.Name(), len(data)) + h.Write(data) + } + return hex.EncodeToString(h.Sum(nil)) +} + +func (a *Agent) postState(ctx context.Context, hc *http.Client, state registry.NodeState) error { + body, err := json.Marshal(state) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.currentServerURL()+"/api/v1/state", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if err := a.currentSigner().Sign(req); err != nil { + return err + } + resp, err := hc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusNoContent: + return nil + case http.StatusConflict: + return errNotConnected + default: + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("Server antwortet %d: %s", resp.StatusCode, strings.TrimSpace(string(msg))) + } +} + +// watchDocker löst bei jedem Container-Event und nach jedem (Wieder-)Aufbau +// des Event-Streams ein erneutes Senden aus. +func (a *Agent) watchDocker(ctx context.Context, kick func()) { + client := &docker.Client{Socket: a.cfg.DockerSocket} + backoff := a.cfg.ReconnectMin + for ctx.Err() == nil { + events, errc := client.Events(ctx) + kick() // verpasste Änderungen nachholen + received := false + for range events { + received = true + kick() + } + err := <-errc + if ctx.Err() != nil { + return + } + if received { + backoff = a.cfg.ReconnectMin + } + a.log.Debug("Docker-Events unterbrochen", "err", err, "retry_in", backoff) + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + backoff = min(backoff*2, 30*time.Second) + } +} diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go new file mode 100644 index 0000000..4a788bc --- /dev/null +++ b/pkg/agent/agent_test.go @@ -0,0 +1,837 @@ +package agent + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "sultan/pkg/auth" + "sultan/pkg/crypto" + "sultan/pkg/docker" + "sultan/pkg/license" + "sultan/pkg/server" + "sultan/pkg/store" + "sultan/pkg/stream" +) + +var ctx = context.Background() + +func eventually(t *testing.T, msg string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !cond() { + if time.Now().After(deadline) { + t.Fatalf("Bedingung nicht erfüllt: %s", msg) + } + time.Sleep(10 * time.Millisecond) + } +} + +// testServer ist ein echter sultan-Server auf freien Ports. +type testServer struct { + *server.Server + dataDir string +} + +func startServer(t *testing.T, dataDir, agentAddr string) *testServer { + return startServerFull(t, dataDir, agentAddr, nil) +} + +func startServerWith(t *testing.T, mutate func(*server.Config)) *testServer { + return startServerFull(t, "", "", mutate) +} + +func startServerFull(t *testing.T, dataDir, agentAddr string, mutate func(*server.Config)) *testServer { + t.Helper() + if dataDir == "" { + dataDir = filepath.Join(t.TempDir(), "server") + } + if agentAddr == "" { + agentAddr = "127.0.0.1:0" + } + cfg := server.Config{ + DataDir: dataDir, AgentAddr: agentAddr, ProviderAddr: "127.0.0.1:0", LanderAddr: "127.0.0.1:0", + RefreshInterval: 20 * time.Millisecond, + Logger: slog.New(slog.DiscardHandler), + } + if mutate != nil { + mutate(&cfg) + } + s, err := server.New(ctx, cfg) + if err != nil { + t.Fatal(err) + } + if err := s.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Shutdown(ctx) }) + return &testServer{Server: s, dataDir: dataDir} +} + +func (s *testServer) url() string { return "https://" + s.AgentAddr() } + +func (s *testServer) db(t *testing.T) *store.Store { + t.Helper() + st, err := store.Open(ctx, filepath.Join(s.dataDir, "sultan.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +// invite legt (falls nötig) den Mandanten an und erzeugt eine Einladung. +func (s *testServer) invite(t *testing.T, tenant string) string { + t.Helper() + st := s.db(t) + if _, err := st.GetTenant(ctx, tenant); err != nil { + if _, err := st.CreateTenant(ctx, store.Tenant{ + ID: tenant, Name: tenant, Email: tenant + "@example.com", Tier: store.TierPro, MaxRoutes: 10, + AllowedDomains: []string{"kunde.de", "*.kunde.de"}, ValidUntil: time.Now().AddDate(1, 0, 0), + }); err != nil { + t.Fatal(err) + } + } + tok, _ := crypto.NewEnrollmentToken(s.Fingerprint(), "") + if err := st.AddEnrollmentToken(ctx, tok.Secret, tenant, time.Now().Add(time.Hour)); err != nil { + t.Fatal(err) + } + return tok.String() +} + +func (s *testServer) traefikConfig(t *testing.T) string { + t.Helper() + resp, err := http.Get("http://" + s.ProviderAddr() + "/v1/traefik-config") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return string(b) +} + +func (s *testServer) nodeStatus(t *testing.T, id string) store.NodeStatus { + n, err := s.db(t).GetNode(ctx, id) + if err != nil { + return "" + } + return n.Status +} + +// fakeDocker bedient /containers/json und /events über einen Unix-Socket. +type fakeDocker struct { + socket string + mu sync.Mutex + containers []docker.Container + subs []chan docker.Event +} + +func newFakeDocker(t *testing.T, containers ...docker.Container) *fakeDocker { + t.Helper() + dir, err := os.MkdirTemp("", "ag") + if err != nil { + t.Fatal(err) + } + f := &fakeDocker{socket: filepath.Join(dir, "d.sock"), containers: containers} + ln, err := net.Listen("unix", f.socket) + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + mux.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + json.NewEncoder(w).Encode(f.containers) + }) + mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) { + ch := make(chan docker.Event, 8) + f.mu.Lock() + f.subs = append(f.subs, ch) + f.mu.Unlock() + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + for { + select { + case <-r.Context().Done(): + return + case ev := <-ch: + json.NewEncoder(w).Encode(ev) + w.(http.Flusher).Flush() + } + } + }) + srv := &http.Server{Handler: mux} + go srv.Serve(ln) + t.Cleanup(func() { srv.Close(); os.RemoveAll(dir) }) + return f +} + +// set ersetzt die Containerliste und meldet ein Event. +func (f *fakeDocker) set(action string, containers ...docker.Container) { + f.mu.Lock() + f.containers = containers + subs := append([]chan docker.Event(nil), f.subs...) + f.mu.Unlock() + for _, ch := range subs { + ch <- docker.Event{Type: "container", Action: action, Actor: docker.Actor{ID: "x"}} + } +} + +func wordpress() docker.Container { + return ctr("wordpress", wpLabels(), pub(80, 8081)) +} + +func testConfig(t *testing.T, dataDir string, s *testServer, token, socket string) Config { + return Config{ + DataDir: dataDir, + ServerURL: s.url(), + Token: token, + NodeID: "node-1", + Hostname: "pi", + DockerSocket: socket, + BackendHost: "100.64.0.42", + Logger: slog.New(slog.DiscardHandler), + Debounce: 20 * time.Millisecond, + RetryInterval: 50 * time.Millisecond, + ReconnectMin: 20 * time.Millisecond, + } +} + +// runAgent startet den Agenten im Hintergrund; stop beendet ihn und +// liefert den Rückgabewert von Run. +func runAgent(t *testing.T, cfg Config) (stop func() error) { + t.Helper() + a, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + actx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- a.Run(actx) }() + var once sync.Once + var result error + stop = func() error { + once.Do(func() { + cancel() + select { + case result = <-done: + case <-time.After(5 * time.Second): + result = errors.New("Agent endet nicht") + } + }) + return result + } + t.Cleanup(func() { stop() }) + return stop +} + +// Der ganze Weg: Enrollment, Stream, Container → Route bei Traefik, +// Container weg → Route weg, Agent weg → offline, Neustart ohne Token. +func TestRun_EndToEnd(t *testing.T) { + s := startServer(t, "", "") + dock := newFakeDocker(t, wordpress()) + dataDir := filepath.Join(t.TempDir(), "suldata") + cfg := testConfig(t, dataDir, s, s.invite(t, "kunde"), dock.socket) + + stop := runAgent(t, cfg) + eventually(t, "Route bei Traefik", func() bool { + c := s.traefikConfig(t) + return strings.Contains(c, "kunde__node-1__wp") && strings.Contains(c, "http://100.64.0.42:8081") + }) + if st := s.nodeStatus(t, "node-1"); st != store.NodeOnline { + t.Errorf("Knotenstatus %q", st) + } + + dock.set("die") // Container gestoppt + eventually(t, "Route nach Containerstopp entfernt", func() bool { return s.traefikConfig(t) == "{}" }) + + dock.set("start", wordpress()) + eventually(t, "Route nach Containerstart zurück", func() bool { + return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") + }) + + if err := stop(); err != nil { + t.Errorf("Run: %v", err) + } + eventually(t, "Knoten offline", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOffline }) + // Ungeplant weg: Wartungsseite, solange die Lease gilt. + eventually(t, "Wartungsseite", func() bool { return strings.Contains(s.traefikConfig(t), "sultan__interceptor") }) + + // Neustart: schon enrollt, kein Token mehr nötig (Einladung ist verbraucht). + cfg.Token = "" + runAgent(t, cfg) + eventually(t, "Route nach Neustart des Agenten wieder normal", func() bool { + c := s.traefikConfig(t) + return strings.Contains(c, "kunde__node-1__wp") && !strings.Contains(c, "sultan__interceptor") + }) +} + +// Nach einem Neustart des Servers verbindet sich der Agent neu und sendet +// seinen Zustand erneut (OnConnect). +func TestRun_ResendsStateAfterServerRestart(t *testing.T) { + serverDir := filepath.Join(t.TempDir(), "server") + s := startServer(t, serverDir, "") + addr := s.AgentAddr() + dock := newFakeDocker(t, wordpress()) + runAgent(t, testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket)) + eventually(t, "Route", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") }) + + s.Shutdown(ctx) + s2 := startServer(t, serverDir, addr) // gleiche Identität, gleiche Adresse + eventually(t, "Route nach Server-Neustart", func() bool { + return strings.Contains(s2.traefikConfig(t), "kunde__node-1__wp") + }) +} + +func TestEnroll_WritesIdentity(t *testing.T) { + s := startServer(t, "", "") + dataDir := filepath.Join(t.TempDir(), "suldata") + a, err := New(testConfig(t, dataDir, s, s.invite(t, "kunde"), "")) + if err != nil { + t.Fatal(err) + } + if err := a.Enroll(ctx); err != nil { + t.Fatalf("Enroll: %v", err) + } + for file, perm := range map[string]os.FileMode{"node.key": 0o600, "node.json": 0o600} { + info, err := os.Stat(filepath.Join(dataDir, file)) + if err != nil || info.Mode().Perm() != perm { + t.Errorf("%s: %v %v", file, info, err) + } + } + var id Identity + b, _ := os.ReadFile(filepath.Join(dataDir, "node.json")) + if err := json.Unmarshal(b, &id); err != nil { + t.Fatal(err) + } + if id.NodeID != "node-1" || id.TenantID != "kunde" || id.Server != s.url() || + id.MasterPublicKey != auth.EncodePublicKey(s.MasterPublicKey()) || id.ServerFingerprint != s.Fingerprint() { + t.Errorf("node.json: %+v", id) + } + if _, err := os.Stat(filepath.Join(dataDir, "known_servers")); err == nil { + t.Error("known_servers ist überflüssig – der Pin steht in node.json") + } + // Zweites Enroll: nichts zu tun, kein Token nötig. + a2, _ := New(Config{DataDir: dataDir, Logger: slog.New(slog.DiscardHandler)}) + if err := a2.Enroll(ctx); err != nil { + t.Errorf("erneutes Enroll ohne Token: %v", err) + } +} + +func TestEnroll_Errors(t *testing.T) { + s := startServer(t, "", "") + other := startServer(t, "", "") + + t.Run("erster Start ohne Token", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "suldata") + a, _ := New(testConfig(t, dir, s, "", "")) + if err := a.Enroll(ctx); !errors.Is(err, ErrNotEnrolled) { + t.Errorf("erwartet ErrNotEnrolled, bekommen %v", err) + } + }) + t.Run("Token ohne URL und ohne --server", func(t *testing.T) { + cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), "") + cfg.ServerURL = "" + a, _ := New(cfg) + if err := a.Enroll(ctx); !errors.Is(err, ErrNotEnrolled) || !strings.Contains(err.Error(), "--server") { + t.Errorf("erwartet Hinweis auf --server, bekommen %v", err) + } + }) + t.Run("Token eines anderen Servers", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "suldata") + a, _ := New(testConfig(t, dir, s, other.invite(t, "kunde"), "")) + if err := a.Enroll(ctx); !errors.Is(err, crypto.ErrFingerprintMismatch) { + t.Errorf("erwartet ErrFingerprintMismatch, bekommen %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "node.json")); err == nil { + t.Error("node.json trotz Fehlschlag geschrieben") + } + }) + t.Run("Einladung verbraucht", func(t *testing.T) { + tok := s.invite(t, "kunde") + a, _ := New(testConfig(t, filepath.Join(t.TempDir(), "a"), s, tok, "")) + if err := a.Enroll(ctx); err != nil { + t.Fatal(err) + } + cfg := testConfig(t, filepath.Join(t.TempDir(), "b"), s, tok, "") + cfg.NodeID = "node-2" + b, _ := New(cfg) + if err := b.Enroll(ctx); err == nil || !strings.Contains(err.Error(), "403") { + t.Errorf("verbrauchte Einladung: %v", err) + } + }) + // Registriert bei s, dann kommt eine Einladung von other: anderer + // Fingerprint = anderer Server = nur mit leerem Datenverzeichnis. + t.Run("Einladung eines anderen Servers trotz Registrierung", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "suldata") + cfg := testConfig(t, dir, s, s.invite(t, "kunde"), "") + cfg.NodeID = "node-wechsel" // node-1 ist auf s schon vergeben + first, _ := New(cfg) + if err := first.Enroll(ctx); err != nil { + t.Fatal(err) + } + switched, _ := New(testConfig(t, dir, other, other.invite(t, "kunde"), "")) + if err := switched.Enroll(ctx); err == nil || !strings.Contains(err.Error(), "anderen Server") { + t.Errorf("Serverwechsel ohne neues Enrollment: %v", err) + } + }) +} + +func TestNew_Validation(t *testing.T) { + dir := t.TempDir() + if _, err := New(Config{DataDir: dir, ServerURL: "http://ingress:8443", Token: "x", NodeID: "n"}); err == nil { + t.Error("http-URL akzeptiert") + } + if _, err := New(Config{DataDir: dir, ServerURL: "https://ingress:8443", NodeID: "a__b"}); err == nil { + t.Error("ungültige Knoten-ID akzeptiert") + } + a, err := New(Config{DataDir: dir, ServerURL: "https://ingress:8443/", Hostname: "Mein.Pi"}) + if err != nil { + t.Fatal(err) + } + if a.cfg.NodeID != "mein-pi" || a.cfg.ServerURL != "https://ingress:8443" || a.cfg.DockerSocket != "/var/run/docker.sock" { + t.Errorf("Defaults: %+v", a.cfg) + } + if def, _ := New(Config{}); def.cfg.DataDir != "./suldata" { + t.Errorf("Default-Datenverzeichnis: %q", def.cfg.DataDir) + } +} + +// Ohne Docker (Bare-Metal) meldet der Agent einen leeren Zustand und bleibt +// verbunden; ein fehlender Socket ist kein Fehler. +func TestRun_WithoutDocker(t *testing.T) { + s := startServer(t, "", "") + cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), "/gibt/es/nicht.sock") + runAgent(t, cfg) + eventually(t, "Knoten online", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline }) + if c := s.traefikConfig(t); c != "{}" { + t.Errorf("Konfiguration ohne Docker: %s", c) + } +} + +// Läuft Run ohne Enrollment-Möglichkeit, endet es sofort mit Fehler. +func TestRun_NotEnrolled(t *testing.T) { + s := startServer(t, "", "") + a, _ := New(testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, "", "")) + if err := a.Run(ctx); !errors.Is(err, ErrNotEnrolled) { + t.Errorf("erwartet ErrNotEnrolled, bekommen %v", err) + } +} + +// Bare-Metal-Weg: Dienst aus /etc/sultan.d → Route; Healthcheck schlägt fehl → +// Wartungsseite (Interceptor); Dienst erholt sich → wieder normal; Datei +// geändert/gelöscht → Konfiguration folgt. +func TestRun_ServiceDirWithHealthcheck(t *testing.T) { + s := startServer(t, "", "") + var healthy atomic.Bool + healthy.Store(true) + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !healthy.Load() { + http.Error(w, "kaputt", http.StatusInternalServerError) + return + } + w.Write([]byte("<html><head><title>Shop

Impressum

")) + })) + defer backend.Close() + port := backend.URL[strings.LastIndex(backend.URL, ":")+1:] + + confDir := t.TempDir() + writeConf := func(content string) { + if err := os.WriteFile(filepath.Join(confDir, "shop.yaml"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + writeConf(` +services: + - name: shop + url: ` + backend.URL + ` + hosts: [shop.kunde.de] + healthcheck: { interval: 30ms, failures: 2, expect_title: Shop } +`) + cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), NoDocker) + cfg.ConfigDir = confDir + cfg.ConfigPoll = 20 * time.Millisecond + runAgent(t, cfg) + + eventually(t, "Route aus sultan.d", func() bool { + c := s.traefikConfig(t) + return strings.Contains(c, "kunde__node-1__shop") && strings.Contains(c, "http://100.64.0.42:"+port) + }) + if strings.Contains(s.traefikConfig(t), "sultan__interceptor") { + t.Error("gesunder Dienst wird umgeleitet") + } + + healthy.Store(false) + eventually(t, "Wartungsseite bei Ausfall", func() bool { + return strings.Contains(s.traefikConfig(t), "sultan__interceptor") + }) + healthy.Store(true) + eventually(t, "zurück zum Dienst nach Erholung", func() bool { + return !strings.Contains(s.traefikConfig(t), "sultan__interceptor") + }) + + writeConf(` +services: + - name: shop + url: ` + backend.URL + ` + hosts: [shop.kunde.de, laden.kunde.de] +`) + eventually(t, "geänderte Datei übernommen", func() bool { + return strings.Contains(s.traefikConfig(t), "laden.kunde.de") + }) + os.Remove(filepath.Join(confDir, "shop.yaml")) + eventually(t, "gelöschte Datei → Route weg", func() bool { return s.traefikConfig(t) == "{}" }) +} + +// Docker- und Datei-Workloads werden gemeinsam gemeldet. +func TestRun_DockerAndServiceDirTogether(t *testing.T) { + s := startServer(t, "", "") + dock := newFakeDocker(t, wordpress()) + confDir := t.TempDir() + os.WriteFile(filepath.Join(confDir, "blog.yaml"), []byte(` +services: + - name: blog + url: http://127.0.0.1:8090 + hosts: [blog.kunde.de] +`), 0o644) + cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket) + cfg.ConfigDir = confDir + cfg.ConfigPoll = 20 * time.Millisecond + runAgent(t, cfg) + eventually(t, "beide Routen", func() bool { + c := s.traefikConfig(t) + return strings.Contains(c, "kunde__node-1__wp") && strings.Contains(c, "kunde__node-1__blog") + }) +} + +// inviteURL erzeugt eine Einladung mit eingebetteter Server-URL. +func (s *testServer) inviteURL(t *testing.T, tenant, serverURL string) string { + t.Helper() + s.invite(t, tenant) // legt den Mandanten bei Bedarf an + tok, err := crypto.NewEnrollmentToken(s.Fingerprint(), serverURL) + if err != nil { + t.Fatal(err) + } + if err := s.db(t).AddEnrollmentToken(ctx, tok.Secret, tenant, time.Now().Add(time.Hour)); err != nil { + t.Fatal(err) + } + return tok.String() +} + +func readIdentity(t *testing.T, dataDir string) Identity { + t.Helper() + var id Identity + b, err := os.ReadFile(filepath.Join(dataDir, "node.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, &id); err != nil { + t.Fatal(err) + } + return id +} + +// Ein einziger Token genügt: die Adresse steckt darin. +func TestEnroll_URLFromToken(t *testing.T) { + s := startServer(t, "", "") + dir := filepath.Join(t.TempDir(), "suldata") + cfg := testConfig(t, dir, s, s.inviteURL(t, "kunde", s.url()), "") + cfg.ServerURL = "" + a, _ := New(cfg) + if err := a.Enroll(ctx); err != nil { + t.Fatalf("Enroll nur mit Token: %v", err) + } + if id := readIdentity(t, dir); id.Server != s.url() || id.ServerFingerprint != s.Fingerprint() { + t.Errorf("node.json: %+v", id) + } +} + +// --server schlägt die URL im Token (z. B. interne Adresse statt öffentlicher). +func TestEnroll_ServerFlagOverridesTokenURL(t *testing.T) { + s := startServer(t, "", "") + dir := filepath.Join(t.TempDir(), "suldata") + cfg := testConfig(t, dir, s, s.inviteURL(t, "kunde", "https://nicht-erreichbar.invalid:8443"), "") + a, _ := New(cfg) // cfg.ServerURL = s.url() + if err := a.Enroll(ctx); err != nil { + t.Fatalf("Enroll mit Override: %v", err) + } + if id := readIdentity(t, dir); id.Server != s.url() { + t.Errorf("gespeicherte Adresse %q, erwartet die tatsächlich genutzte %q", id.Server, s.url()) + } +} + +// Ist der Agent registriert, darf dieselbe Einladung (bzw. eine des +// gleichen Servers) erneut mitgegeben werden – sie wird ignoriert. +func TestEnroll_SameServerTokenIgnoredWhenEnrolled(t *testing.T) { + s := startServer(t, "", "") + dir := filepath.Join(t.TempDir(), "suldata") + tok := s.inviteURL(t, "kunde", s.url()) + cfg := testConfig(t, dir, s, tok, "") + a, _ := New(cfg) + if err := a.Enroll(ctx); err != nil { + t.Fatal(err) + } + again, _ := New(cfg) // gleicher, inzwischen verbrauchter Token (z. B. in docker-compose stehen geblieben) + if err := again.Enroll(ctx); err != nil { + t.Errorf("erneuter Start mit altem Token: %v", err) + } +} + +// Der Pin hängt am Server, nicht an der Adresse: derselbe Server unter +// neuer Adresse funktioniert, die neue Adresse wird übernommen. +func TestRun_AddressChangePersisted(t *testing.T) { + s := startServer(t, "", "") + dock := newFakeDocker(t, wordpress()) + dir := filepath.Join(t.TempDir(), "suldata") + cfg := testConfig(t, dir, s, s.invite(t, "kunde"), dock.socket) + a, _ := New(cfg) + if err := a.Enroll(ctx); err != nil { + t.Fatal(err) + } + + moved := "https://localhost" + s.url()[len("https://127.0.0.1"):] + cfg.Token, cfg.ServerURL = "", moved + runAgent(t, cfg) + eventually(t, "Route über neue Adresse", func() bool { + return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") + }) + eventually(t, "neue Adresse gespeichert", func() bool { return readIdentity(t, dir).Server == moved }) + if id := readIdentity(t, dir); id.ServerFingerprint != s.Fingerprint() { + t.Errorf("Fingerprint verändert: %+v", id) + } +} + +// `sultan agent maintenance on|off` auf dem Knoten, z. B. aus einem Patch-Skript. +func TestSetMaintenance(t *testing.T) { + s := startServer(t, "", "") + dock := newFakeDocker(t, wordpress()) + dir := filepath.Join(t.TempDir(), "suldata") + runAgent(t, testConfig(t, dir, s, s.invite(t, "kunde"), dock.socket)) + eventually(t, "Route", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") }) + + ctl, err := New(Config{DataDir: dir, Logger: slog.New(slog.DiscardHandler)}) + if err != nil { + t.Fatal(err) + } + if err := ctl.SetMaintenance(ctx, true, 2*time.Hour, "Kernel-Update", "Sicherheitsupdate"); err != nil { + t.Fatalf("SetMaintenance: %v", err) + } + n, _ := s.db(t).GetNode(ctx, "node-1") + if n.Maintenance == nil || n.Maintenance.Reason != "Kernel-Update" || n.Maintenance.PublicReason != "Sicherheitsupdate" { + t.Errorf("gespeichert: %+v", n.Maintenance) + } + if !strings.Contains(s.traefikConfig(t), "sultan__interceptor") { + t.Error("Wartung nicht wirksam") + } + if err := ctl.SetMaintenance(ctx, false, 0, "", ""); err != nil { + t.Fatal(err) + } + if strings.Contains(s.traefikConfig(t), "sultan__interceptor") { + t.Error("Wartung nicht beendet") + } + + notEnrolled, _ := New(Config{DataDir: filepath.Join(t.TempDir(), "leer"), Logger: slog.New(slog.DiscardHandler)}) + if err := notEnrolled.SetMaintenance(ctx, true, time.Hour, "", ""); !errors.Is(err, ErrNotEnrolled) { + t.Errorf("nicht registriert: %v", err) + } +} + +func TestRun_LeaseRecorded(t *testing.T) { + s := startServer(t, "", "") + dir := filepath.Join(t.TempDir(), "suldata") + var logs syncBuffer + cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker) + cfg.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + runAgent(t, cfg) + + eventually(t, "lease.json", func() bool { + _, err := os.Stat(filepath.Join(dir, "lease.json")) + return err == nil + }) + b, _ := os.ReadFile(filepath.Join(dir, "lease.json")) + l, err := license.ParseLease(b, s.MasterPublicKey()) + if err != nil || l.NodeID != "node-1" { + t.Errorf("lease.json: %+v %v", l, err) + } + eventually(t, "Lease im Log", func() bool { return strings.Contains(logs.String(), "Lease erhalten") }) +} + +// resync: Der Server fordert den Zustand neu an (hier: Container geändert, +// ohne dass Docker ein Event geschickt hat). +func TestRun_ResyncCommand(t *testing.T) { + s := startServer(t, "", "") + dock := newFakeDocker(t) + runAgent(t, testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket)) + eventually(t, "online", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline }) + + dock.mu.Lock() + dock.containers = []docker.Container{wordpress()} + dock.mu.Unlock() + if err := s.db(t).EnqueueNodeCommand(ctx, "node-1", stream.CmdResync, ""); err != nil { + t.Fatal(err) + } + eventually(t, "Zustand nach resync", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") }) +} + +func TestRun_RotateKeyCommand(t *testing.T) { + s := startServer(t, "", "") + dock := newFakeDocker(t) + dir := filepath.Join(t.TempDir(), "suldata") + runAgent(t, testConfig(t, dir, s, s.invite(t, "kunde"), dock.socket)) + eventually(t, "online", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline }) + before, _ := os.ReadFile(filepath.Join(dir, "node.key")) + oldPub, _ := s.db(t).GetNode(ctx, "node-1") + + s.db(t).EnqueueNodeCommand(ctx, "node-1", stream.CmdRotateKey, "Routine") + eventually(t, "Schlüssel gewechselt", func() bool { + n, _ := s.db(t).GetNode(ctx, "node-1") + after, _ := os.ReadFile(filepath.Join(dir, "node.key")) + return n.PublicKey != oldPub.PublicKey && string(after) != string(before) + }) + if _, err := os.Stat(filepath.Join(dir, "node.key.new")); err == nil { + t.Error("node.key.new nach erfolgreicher Rotation noch vorhanden") + } + // Der Agent arbeitet mit dem neuen Schlüssel weiter. + dock.set("start", wordpress()) + eventually(t, "Zustand mit neuem Schlüssel", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") }) +} + +// Abbruch mitten in der Rotation: Der Server hat den neuen Schlüssel schon, +// der Agent hat node.key.new noch nicht umbenannt. Beim nächsten Start wird +// das sicher abgeschlossen. +func TestRotateKey_RecoveryAfterCrash(t *testing.T) { + s := startServer(t, "", "") + dir := filepath.Join(t.TempDir(), "suldata") + cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker) + a, _ := New(cfg) + if err := a.Enroll(ctx); err != nil { + t.Fatal(err) + } + newKey, _ := auth.GenerateKey() + if err := auth.WritePrivateKey(filepath.Join(dir, "node.key.new"), newKey); err != nil { + t.Fatal(err) + } + newPub := auth.EncodePublicKey(newKey.Public().(ed25519.PublicKey)) + if err := s.db(t).UpdateNodePublicKey(ctx, "node-1", newPub); err != nil { // Server war schon fertig + t.Fatal(err) + } + + cfg.Token = "" + runAgent(t, cfg) + eventually(t, "online mit neuem Schlüssel", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline }) + got, err := auth.ReadPrivateKey(filepath.Join(dir, "node.key")) + if err != nil || !got.Equal(newKey) { + t.Errorf("node.key nicht durch den neuen Schlüssel ersetzt: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "node.key.new")); err == nil { + t.Error("node.key.new noch vorhanden") + } +} + +// Unsignierte oder gefälschte Events werden ignoriert – kein Befehl ohne +// gültige Signatur des Servers. +func TestHandleEvent_RejectsUnsigned(t *testing.T) { + s := startServer(t, "", "") + dir := filepath.Join(t.TempDir(), "suldata") + var logs syncBuffer + cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker) + cfg.Logger = slog.New(slog.NewTextHandler(&logs, nil)) + a, _ := New(cfg) + if err := a.Enroll(ctx); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(filepath.Join(dir, "node.key")) + forged, _ := stream.NewEvent(stream.EventNodeCommand, stream.NodeCommand{Command: stream.CmdRotateKey}) + if err := a.handleEvent(forged); err != nil { + t.Errorf("handleEvent darf den Stream nicht beenden: %v", err) + } + after, _ := os.ReadFile(filepath.Join(dir, "node.key")) + if string(before) != string(after) { + t.Error("unsignierter Befehl wurde ausgeführt") + } + if !strings.Contains(logs.String(), "verworfen") { + t.Errorf("keine Warnung im Log: %s", logs.String()) + } +} + +type syncBuffer struct { + mu sync.Mutex + b strings.Builder +} + +func (s *syncBuffer) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.Write(p) +} +func (s *syncBuffer) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() } + +// Der Server schickt beim Verbinden seine Adressliste; der Agent merkt sie +// sich in node.json (nur gültige https-Adressen). +func TestRun_StoresServerURLsFromConfig(t *testing.T) { + s := startServerWith(t, func(c *server.Config) { + c.PublicURLs = []string{"https://ingress.example:8443", "https://100.64.0.1:8443"} + }) + dir := filepath.Join(t.TempDir(), "suldata") + runAgent(t, testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker)) + eventually(t, "server_urls in node.json", func() bool { + b, err := os.ReadFile(filepath.Join(dir, "node.json")) + var id Identity + return err == nil && json.Unmarshal(b, &id) == nil && len(id.ServerURLs) == 2 + }) + if id := readIdentity(t, dir); id.ServerURLs[0] != "https://ingress.example:8443" || id.Server != s.url() { + t.Errorf("node.json: %+v", id) + } +} + +// Ausweichweg: Die zuletzt genutzte Adresse ist tot, eine andere aus der +// Liste funktioniert – der Agent verbindet sich darüber und merkt sie sich. +func TestRun_FailoverToNextServerURL(t *testing.T) { + s := startServer(t, "", "") + dir := filepath.Join(t.TempDir(), "suldata") + cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker) + a, _ := New(cfg) + if err := a.Enroll(ctx); err != nil { + t.Fatal(err) + } + id := readIdentity(t, dir) + dead := "https://127.0.0.1:1" + id.Server, id.ServerURLs = dead, []string{dead, s.url()} + if err := writeJSON(filepath.Join(dir, "node.json"), id); err != nil { + t.Fatal(err) + } + + cfg.Token, cfg.ServerURL = "", "" + runAgent(t, cfg) + eventually(t, "online über Ausweichadresse", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline }) + eventually(t, "funktionierende Adresse gemerkt", func() bool { return readIdentity(t, dir).Server == s.url() }) +} + +// --server hat Vorrang, die übrigen Adressen dienen als Ausweichweg. +func TestServerCandidates(t *testing.T) { + a := &Agent{cfg: Config{ServerURL: "https://override:8443"}, id: Identity{ + Server: "https://zuletzt:8443", ServerURLs: []string{"https://a:8443", "https://zuletzt:8443", "https://b:8443"}, + }} + want := []string{"https://override:8443", "https://zuletzt:8443", "https://a:8443", "https://b:8443"} + if got := a.serverCandidates(); !reflect.DeepEqual(got, want) { + t.Errorf("Kandidaten %v, erwartet %v", got, want) + } + a.cfg.ServerURL = "" + if got := a.serverCandidates(); got[0] != "https://zuletzt:8443" || len(got) != 3 { + t.Errorf("ohne Override: %v", got) + } +} diff --git a/pkg/agent/events.go b/pkg/agent/events.go new file mode 100644 index 0000000..5212123 --- /dev/null +++ b/pkg/agent/events.go @@ -0,0 +1,175 @@ +package agent + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + + "sultan/pkg/auth" + "sultan/pkg/license" + "sultan/pkg/server" + "sultan/pkg/stream" +) + +// currentSigner liefert den aktuellen Signierer (rotate_key tauscht ihn aus). +func (a *Agent) currentSigner() *auth.Signer { + a.keyMu.RLock() + defer a.keyMu.RUnlock() + return a.signer +} + +func (a *Agent) setSigner(s *auth.Signer) { + a.keyMu.Lock() + a.signer = s + a.keyMu.Unlock() +} + +// handleEvent verarbeitet Events vom Server. Nur Events mit gültiger +// Signatur des Master-Keys werden beachtet; Fehler beenden nie den Stream. +func (a *Agent) handleEvent(ev stream.Event) error { + pub, err := auth.DecodePublicKey(a.id.MasterPublicKey) + if err != nil { + a.log.Error("Master-Key in node.json unlesbar – Event verworfen", "err", err) + return nil + } + if err := stream.VerifyEvent(ev, pub); err != nil { + a.log.Warn("Event ohne gültige Server-Signatur verworfen", "type", ev.Type, "err", err) + return nil + } + switch ev.Type { + case stream.EventLeaseRenew: + a.recordLease(ev.Payload, pub) + case stream.EventNodeCommand: + cmd, err := stream.ParseNodeCommand(ev) + if err != nil { + a.log.Warn("Befehl unlesbar", "err", err) + return nil + } + a.runCommand(cmd) + default: + a.log.Info("Ereignis vom Server", "type", ev.Type) + } + return nil +} + +// recordLease prüft die Lease, legt sie als lease.json ab und protokolliert +// sie (vorerst ohne weitere Konsequenz, Spec 4a). +func (a *Agent) recordLease(doc []byte, pub ed25519.PublicKey) { + l, err := license.ParseLease(doc, pub) + if err != nil { + a.log.Warn("Lease ungültig – verworfen", "err", err) + return + } + if l.NodeID != a.id.NodeID { + a.log.Warn("Lease für anderen Knoten – verworfen", "node", l.NodeID) + return + } + tmp := a.path("lease.json.tmp") + if err := os.WriteFile(tmp, doc, 0o600); err == nil { + err = os.Rename(tmp, a.path("lease.json")) + } + if err != nil { + a.log.Warn("lease.json nicht gespeichert", "err", err) + } + a.log.Info("Lease erhalten", "valid_until", l.ValidUntil, "renew_after", l.RenewAfter, "tier", l.Tier) +} + +func (a *Agent) runCommand(cmd stream.NodeCommand) { + switch cmd.Command { + case stream.CmdResync: + a.log.Info("Server fordert Zustand an (resync)") + if a.kick != nil { + a.kick() + } + case stream.CmdRotateKey: + a.log.Info("Server fordert Schlüsselwechsel an", "reason", cmd.Reason) + ctx := a.runCtx + if ctx == nil { + ctx = context.Background() + } + if err := a.rotateKey(ctx); err != nil { + a.log.Error("Schlüsselwechsel fehlgeschlagen", "err", err) + } + case stream.CmdConfig: + a.updateServerURLs(cmd.ServerURLs) + case stream.CmdDrain, stream.CmdMaintenance, stream.CmdResume: + a.log.Info("Hinweis vom Server", "command", cmd.Command, "reason", cmd.Reason) + default: + a.log.Warn("Unbekannter Befehl ignoriert", "command", cmd.Command) + } +} + +// rotateKey erzeugt einen neuen Schlüssel (node.key.new) und meldet ihn. +func (a *Agent) rotateKey(ctx context.Context) error { + newPath := a.path("node.key.new") + if _, err := os.Stat(newPath); errors.Is(err, os.ErrNotExist) { + key, err := auth.GenerateKey() + if err != nil { + return err + } + if err := auth.WritePrivateKey(newPath, key); err != nil { + return err + } + } + return a.completeRotation(ctx) +} + +// completeRotation meldet node.key.new beim Server und benennt die Datei erst +// danach um. Abbruch-sicher: Lehnt der Server den alten Schlüssel ab (er hat +// den neuen schon), wird mit dem neuen wiederholt. +func (a *Agent) completeRotation(ctx context.Context) error { + newPath := a.path("node.key.new") + newKey, err := auth.ReadPrivateKey(newPath) + if err != nil { + return err + } + body, _ := json.Marshal(server.RotateKeyRequest{ + PublicKey: auth.EncodePublicKey(newKey.Public().(ed25519.PublicKey)), + Proof: auth.RotationProof(a.id.NodeID, newKey), + }) + newSigner := &auth.Signer{Key: newKey} + status, err := a.postSigned(ctx, "/api/v1/rotate-key", body, a.currentSigner()) + if err == nil && status == http.StatusUnauthorized { + status, err = a.postSigned(ctx, "/api/v1/rotate-key", body, newSigner) + } + if err != nil { + return err + } + if status != http.StatusNoContent { + return fmt.Errorf("Server lehnt Schlüsselwechsel ab (%d)", status) + } + if err := os.Rename(newPath, a.path("node.key")); err != nil { + return err + } + a.setSigner(newSigner) + a.log.Info("Knotenschlüssel gewechselt") + return nil +} + +func (a *Agent) postSigned(ctx context.Context, path string, body []byte, signer *auth.Signer) (int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.currentServerURL()+path, bytes.NewReader(body)) + if err != nil { + return 0, err + } + req.Header.Set("Content-Type", "application/json") + if err := signer.Sign(req); err != nil { + return 0, err + } + resp, err := a.httpClient().Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 && resp.StatusCode != http.StatusUnauthorized { + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return resp.StatusCode, fmt.Errorf("Server antwortet %d: %s", resp.StatusCode, strings.TrimSpace(string(msg))) + } + return resp.StatusCode, nil +} diff --git a/pkg/agent/health.go b/pkg/agent/health.go new file mode 100644 index 0000000..d87144c --- /dev/null +++ b/pkg/agent/health.go @@ -0,0 +1,137 @@ +package agent + +import ( + "context" + "fmt" + "io" + "net/http" + "regexp" + "slices" + "strings" + "sync" +) + +var ( + tagRe = regexp.MustCompile(`(?s)<[^>]*>`) + spaceRe = regexp.MustCompile(`\s+`) +) + +// checkHealth ruft spec.URL ab und prüft Status sowie optional und +// ein Element mit Text. Bewusst einfacher Textabgleich statt HTML-Parser: +// Es geht um "liefert die Seite noch das Erwartete", nicht um Validierung. +func checkHealth(ctx context.Context, client *http.Client, spec HealthSpec) error { + ctx, cancel := context.WithTimeout(ctx, spec.Timeout.Duration) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, spec.URL, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != spec.ExpectStatus { + return fmt.Errorf("Status %d statt %d", resp.StatusCode, spec.ExpectStatus) + } + if spec.ExpectTitle == "" && spec.ExpectContains == nil { + return nil + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return err + } + if spec.ExpectTitle != "" { + titles := elementTexts(string(body), "title") + if len(titles) == 0 || titles[0] != strings.TrimSpace(spec.ExpectTitle) { + return fmt.Errorf("<title> %q statt %q", strings.Join(titles, ""), spec.ExpectTitle) + } + } + if c := spec.ExpectContains; c != nil { + for _, text := range elementTexts(string(body), c.Tag) { + if strings.Contains(text, c.Text) { + return nil + } + } + return fmt.Errorf("kein <%s> mit %q", c.Tag, c.Text) + } + return nil +} + +// elementTexts liefert den Textinhalt aller <tag>-Elemente (ohne innere Tags, +// Leerraum zusammengefasst). Groß-/Kleinschreibung des Tags ist egal. +func elementTexts(html, tag string) []string { + t := regexp.QuoteMeta(tag) + re := regexp.MustCompile(`(?is)<` + t + `\b[^>]*>(.*?)</` + t + `\s*>`) + var out []string + for _, m := range re.FindAllStringSubmatch(html, -1) { + text := tagRe.ReplaceAllString(m[1], " ") + out = append(out, strings.TrimSpace(spaceRe.ReplaceAllString(text, " "))) + } + return out +} + +// healthTracker merkt sich den Zustand je Dienst: "down" erst nach +// threshold Fehlschlägen in Folge, "up" nach einem Erfolg. +type healthTracker struct { + mu sync.Mutex + state map[string]*healthState +} + +type healthState struct { + fails int + down bool +} + +// report verbucht ein Ergebnis und meldet, ob sich up/down geändert hat. +func (t *healthTracker) report(name string, threshold int, ok bool) bool { + t.mu.Lock() + defer t.mu.Unlock() + if t.state == nil { + t.state = map[string]*healthState{} + } + st := t.state[name] + if st == nil { + st = &healthState{} + t.state[name] = st + } + if ok { + st.fails = 0 + if st.down { + st.down = false + return true + } + return false + } + st.fails++ + if !st.down && st.fails >= threshold { + st.down = true + return true + } + return false +} + +// down liefert die ausgefallenen Dienste, sortiert (nil, wenn keiner). +func (t *healthTracker) down() []string { + t.mu.Lock() + defer t.mu.Unlock() + var out []string + for name, st := range t.state { + if st.down { + out = append(out, name) + } + } + slices.Sort(out) + return out +} + +// retain vergisst Dienste, die es nicht mehr gibt. +func (t *healthTracker) retain(keep map[string]bool) { + t.mu.Lock() + defer t.mu.Unlock() + for name := range t.state { + if !keep[name] { + delete(t.state, name) + } + } +} diff --git a/pkg/agent/health_test.go b/pkg/agent/health_test.go new file mode 100644 index 0000000..2eda8fe --- /dev/null +++ b/pkg/agent/health_test.go @@ -0,0 +1,98 @@ +package agent + +import ( + "context" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" +) + +const page = `<!doctype html><html><head><TITLE> Shop - Willkommen +

Hallo

Impressum

` + +func healthServer(t *testing.T, status int, body string, delay time.Duration) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if delay > 0 { + select { + case <-time.After(delay): + case <-r.Context().Done(): + return + } + } + w.WriteHeader(status) + w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +func TestCheckHealth(t *testing.T) { + ok := healthServer(t, 200, page, 0) + tests := []struct { + name string + url string + spec HealthSpec + ok bool + }{ + {"nur Status", ok, HealthSpec{ExpectStatus: 200}, true}, + {"Titel passt (Leerraum, Großschreibung des Tags egal)", ok, HealthSpec{ExpectStatus: 200, ExpectTitle: "Shop - Willkommen"}, true}, + {"Tag mit Text, verschachtelt", ok, HealthSpec{ExpectStatus: 200, ExpectContains: &ContainsSpec{Tag: "H2", Text: "Impressum"}}, true}, + {"falscher Status", healthServer(t, 500, page, 0), HealthSpec{ExpectStatus: 200}, false}, + {"anderer erwarteter Status", healthServer(t, 204, "", 0), HealthSpec{ExpectStatus: 204}, true}, + {"Titel weicht ab", ok, HealthSpec{ExpectStatus: 200, ExpectTitle: "Wartung"}, false}, + {"Text fehlt", ok, HealthSpec{ExpectStatus: 200, ExpectContains: &ContainsSpec{Tag: "h2", Text: "Datenschutz"}}, false}, + {"Text im falschen Tag", ok, HealthSpec{ExpectStatus: 200, ExpectContains: &ContainsSpec{Tag: "h1", Text: "Impressum"}}, false}, + {"Zeitüberschreitung", healthServer(t, 200, page, time.Second), HealthSpec{ExpectStatus: 200, Timeout: Duration{50 * time.Millisecond}}, false}, + {"nicht erreichbar", "http://127.0.0.1:1", HealthSpec{ExpectStatus: 200}, false}, + } + for _, tt := range tests { + if tt.spec.Timeout.Duration == 0 { + tt.spec.Timeout = Duration{2 * time.Second} + } + tt.spec.URL = tt.url + err := checkHealth(context.Background(), http.DefaultClient, tt.spec) + if (err == nil) != tt.ok { + t.Errorf("%s: err=%v, erwartet ok=%v", tt.name, err, tt.ok) + } + } +} + +// Ausfall erst nach N Fehlschlägen in Folge, Erholung nach einem Erfolg – +// gegen Flattern. +func TestHealthTracker(t *testing.T) { + var tr healthTracker + steps := []struct { + ok bool + changed bool + down []string + }{ + {true, false, nil}, + {false, false, nil}, + {true, false, nil}, // Zähler zurückgesetzt + {false, false, nil}, + {false, true, []string{"shop"}}, + {false, false, []string{"shop"}}, + {true, true, nil}, + } + for i, s := range steps { + if changed := tr.report("shop", 2, s.ok); changed != s.changed { + t.Errorf("Schritt %d: changed=%v, erwartet %v", i, changed, s.changed) + } + if got := tr.down(); !reflect.DeepEqual(got, s.down) { + t.Errorf("Schritt %d: down=%v, erwartet %v", i, got, s.down) + } + } + + tr.report("a", 1, false) + tr.report("b", 1, false) + if got := tr.down(); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Errorf("sortiert: %v", got) + } + tr.retain(map[string]bool{"b": true}) + if got := tr.down(); !reflect.DeepEqual(got, []string{"b"}) { + t.Errorf("nach retain: %v", got) + } +} diff --git a/pkg/agent/host.go b/pkg/agent/host.go new file mode 100644 index 0000000..bec647a --- /dev/null +++ b/pkg/agent/host.go @@ -0,0 +1,94 @@ +package agent + +import ( + "fmt" + "net" + "net/url" + "regexp" + "strings" + + "sultan/pkg/policy" +) + +// backendHost bestimmt die Adresse, unter der der Ingress die Dienste +// dieses Hosts erreicht: explizit gesetzt, sonst die IP des angegebenen +// Interfaces (z. B. wt0 für NetBird), sonst die lokale IP, über die der +// Server erreicht wird. +func backendHost(explicit, iface, serverURL string) (string, error) { + if explicit != "" { + return explicit, nil + } + if iface != "" { + return interfaceIP(iface) + } + addr, err := hostPort(serverURL) + if err != nil { + return "", err + } + // UDP-"Verbindung" sendet nichts, ermittelt aber die ausgehende Adresse. + conn, err := net.Dial("udp", addr) + if err != nil { + return "", fmt.Errorf("agent: Backend-Adresse ermitteln: %w", err) + } + defer conn.Close() + return conn.LocalAddr().(*net.UDPAddr).IP.String(), nil +} + +func interfaceIP(name string) (string, error) { + ifc, err := net.InterfaceByName(name) + if err != nil { + return "", fmt.Errorf("agent: Interface %s: %w", name, err) + } + addrs, err := ifc.Addrs() + if err != nil { + return "", err + } + var v6 string + for _, a := range addrs { + ipnet, ok := a.(*net.IPNet) + if !ok { + continue + } + if ip4 := ipnet.IP.To4(); ip4 != nil { + return ip4.String(), nil + } + if v6 == "" && !ipnet.IP.IsLinkLocalUnicast() { + v6 = ipnet.IP.String() + } + } + if v6 != "" { + return v6, nil + } + return "", fmt.Errorf("agent: Interface %s hat keine nutzbare IP", name) +} + +// hostPort liefert host:port der Server-URL (Default-Port 443). +func hostPort(serverURL string) (string, error) { + u, err := url.Parse(serverURL) + if err != nil || u.Hostname() == "" { + return "", fmt.Errorf("agent: Server-URL %q ungültig", serverURL) + } + if u.Port() == "" { + return net.JoinHostPort(u.Hostname(), "443"), nil + } + return u.Host, nil +} + +var ( + nodeIDUnsafe = regexp.MustCompile(`[^a-z0-9_-]+`) + nodeIDDashes = regexp.MustCompile(`-{2,}`) + nodeIDUnders = regexp.MustCompile(`_{2,}`) +) + +// DefaultNodeID leitet aus dem Hostnamen eine gültige Knoten-ID ab +// (klein, a–z/0–9/-/_, kein "__"). +func DefaultNodeID(hostname string) (string, error) { + id := nodeIDUnsafe.ReplaceAllString(strings.ToLower(strings.TrimSpace(hostname)), "-") + id = nodeIDDashes.ReplaceAllString(id, "-") + id = nodeIDUnders.ReplaceAllString(id, "_") + id = strings.Trim(id, "-_") + if !policy.ValidID(id) { + return "", fmt.Errorf("agent: aus Hostname %q lässt sich keine Knoten-ID ableiten – --node-id angeben", hostname) + } + return id, nil +} diff --git a/pkg/agent/host_test.go b/pkg/agent/host_test.go new file mode 100644 index 0000000..1b4de8b --- /dev/null +++ b/pkg/agent/host_test.go @@ -0,0 +1,44 @@ +package agent + +import "testing" + +func TestBackendHost(t *testing.T) { + tests := []struct { + name, explicit, iface, server, want string + wantErr bool + }{ + {"explizit", "100.64.0.42", "", "https://127.0.0.1:8443", "100.64.0.42", false}, + {"explizit schlägt Interface", "192.168.1.5", "lo", "https://127.0.0.1:8443", "192.168.1.5", false}, + {"Interface", "", "lo", "https://127.0.0.1:8443", "127.0.0.1", false}, + {"unbekanntes Interface", "", "gibtsnicht0", "https://127.0.0.1:8443", "", true}, + {"automatisch: Route zum Server", "", "", "https://127.0.0.1:8443", "127.0.0.1", false}, + {"automatisch ohne Port", "", "", "https://127.0.0.1", "127.0.0.1", false}, + } + for _, tt := range tests { + got, err := backendHost(tt.explicit, tt.iface, tt.server) + if (err != nil) != tt.wantErr || got != tt.want { + t.Errorf("%s: %q, %v; erwartet %q (Fehler=%v)", tt.name, got, err, tt.want, tt.wantErr) + } + } +} + +func TestDefaultNodeID(t *testing.T) { + tests := map[string]string{ + "pi": "pi", + "Proxmox-VM.42.local": "proxmox-vm-42-local", + "web__01": "web_01", + " -host- ": "host", + "äöü-box": "box", + } + for in, want := range tests { + got, err := DefaultNodeID(in) + if err != nil || got != want { + t.Errorf("DefaultNodeID(%q) = %q, %v; erwartet %q", in, got, err, want) + } + } + for _, bad := range []string{"", "---", "äöü"} { + if _, err := DefaultNodeID(bad); err == nil { + t.Errorf("DefaultNodeID(%q): erwartet Fehler", bad) + } + } +} diff --git a/pkg/agent/identity.go b/pkg/agent/identity.go new file mode 100644 index 0000000..d8cc023 --- /dev/null +++ b/pkg/agent/identity.go @@ -0,0 +1,286 @@ +package agent + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "sultan/pkg/auth" + "sultan/pkg/crypto" + "sultan/pkg/server" +) + +// ErrNotEnrolled: Es gibt noch keine Registrierung und keine (vollständige) Einladung. +var ErrNotEnrolled = errors.New("agent: noch nicht registriert – beim ersten Start --token angeben (und --server, falls die Einladung keine Adresse enthält)") + +// Identity ist das Ergebnis des Enrollments (node.json). ServerFingerprint +// ist der Pin: Er gilt für den Server, nicht für eine Adresse – Server kann +// sich ändern (DNS-Umzug, NetBird), solange das Zertifikat dasselbe ist. +type Identity struct { + Server string `json:"server"` + ServerFingerprint crypto.Fingerprint `json:"server_fingerprint"` + // ServerURLs: weitere Adressen des Servers (per config-Befehl), Ausweichweg. + ServerURLs []string `json:"server_urls,omitempty"` + NodeID string `json:"node_id"` + TenantID string `json:"tenant_id"` + MasterPublicKey string `json:"master_public_key"` +} + +func (a *Agent) path(name string) string { return filepath.Join(a.cfg.DataDir, name) } + +// Enroll stellt sicher, dass der Agent registriert ist: Liegt node.json +// vor, wird sie geladen; sonst wird die Einladung eingelöst. Der Server wird +// dabei gegen den Fingerprint aus der Einladung geprüft. +// +// Server-Adresse: --server vor der Adresse aus der Einladung vor node.json. +func (a *Agent) Enroll(ctx context.Context) error { + if err := os.MkdirAll(a.cfg.DataDir, 0o700); err != nil { + return fmt.Errorf("agent: Datenverzeichnis: %w", err) + } + key, err := loadOrCreateKey(a.path("node.key")) + if err != nil { + return err + } + a.setSigner(&auth.Signer{Key: key}) + + if data, err := os.ReadFile(a.path("node.json")); err == nil { + var id Identity + if err := json.Unmarshal(data, &id); err != nil { + return fmt.Errorf("agent: node.json beschädigt: %w", err) + } + if id.ServerFingerprint == "" { + return errors.New("agent: node.json ohne server_fingerprint – Datenverzeichnis leeren und neu einladen") + } + if a.cfg.Token != "" { + // Eine stehen gebliebene Einladung desselben Servers ist harmlos; + // eine Einladung eines anderen Servers ist ein Fehler. + if tok, err := crypto.ParseEnrollmentToken(a.cfg.Token); err == nil && tok.Fingerprint != id.ServerFingerprint { + return fmt.Errorf("agent: bereits bei einem anderen Server registriert (Fingerprint %s) – für den neuen Server das Datenverzeichnis leeren und neu einladen", id.ServerFingerprint) + } + } + a.id = id + a.serverURL = id.Server + if a.cfg.ServerURL != "" { + a.serverURL = a.cfg.ServerURL + } + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + + if a.cfg.Token == "" { + return ErrNotEnrolled + } + if a.nodeIDErr != nil { + return a.nodeIDErr + } + tok, err := crypto.ParseEnrollmentToken(a.cfg.Token) + if err != nil { + return err + } + serverURL := a.cfg.ServerURL + if serverURL == "" { + serverURL = tok.ServerURL + } + if serverURL == "" { + return fmt.Errorf("%w: die Einladung enthält keine Server-Adresse – --server angeben", ErrNotEnrolled) + } + hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(tok.Fingerprint)}} + body, _ := json.Marshal(server.EnrollRequest{Token: tok.Secret, NodeID: a.cfg.NodeID, Hostname: a.cfg.Hostname}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, serverURL+"/api/v1/enroll", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if err := a.currentSigner().Sign(req); err != nil { + return err + } + resp, err := hc.Do(req) + if err != nil { + return fmt.Errorf("agent: Enrollment: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("agent: Enrollment abgelehnt (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg))) + } + var er server.EnrollResponse + if err := json.NewDecoder(resp.Body).Decode(&er); err != nil { + return fmt.Errorf("agent: Enrollment-Antwort: %w", err) + } + a.id = Identity{ + Server: serverURL, ServerFingerprint: tok.Fingerprint, + NodeID: er.NodeID, TenantID: er.TenantID, MasterPublicKey: er.MasterPublicKey, + } + a.serverURL = serverURL + if err := writeJSON(a.path("node.json"), a.id); err != nil { + return err + } + a.log.Info("Registriert", "node", er.NodeID, "tenant", er.TenantID, "server", serverURL) + return nil +} + +// serverCandidates liefert die Adressen in Versuchsreihenfolge: --server, +// zuletzt funktionierende Adresse, dann die Liste des Servers. +func (a *Agent) serverCandidates() []string { + var out []string + for _, u := range append([]string{a.cfg.ServerURL, a.id.Server}, a.id.ServerURLs...) { + if u != "" && !slices.Contains(out, u) { + out = append(out, u) + } + } + return out +} + +func (a *Agent) currentServerURL() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.serverURL +} + +// nextServerURL wechselt nach einem Verbindungsfehler zur nächsten Adresse. +func (a *Agent) nextServerURL() string { + a.mu.Lock() + defer a.mu.Unlock() + cands := a.serverCandidates() + next := cands[(slices.Index(cands, a.serverURL)+1)%len(cands)] + a.serverURL = next + return next +} + +// updateServerURLs übernimmt die Adressliste des Servers (nur gültige). +func (a *Agent) updateServerURLs(urls []string) { + var valid []string + for _, u := range urls { + if v, err := crypto.ValidateServerURL(u); err == nil && !slices.Contains(valid, v) { + valid = append(valid, v) + } else if err != nil { + a.log.Warn("Ungültige Server-Adresse ignoriert", "url", u) + } + } + a.mu.Lock() + defer a.mu.Unlock() + if slices.Equal(valid, a.id.ServerURLs) { + return + } + updated := a.id + updated.ServerURLs = valid + if err := writeJSON(a.path("node.json"), updated); err != nil { + a.log.Warn("Server-Adressen nicht gespeichert", "err", err) + return + } + a.id = updated + a.log.Info("Server-Adressen übernommen", "server_urls", valid) +} + +// rememberServerURL übernimmt eine abweichende, erfolgreich genutzte +// Adresse in node.json (der Pin bleibt unverändert). +func (a *Agent) rememberServerURL() { + a.mu.Lock() + defer a.mu.Unlock() + if a.id.Server == a.serverURL { + return + } + updated := a.id + updated.Server = a.serverURL + if err := writeJSON(a.path("node.json"), updated); err != nil { + a.log.Warn("Neue Server-Adresse nicht gespeichert", "err", err) + return + } + a.id = updated + a.log.Info("Neue Server-Adresse übernommen", "server", a.serverURL) +} + +func loadOrCreateKey(path string) (ed25519.PrivateKey, error) { + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + key, err := auth.GenerateKey() + if err != nil { + return nil, err + } + if err := auth.WritePrivateKey(path, key); err != nil { + return nil, err + } + } + return auth.ReadPrivateKey(path) +} + +// writeJSON schreibt atomar (temporäre Datei + rename) mit Rechten 0600. +func writeJSON(path string, v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// loadIdentity lädt Identität und Schlüssel eines registrierten Agenten, +// ohne etwas anzulegen (für Befehle neben dem laufenden Agenten). +func (a *Agent) loadIdentity() error { + data, err := os.ReadFile(a.path("node.json")) + if errors.Is(err, os.ErrNotExist) { + return ErrNotEnrolled + } + if err != nil { + return err + } + if err := json.Unmarshal(data, &a.id); err != nil { + return fmt.Errorf("agent: node.json beschädigt: %w", err) + } + key, err := auth.ReadPrivateKey(a.path("node.key")) + if err != nil { + return err + } + a.setSigner(&auth.Signer{Key: key}) + a.serverURL = a.id.Server + if a.cfg.ServerURL != "" { + a.serverURL = a.cfg.ServerURL + } + return nil +} + +// SetMaintenance meldet beim Server Wartung für diesen Knoten an (on) oder +// ab. d = 0 bedeutet unbefristet; reason ist intern, publicReason erscheint +// auf der Wartungsseite. +func (a *Agent) SetMaintenance(ctx context.Context, on bool, d time.Duration, reason, publicReason string) error { + if err := a.loadIdentity(); err != nil { + return err + } + req := server.MaintenanceRequest{Enabled: on} + if on { + req.Duration, req.Reason, req.PublicReason = d.String(), reason, publicReason + } + body, _ := json.Marshal(req) + hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.serverURL+"/api/v1/maintenance", bytes.NewReader(body)) + if err != nil { + return err + } + hreq.Header.Set("Content-Type", "application/json") + if err := a.currentSigner().Sign(hreq); err != nil { + return err + } + hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(a.id.ServerFingerprint)}} + resp, err := hc.Do(hreq) + if err != nil { + return fmt.Errorf("agent: Wartung melden: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("agent: Server lehnt Wartung ab (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg))) + } + return nil +} diff --git a/pkg/agent/sultand.go b/pkg/agent/sultand.go new file mode 100644 index 0000000..320fc35 --- /dev/null +++ b/pkg/agent/sultand.go @@ -0,0 +1,303 @@ +package agent + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "time" + "unicode" + "unicode/utf8" + + "gopkg.in/yaml.v3" + + "sultan/pkg/license" + "sultan/pkg/registry" +) + +// ServiceSpec ist ein Dienst aus /etc/sultan.d/*.yaml (Spec 4.1b) – für +// Bare-Metal-/LAMP-Hosts ohne Docker-Labels. +type ServiceSpec struct { + Name string `yaml:"name"` + URL string `yaml:"url"` // lokaler Dienst; sein Port gilt auch für den Ingress + Hosts []string `yaml:"hosts"` + HTTPS bool `yaml:"https"` + CertResolver string `yaml:"certresolver"` + EntryPoints []string `yaml:"entrypoints"` // Default websecure (https) bzw. web + RedirectHTTPToHTTPS bool `yaml:"redirect_http_to_https"` + RedirectWWW string `yaml:"redirect_www"` // none | to_www | to_non_www + Healthcheck *HealthSpec `yaml:"healthcheck"` + Maintenance bool `yaml:"maintenance"` // Dienst in Wartung (Wartungsseite) + MaintenancePublic string `yaml:"maintenance_public_reason"` // optional, ersetzt den Standardtext + Labels map[string]string `yaml:"labels"` // rohe traefik.*-Labels, überschreiben die erzeugten +} + +// HealthSpec beschreibt den Healthcheck eines Dienstes. +type HealthSpec struct { + URL string `yaml:"url"` // Default: URL des Dienstes + Interval Duration `yaml:"interval"` // Default 30s + Timeout Duration `yaml:"timeout"` // Default 5s + Failures int `yaml:"failures"` // Fehlschläge in Folge bis "down", Default 2 + ExpectStatus int `yaml:"expect_status"` + ExpectTitle string `yaml:"expect_title"` + ExpectContains *ContainsSpec `yaml:"expect_contains"` +} + +// ContainsSpec: Ein Element muss Text enthalten. +type ContainsSpec struct { + Tag string `yaml:"tag"` + Text string `yaml:"text"` +} + +// Duration liest Go-Dauern wie "30s" aus YAML. +type Duration struct{ time.Duration } + +func (d *Duration) UnmarshalYAML(n *yaml.Node) error { + var s string + if err := n.Decode(&s); err != nil { + return err + } + v, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("Dauer %q nicht lesbar (z. B. 30s, 1m)", s) + } + d.Duration = v + return nil +} + +var serviceNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$`) + +// ParseServices liest eine Datei; unbekannte Felder sind Fehler. +func ParseServices(data []byte) ([]ServiceSpec, error) { + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + var f struct { + Services []ServiceSpec `yaml:"services"` + } + if err := dec.Decode(&f); err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + seen := map[string]bool{} + for i := range f.Services { + s := &f.Services[i] + if err := s.normalize(); err != nil { + return nil, fmt.Errorf("Dienst %d (%q): %w", i+1, s.Name, err) + } + if seen[s.Name] { + return nil, fmt.Errorf("Dienst %q doppelt", s.Name) + } + seen[s.Name] = true + } + return f.Services, nil +} + +func (s *ServiceSpec) normalize() error { + if !serviceNameRe.MatchString(s.Name) { + return errors.New("name fehlt oder ungültig (a–z, 0–9, - und _)") + } + u, err := url.Parse(s.URL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" { + return fmt.Errorf("url %q muss eine http(s)-URL sein", s.URL) + } + if len(s.Hosts) == 0 { + return errors.New("hosts fehlt") + } + for i, h := range s.Hosts { + n, err := license.NormalizeDomain(h) + if err != nil || strings.HasPrefix(n, "*.") { + return fmt.Errorf("Host %q ungültig (keine Wildcards)", h) + } + s.Hosts[i] = n + } + switch s.RedirectWWW { + case "", "none", "to_www", "to_non_www": + default: + return fmt.Errorf("redirect_www %q ungültig (none, to_www, to_non_www)", s.RedirectWWW) + } + if utf8.RuneCountInString(s.MaintenancePublic) > 300 || strings.ContainsFunc(s.MaintenancePublic, unicode.IsControl) { + return errors.New("maintenance_public_reason: einzeilig, höchstens 300 Zeichen") + } + if s.RedirectHTTPToHTTPS && !s.HTTPS { + return errors.New("redirect_http_to_https erfordert https: true") + } + if h := s.Healthcheck; h != nil { + if h.URL == "" { + h.URL = s.URL + } + if h.Interval.Duration <= 0 { + h.Interval.Duration = 30 * time.Second + } + if h.Timeout.Duration <= 0 { + h.Timeout.Duration = 5 * time.Second + } + if h.Failures <= 0 { + h.Failures = 2 + } + if h.ExpectStatus == 0 { + h.ExpectStatus = 200 + } + if h.ExpectStatus < 100 || h.ExpectStatus > 599 { + return fmt.Errorf("expect_status %d ungültig", h.ExpectStatus) + } + if c := h.ExpectContains; c != nil && (c.Tag == "" || c.Text == "") { + return errors.New("expect_contains braucht tag und text") + } + } + return nil +} + +// LoadServiceDir liest alle *.yaml/*.yml eines Verzeichnisses (alphabetisch). +// Fehlerhafte Dateien und doppelte Dienstnamen werden mit Warnung +// übersprungen, damit eine kaputte Datei nicht alle Dienste abschaltet. Ein +// fehlendes Verzeichnis bedeutet: keine Dienste. +func LoadServiceDir(dir string) ([]ServiceSpec, []string) { + entries, err := os.ReadDir(dir) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, []string{err.Error()} + } + var specs []ServiceSpec + var warnings []string + origin := map[string]string{} + for _, e := range entries { + ext := filepath.Ext(e.Name()) + if e.IsDir() || (ext != ".yaml" && ext != ".yml") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", e.Name(), err)) + continue + } + parsed, err := ParseServices(data) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", e.Name(), err)) + continue + } + for _, s := range parsed { + if first, dup := origin[s.Name]; dup { + warnings = append(warnings, fmt.Sprintf("%s: Dienst %q schon in %s definiert – übersprungen", e.Name(), s.Name, first)) + continue + } + origin[s.Name] = e.Name() + specs = append(specs, s) + } + } + return specs, warnings +} + +// FileWorkloads übersetzt Dienste in Workloads mit Traefik-Labels; danach +// gelten dieselben Regeln (FromLabels, Policy) wie für Docker-Container. +func FileWorkloads(specs []ServiceSpec, host string) []registry.Workload { + var out []registry.Workload + for _, s := range specs { + out = append(out, registry.Workload{Name: "sultan.d/" + s.Name, Host: host, Labels: s.labels()}) + } + return out +} + +func (s ServiceSpec) labels() map[string]string { + u, _ := url.Parse(s.URL) + port := u.Port() + if port == "" { + port = map[string]string{"http": "80", "https": "443"}[u.Scheme] + } + scheme := "http" + if s.HTTPS { + scheme = "https" + } + + hosts := slices.Clone(s.Hosts) + var bases []string + if s.RedirectWWW == "to_www" || s.RedirectWWW == "to_non_www" { + for _, h := range s.Hosts { + if b := strings.TrimPrefix(h, "www."); !slices.Contains(bases, b) { + bases = append(bases, b) + } + } + // Beide Varianten müssen beim Router ankommen, sonst greift die + // Umleitung nie. + for _, b := range bases { + for _, h := range []string{b, "www." + b} { + if !slices.Contains(hosts, h) { + hosts = append(hosts, h) + } + } + } + } + var rules []string + for _, h := range hosts { + rules = append(rules, "Host(`"+h+"`)") + } + rule := strings.Join(rules, " || ") + + entryPoints := s.EntryPoints + if len(entryPoints) == 0 { + entryPoints = []string{"web"} + if s.HTTPS { + entryPoints = []string{"websecure"} + } + } + + n := s.Name + router := "traefik.http.routers." + n + "." + l := map[string]string{ + "traefik.enable": "true", + router + "rule": rule, + router + "entrypoints": strings.Join(entryPoints, ","), + router + "service": n, + "traefik.http.services." + n + ".loadbalancer.server.port": port, + } + if u.Scheme == "https" { + l["traefik.http.services."+n+".loadbalancer.server.scheme"] = "https" + } + if s.HTTPS { + l[router+"tls"] = "true" + if s.CertResolver != "" { + l[router+"tls.certresolver"] = s.CertResolver + } + } + if len(bases) > 0 { + alts := make([]string, len(bases)) + for i, b := range bases { + alts[i] = regexp.QuoteMeta(b) + } + mw := "traefik.http.middlewares." + n + "-www.redirectregex." + if s.RedirectWWW == "to_non_www" { + l[mw+"regex"] = `^https?://www\.(` + strings.Join(alts, "|") + `)(.*)` + l[mw+"replacement"] = scheme + "://${1}${2}" + } else { + l[mw+"regex"] = `^https?://(` + strings.Join(alts, "|") + `)(.*)` + l[mw+"replacement"] = scheme + "://www.${1}${2}" + } + l[mw+"permanent"] = "true" + l[router+"middlewares"] = n + "-www" + } + if s.RedirectHTTPToHTTPS { + httpRouter := "traefik.http.routers." + n + "-http." + l[httpRouter+"rule"] = rule + l[httpRouter+"entrypoints"] = "web" + l[httpRouter+"service"] = n + l[httpRouter+"middlewares"] = n + "-https" + l["traefik.http.middlewares."+n+"-https.redirectscheme.scheme"] = "https" + l["traefik.http.middlewares."+n+"-https.redirectscheme.permanent"] = "true" + } + if s.Maintenance { + l[registry.LabelMaintenance] = "true" + if s.MaintenancePublic != "" { + l[registry.LabelMaintenancePublicReason] = s.MaintenancePublic + } + } + for k, v := range s.Labels { + l[k] = v + } + return l +} diff --git a/pkg/agent/sultand_test.go b/pkg/agent/sultand_test.go new file mode 100644 index 0000000..dcad395 --- /dev/null +++ b/pkg/agent/sultand_test.go @@ -0,0 +1,236 @@ +package agent + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "sultan/pkg/traefik" +) + +func parse(t *testing.T, yaml string) []ServiceSpec { + t.Helper() + specs, err := ParseServices([]byte(yaml)) + if err != nil { + t.Fatalf("ParseServices: %v", err) + } + return specs +} + +func TestFileWorkloads_Minimal(t *testing.T) { + specs := parse(t, ` +services: + - name: shop + url: http://127.0.0.1:8080 + hosts: [shop.kunde.de] +`) + ws := FileWorkloads(specs, "100.64.0.42") + want := map[string]string{ + "traefik.enable": "true", + "traefik.http.routers.shop.rule": "Host(`shop.kunde.de`)", + "traefik.http.routers.shop.entrypoints": "web", + "traefik.http.routers.shop.service": "shop", + "traefik.http.services.shop.loadbalancer.server.port": "8080", + } + if len(ws) != 1 || ws[0].Name != "sultan.d/shop" || ws[0].Host != "100.64.0.42" || !reflect.DeepEqual(ws[0].Labels, want) { + t.Errorf("Workloads = %+v", ws) + } +} + +func TestFileWorkloads_ComfortSwitches(t *testing.T) { + specs := parse(t, ` +services: + - name: kunde-a + url: https://127.0.0.1:8443/ + hosts: [Kunde-A.de] + https: true + certresolver: dns-resolver + redirect_http_to_https: true + redirect_www: to_non_www + labels: + traefik.http.middlewares.kunde-a-www.redirectregex.permanent: "false" +`) + rule := "Host(`kunde-a.de`) || Host(`www.kunde-a.de`)" + want := map[string]string{ + "traefik.enable": "true", + "traefik.http.routers.kunde-a.rule": rule, + "traefik.http.routers.kunde-a.entrypoints": "websecure", + "traefik.http.routers.kunde-a.service": "kunde-a", + "traefik.http.routers.kunde-a.tls": "true", + "traefik.http.routers.kunde-a.tls.certresolver": "dns-resolver", + "traefik.http.routers.kunde-a.middlewares": "kunde-a-www", + "traefik.http.services.kunde-a.loadbalancer.server.port": "8443", + "traefik.http.services.kunde-a.loadbalancer.server.scheme": "https", + "traefik.http.routers.kunde-a-http.rule": rule, + "traefik.http.routers.kunde-a-http.entrypoints": "web", + "traefik.http.routers.kunde-a-http.service": "kunde-a", + "traefik.http.routers.kunde-a-http.middlewares": "kunde-a-https", + "traefik.http.middlewares.kunde-a-https.redirectscheme.scheme": "https", + "traefik.http.middlewares.kunde-a-https.redirectscheme.permanent": "true", + "traefik.http.middlewares.kunde-a-www.redirectregex.regex": `^https?://www\.(kunde-a\.de)(.*)`, + "traefik.http.middlewares.kunde-a-www.redirectregex.replacement": "https://${1}${2}", + "traefik.http.middlewares.kunde-a-www.redirectregex.permanent": "false", // rohes Label gewinnt + } + got := FileWorkloads(specs, "h")[0].Labels + if !reflect.DeepEqual(got, want) { + for k, v := range want { + if got[k] != v { + t.Errorf("%s = %q, erwartet %q", k, got[k], v) + } + } + for k := range got { + if _, ok := want[k]; !ok { + t.Errorf("unerwartetes Label %s = %q", k, got[k]) + } + } + } +} + +func TestFileWorkloads_RedirectToWWW(t *testing.T) { + specs := parse(t, ` +services: + - name: web + url: http://127.0.0.1 + hosts: [www.shop.de, kunde.de] + redirect_www: to_www +`) + l := FileWorkloads(specs, "h")[0].Labels + if l["traefik.http.routers.web.rule"] != "Host(`www.shop.de`) || Host(`kunde.de`) || Host(`shop.de`) || Host(`www.kunde.de`)" { + t.Errorf("Regel = %s", l["traefik.http.routers.web.rule"]) + } + if l["traefik.http.middlewares.web-www.redirectregex.regex"] != `^https?://(shop\.de|kunde\.de)(.*)` || + l["traefik.http.middlewares.web-www.redirectregex.replacement"] != "http://www.${1}${2}" { + t.Errorf("Middleware: %v", l) + } + if l["traefik.http.services.web.loadbalancer.server.port"] != "80" { + t.Errorf("Default-Port: %s", l["traefik.http.services.web.loadbalancer.server.port"]) + } +} + +// Die erzeugten Labels müssen dieselbe Übersetzung bestehen wie Docker-Labels. +func TestFileWorkloads_LabelsAreValidTraefik(t *testing.T) { + specs := parse(t, ` +services: + - name: kunde-a + url: https://127.0.0.1:8443/ + hosts: [kunde-a.de] + https: true + redirect_http_to_https: true + redirect_www: to_non_www +`) + w := FileWorkloads(specs, "100.64.0.42")[0] + cfg, err := traefik.FromLabels(w.Host, w.Labels) + if err != nil { + t.Fatalf("FromLabels: %v", err) + } + if len(cfg.HTTP.Routers) != 2 || cfg.HTTP.Services["kunde-a"].LoadBalancer.Servers[0].URL != "https://100.64.0.42:8443" { + t.Errorf("Konfiguration: %+v", cfg.HTTP) + } +} + +func TestParseServices_Healthcheck(t *testing.T) { + specs := parse(t, ` +services: + - name: shop + url: http://127.0.0.1:8080/start + hosts: [shop.kunde.de] + healthcheck: + interval: 10s + expect_title: "Shop - Willkommen" + expect_contains: { tag: h2, text: Impressum } + - name: blog + url: http://127.0.0.1:8081 + hosts: [blog.kunde.de] + healthcheck: {} +`) + h := specs[0].Healthcheck + if h == nil || h.Interval.Duration != 10*time.Second || h.Timeout.Duration != 5*time.Second || h.Failures != 2 || + h.ExpectStatus != 200 || h.URL != "http://127.0.0.1:8080/start" || h.ExpectTitle != "Shop - Willkommen" || + h.ExpectContains == nil || h.ExpectContains.Tag != "h2" || h.ExpectContains.Text != "Impressum" { + t.Errorf("Healthcheck: %+v", h) + } + if d := specs[1].Healthcheck; d == nil || d.Interval.Duration != 30*time.Second || d.ExpectStatus != 200 { + t.Errorf("Healthcheck-Defaults: %+v", d) + } +} + +func TestParseServices_Errors(t *testing.T) { + tests := map[string]string{ + "kein YAML": "services: [", + "unbekanntes Feld": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n farbe: blau\n", + "Name fehlt": "services:\n - url: http://x\n hosts: [a.de]\n", + "Name ungültig": "services:\n - name: a.b\n url: http://x\n hosts: [a.de]\n", + "URL fehlt": "services:\n - name: a\n hosts: [a.de]\n", + "URL kein http": "services:\n - name: a\n url: ftp://x\n hosts: [a.de]\n", + "Hosts fehlen": "services:\n - name: a\n url: http://x\n", + "Host mit Wildcard": "services:\n - name: a\n url: http://x\n hosts: ['*.a.de']\n", + "redirect_www ungültig": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n redirect_www: manchmal\n", + "Redirect ohne https": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n redirect_http_to_https: true\n", + "Intervall unlesbar": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n healthcheck: { interval: oft }\n", + "Status ungültig": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n healthcheck: { expect_status: 42 }\n", + "contains ohne Tag": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n healthcheck: { expect_contains: { text: x } }\n", + "doppelter Name": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n - name: a\n url: http://y\n hosts: [b.de]\n", + } + for name, yaml := range tests { + if _, err := ParseServices([]byte(yaml)); err == nil { + t.Errorf("%s: erwartet Fehler", name) + } + } +} + +func TestLoadServiceDir(t *testing.T) { + dir := t.TempDir() + write := func(name, content string) { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + write("b.yaml", "services:\n - name: shop\n url: http://127.0.0.1:8080\n hosts: [shop.kunde.de]\n") + write("a.yml", "services:\n - name: blog\n url: http://127.0.0.1:8081\n hosts: [blog.kunde.de]\n") + write("c.yaml", "services: [") // kaputt + write("d.yaml", "services:\n - name: shop\n url: http://127.0.0.1:9\n hosts: [x.kunde.de]\n") // doppelt + write("notizen.txt", "services:\n - name: txt\n url: http://127.0.0.1:1\n hosts: [t.kunde.de]\n") // ignoriert + + specs, warnings := LoadServiceDir(dir) + var names []string + for _, s := range specs { + names = append(names, s.Name) + } + if !reflect.DeepEqual(names, []string{"blog", "shop"}) { + t.Errorf("Dienste = %v (Dateien alphabetisch, Duplikat und .txt ignoriert)", names) + } + if len(warnings) != 2 || !strings.Contains(strings.Join(warnings, "\n"), "c.yaml") || !strings.Contains(strings.Join(warnings, "\n"), "d.yaml") { + t.Errorf("Warnungen = %v", warnings) + } + + if specs, warnings := LoadServiceDir(filepath.Join(dir, "fehlt")); len(specs) != 0 || len(warnings) != 0 { + t.Errorf("fehlendes Verzeichnis: %v %v", specs, warnings) + } +} + +func TestFileWorkloads_Maintenance(t *testing.T) { + specs := parse(t, ` +services: + - name: shop + url: http://127.0.0.1:8080 + hosts: [shop.kunde.de] + maintenance: true + maintenance_public_reason: "Inventur bis Montag" + - name: blog + url: http://127.0.0.1:8081 + hosts: [blog.kunde.de] +`) + ws := FileWorkloads(specs, "h") + if ws[0].Labels["sultan.maintenance"] != "true" || ws[0].Labels["sultan.maintenance.public_reason"] != "Inventur bis Montag" { + t.Errorf("Wartungs-Labels fehlen: %v", ws[0].Labels) + } + if _, ok := ws[1].Labels["sultan.maintenance"]; ok { + t.Error("Dienst ohne Wartung hat Wartungs-Label") + } + if _, err := ParseServices([]byte("services:\n - name: a\n url: http://x\n hosts: [a.de]\n maintenance_public_reason: \"a\\nb\"\n")); err == nil { + t.Error("mehrzeiliger Grund akzeptiert") + } +} diff --git a/pkg/agent/workloads.go b/pkg/agent/workloads.go new file mode 100644 index 0000000..b1756c2 --- /dev/null +++ b/pkg/agent/workloads.go @@ -0,0 +1,147 @@ +package agent + +import ( + "fmt" + "maps" + "net" + "regexp" + "slices" + "strconv" + "strings" + + "sultan/pkg/docker" + "sultan/pkg/registry" +) + +const servicesPrefix = "traefik.http.services." + +var unsafeName = regexp.MustCompile(`[^A-Za-z0-9_-]`) + +// Workloads übersetzt laufende Container in Workloads für service_state. +// +// Berücksichtigt werden nur Container mit traefik.enable=true. Weil der +// Ingress auf einem anderen Host sitzt, wird loadbalancer.server.port (Port +// im Container) auf den veröffentlichten Host-Port umgerechnet. Fehlt das +// Port-Label, gilt wie bei Traefiks Docker-Provider der einzige +// veröffentlichte Port; ohne Service-Labels entsteht ein Service mit dem +// Containernamen. Container, deren Ports von außen nicht erreichbar sind +// (nicht veröffentlicht, nur Loopback, nur UDP), werden mit Warnung +// weggelassen. +func Workloads(containers []docker.Container, host string) ([]registry.Workload, []string) { + var out []registry.Workload + var warnings []string + for _, c := range containers { + if !enabled(c.Labels) { + continue + } + labels, warning := translatePorts(c) + if warning != "" { + warnings = append(warnings, warning) + continue + } + out = append(out, registry.Workload{Name: c.Name(), Host: host, Labels: labels}) + } + slices.SortFunc(out, func(a, b registry.Workload) int { return strings.Compare(a.Name, b.Name) }) + return out, warnings +} + +func enabled(labels map[string]string) bool { + for k, v := range labels { + if strings.EqualFold(k, "traefik.enable") { + on, err := strconv.ParseBool(strings.TrimSpace(v)) + return err == nil && on + } + } + return false +} + +// reachablePorts: Container-Port → von außen erreichbarer Host-Port (TCP). +func reachablePorts(ports []docker.Port) map[uint16]uint16 { + out := map[uint16]uint16{} + for _, p := range ports { + if p.Type != "tcp" || p.PublicPort == 0 { + continue + } + if ip := net.ParseIP(p.IP); ip != nil && ip.IsLoopback() { + continue + } + out[p.PrivatePort] = p.PublicPort + } + return out +} + +type serviceLabels struct { + name string // Schreibweise aus dem Label + portKey string // Original-Schlüssel des Port-Labels, "" wenn keins +} + +func translatePorts(c docker.Container) (map[string]string, string) { + labels := maps.Clone(c.Labels) + reachable := reachablePorts(c.Ports) + single := func() (uint16, bool) { + for _, pub := range reachable { + return pub, len(reachable) == 1 + } + return 0, false + } + + services := map[string]*serviceLabels{} + hasRouters := false + for k := range labels { + lk := strings.ToLower(k) + if strings.HasPrefix(lk, "traefik.http.routers.") { + hasRouters = true + } + rest, ok := strings.CutPrefix(lk, servicesPrefix) + if !ok { + continue + } + svc, attr, ok := strings.Cut(rest, ".") + if !ok { + continue + } + s := services[svc] + if s == nil { + name, _, _ := strings.Cut(k[len(servicesPrefix):], ".") + s = &serviceLabels{name: name} + services[svc] = s + } + if attr == "loadbalancer.server.port" { + s.portKey = k + } + } + + if len(services) == 0 { + if !hasRouters { + return labels, "" + } + pub, ok := single() + if !ok { + return nil, fmt.Sprintf("Container %s: %d erreichbare Ports – Label loadbalancer.server.port angeben", c.Name(), len(reachable)) + } + svc := unsafeName.ReplaceAllString(c.Name(), "-") + labels[servicesPrefix+svc+".loadbalancer.server.port"] = strconv.Itoa(int(pub)) + return labels, "" + } + + for _, s := range services { + if s.portKey == "" { + pub, ok := single() + if !ok { + return nil, fmt.Sprintf("Container %s, Service %s: %d erreichbare Ports – Label loadbalancer.server.port angeben", c.Name(), s.name, len(reachable)) + } + labels[servicesPrefix+s.name+".loadbalancer.server.port"] = strconv.Itoa(int(pub)) + continue + } + private, err := strconv.Atoi(strings.TrimSpace(labels[s.portKey])) + if err != nil { + return nil, fmt.Sprintf("Container %s: Port %q nicht lesbar", c.Name(), labels[s.portKey]) + } + pub, ok := reachable[uint16(private)] + if !ok { + return nil, fmt.Sprintf("Container %s: Port %d ist von außen nicht erreichbar (nicht veröffentlicht oder nur 127.0.0.1)", c.Name(), private) + } + labels[s.portKey] = strconv.Itoa(int(pub)) + } + return labels, "" +} diff --git a/pkg/agent/workloads_test.go b/pkg/agent/workloads_test.go new file mode 100644 index 0000000..82c6d8b --- /dev/null +++ b/pkg/agent/workloads_test.go @@ -0,0 +1,150 @@ +package agent + +import ( + "reflect" + "strings" + "testing" + + "sultan/pkg/docker" + "sultan/pkg/registry" +) + +func ctr(name string, labels map[string]string, ports ...docker.Port) docker.Container { + return docker.Container{ID: name + "-id", Names: []string{"/" + name}, State: "running", Labels: labels, Ports: ports} +} + +func pub(private, public uint16) docker.Port { + return docker.Port{IP: "0.0.0.0", PrivatePort: private, PublicPort: public, Type: "tcp"} +} + +func wpLabels() map[string]string { + return map[string]string{ + "traefik.enable": "true", + "traefik.http.routers.wp.rule": "Host(`kunde.de`)", + "traefik.http.services.wp.loadbalancer.server.port": "80", + } +} + +func TestWorkloads_TranslatesPublishedPort(t *testing.T) { + in := ctr("wordpress", wpLabels(), pub(80, 8081), pub(443, 8443)) + got, warnings := Workloads([]docker.Container{in}, "100.64.0.42") + if len(warnings) != 0 { + t.Errorf("Warnungen: %v", warnings) + } + want := []registry.Workload{{ + Name: "wordpress", Host: "100.64.0.42", + Labels: map[string]string{ + "traefik.enable": "true", + "traefik.http.routers.wp.rule": "Host(`kunde.de`)", + "traefik.http.services.wp.loadbalancer.server.port": "8081", + }, + }} + if !reflect.DeepEqual(got, want) { + t.Errorf("Workloads =\n%+v\nerwartet\n%+v", got, want) + } + if in.Labels["traefik.http.services.wp.loadbalancer.server.port"] != "80" { + t.Error("Eingabe-Labels wurden verändert") + } +} + +func TestWorkloads_SkipsDisabled(t *testing.T) { + off := wpLabels() + off["traefik.enable"] = "false" + none := wpLabels() + delete(none, "traefik.enable") + got, warnings := Workloads([]docker.Container{ + ctr("aus", off, pub(80, 8081)), + ctr("ohne", none, pub(80, 8082)), + ctr("db", map[string]string{}, pub(5432, 5432)), + }, "h") + if len(got) != 0 || len(warnings) != 0 { + t.Errorf("got=%v warnings=%v", got, warnings) + } +} + +func TestWorkloads_UnreachablePorts(t *testing.T) { + tests := map[string][]docker.Port{ + "nicht veröffentlicht": {{PrivatePort: 80, Type: "tcp"}}, + "nur IPv4-Loopback": {{IP: "127.0.0.1", PrivatePort: 80, PublicPort: 8081, Type: "tcp"}}, + "nur IPv6-Loopback": {{IP: "::1", PrivatePort: 80, PublicPort: 8081, Type: "tcp"}}, + "nur UDP": {{IP: "0.0.0.0", PrivatePort: 80, PublicPort: 8081, Type: "udp"}}, + "anderer Port": {pub(8080, 8081)}, + } + for name, ports := range tests { + got, warnings := Workloads([]docker.Container{ctr("wordpress", wpLabels(), ports...)}, "h") + if len(got) != 0 { + t.Errorf("%s: Workload trotz unerreichbarem Port: %+v", name, got) + } + if len(warnings) != 1 || !strings.Contains(warnings[0], "wordpress") || !strings.Contains(warnings[0], "80") { + t.Errorf("%s: Warnungen = %v", name, warnings) + } + } +} + +func TestWorkloads_IPv6AnyIsReachable(t *testing.T) { + got, _ := Workloads([]docker.Container{ctr("wordpress", wpLabels(), + docker.Port{IP: "::", PrivatePort: 80, PublicPort: 8081, Type: "tcp"})}, "h") + if len(got) != 1 || got[0].Labels["traefik.http.services.wp.loadbalancer.server.port"] != "8081" { + t.Errorf("got = %+v", got) + } +} + +// Wie Traefiks Docker-Provider: ohne Service-Labels gibt es einen Service +// mit dem Containernamen und dem einzigen veröffentlichten Port. +func TestWorkloads_DefaultService(t *testing.T) { + labels := map[string]string{"traefik.enable": "true", "traefik.http.routers.app.rule": "Host(`app.kunde.de`)"} + got, warnings := Workloads([]docker.Container{ctr("my.app", labels, pub(3000, 13000))}, "h") + if len(warnings) != 0 || len(got) != 1 { + t.Fatalf("got=%+v warnings=%v", got, warnings) + } + if got[0].Labels["traefik.http.services.my-app.loadbalancer.server.port"] != "13000" { + t.Errorf("Default-Service fehlt: %v", got[0].Labels) + } + + _, warnings = Workloads([]docker.Container{ctr("multi", labels, pub(3000, 13000), pub(3001, 13001))}, "h") + if len(warnings) != 1 || !strings.Contains(warnings[0], "multi") { + t.Errorf("mehrere Ports ohne Port-Label: Warnungen = %v", warnings) + } +} + +func TestWorkloads_ServiceWithoutPortLabel(t *testing.T) { + labels := map[string]string{ + "traefik.enable": "true", + "traefik.http.routers.wp.rule": "Host(`kunde.de`)", + "traefik.http.services.wp.loadbalancer.passhostheader": "true", + } + got, warnings := Workloads([]docker.Container{ctr("wordpress", labels, pub(80, 8081))}, "h") + if len(warnings) != 0 || len(got) != 1 || got[0].Labels["traefik.http.services.wp.loadbalancer.server.port"] != "8081" { + t.Errorf("got=%+v warnings=%v", got, warnings) + } +} + +func TestWorkloads_MultipleServicesAndCaseInsensitiveKeys(t *testing.T) { + labels := map[string]string{ + "Traefik.Enable": "TRUE", + "traefik.http.routers.web.rule": "Host(`kunde.de`)", + "traefik.http.routers.web.service": "web", + "traefik.http.routers.api.rule": "Host(`api.kunde.de`)", + "traefik.http.routers.api.service": "api", + "traefik.http.services.web.loadbalancer.server.port": "80", + "Traefik.HTTP.Services.Api.LoadBalancer.Server.Port": "9000", + } + got, warnings := Workloads([]docker.Container{ctr("app", labels, pub(80, 8080), pub(9000, 19000))}, "h") + if len(warnings) != 0 || len(got) != 1 { + t.Fatalf("got=%+v warnings=%v", got, warnings) + } + l := got[0].Labels + if l["traefik.http.services.web.loadbalancer.server.port"] != "8080" || l["Traefik.HTTP.Services.Api.LoadBalancer.Server.Port"] != "19000" { + t.Errorf("Labels = %v", l) + } +} + +func TestWorkloads_SortedByName(t *testing.T) { + got, _ := Workloads([]docker.Container{ + ctr("b", wpLabels(), pub(80, 8082)), + ctr("a", wpLabels(), pub(80, 8081)), + }, "h") + if len(got) != 2 || got[0].Name != "a" || got[1].Name != "b" { + t.Errorf("Reihenfolge: %+v", got) + } +} diff --git a/pkg/auth/integration_test.go b/pkg/auth/integration_test.go index 56984a8..7de4fc2 100644 --- a/pkg/auth/integration_test.go +++ b/pkg/auth/integration_test.go @@ -212,7 +212,7 @@ func TestIntegration_TokenWithPinnedTLS(t *testing.T) { srv, sc, _ := pinnedServer(t, db, hub) // Server-Seite (sultan token): Token erzeugen, nur das Secret speichern. - tok, err := crypto.NewEnrollmentToken(sc.Fingerprint) + tok, err := crypto.NewEnrollmentToken(sc.Fingerprint, "") if err != nil { t.Fatal(err) } @@ -225,9 +225,7 @@ func TestIntegration_TokenWithPinnedTLS(t *testing.T) { if err != nil { t.Fatal(err) } - addr := srv.Listener.Addr().String() - ks := &crypto.KnownServers{Path: filepath.Join(t.TempDir(), "known_servers")} - firstContact := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.ClientTLSConfig(addr, ks, parsed.Fingerprint)}} + firstContact := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(parsed.Fingerprint)}} priv, _ := GenerateKey() signer := &Signer{Key: priv} @@ -240,8 +238,8 @@ func TestIntegration_TokenWithPinnedTLS(t *testing.T) { t.Fatalf("Enrollment: Status %d", resp.StatusCode) } - // Ab jetzt nur noch über den Pin, ohne Token-Fingerprint. - pinned := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.ClientTLSConfig(addr, ks, "")}} + // Ab jetzt über den gespeicherten Pin (beim Agent: node.json). + pinned := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(parsed.Fingerprint)}} client := &stream.Client{URL: srv.URL + "/api/v1/stream", HTTPClient: pinned, Prepare: signer.Sign} got := make(chan stream.Event, 1) runCtx, cancel := context.WithCancel(ctx) @@ -275,12 +273,10 @@ func TestIntegration_MITMNeverSeesToken(t *testing.T) { _, realCert, _ := pinnedServer(t, db, hub) mitm, _, mitmHits := pinnedServer(t, homelabStore(t), &stream.Hub{}) - tok, _ := crypto.NewEnrollmentToken(realCert.Fingerprint) + tok, _ := crypto.NewEnrollmentToken(realCert.Fingerprint, "") db.AddEnrollmentToken(ctx, tok.Secret, "homelab", time.Now().Add(time.Hour)) - ks := &crypto.KnownServers{Path: filepath.Join(t.TempDir(), "known_servers")} - addr := mitm.Listener.Addr().String() - hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.ClientTLSConfig(addr, ks, tok.Fingerprint)}} + hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(tok.Fingerprint)}} priv, _ := GenerateKey() _, err := enroll(&Signer{Key: priv}, hc, mitm.URL, tok.Secret) diff --git a/pkg/auth/keys.go b/pkg/auth/keys.go index 2065412..57bdecf 100644 --- a/pkg/auth/keys.go +++ b/pkg/auth/keys.go @@ -104,3 +104,24 @@ func DecodePublicKey(s string) (ed25519.PublicKey, error) { } return ed25519.PublicKey(b), nil } + +// rotationVersion trennt Rotationsnachweise von anderen Signaturen. +const rotationVersion = "SULTAN-ROTATE-V1\n" + +func rotationMessage(nodeID string, pub ed25519.PublicKey) []byte { + return []byte(rotationVersion + nodeID + "\n" + EncodePublicKey(pub)) +} + +// RotationProof beweist beim Schlüsselwechsel (rotate_key) den Besitz des +// neuen Schlüssels; der Nachweis ist an die Knoten-ID gebunden. +func RotationProof(nodeID string, newKey ed25519.PrivateKey) string { + pub := newKey.Public().(ed25519.PublicKey) + return base64.StdEncoding.EncodeToString(ed25519.Sign(newKey, rotationMessage(nodeID, pub))) +} + +// VerifyRotationProof prüft einen RotationProof. +func VerifyRotationProof(nodeID string, pub ed25519.PublicKey, proof string) bool { + sig, err := base64.StdEncoding.DecodeString(proof) + return err == nil && len(sig) == ed25519.SignatureSize && len(pub) == ed25519.PublicKeySize && + ed25519.Verify(pub, rotationMessage(nodeID, pub), sig) +} diff --git a/pkg/checkmk/checkmk.go b/pkg/checkmk/checkmk.go new file mode 100644 index 0000000..08ce78b --- /dev/null +++ b/pkg/checkmk/checkmk.go @@ -0,0 +1,200 @@ +// Package checkmk erzeugt die Ausgabe für Checkmk im Local-Check-Format +// (Spec 6.1): ` "" ` mit 0 OK, 1 WARN, +// 2 CRIT. Der Text ist ASCII (Umlaute umschrieben), einzeilig. +package checkmk + +import ( + "fmt" + "math" + "slices" + "strings" + "time" +) + +// Schwellen (Spec 6.2 / Entscheidungen 2026-10-02). +const ( + ContractWarnDays = 30 + ContractCritDays = 14 + OfflineCritAfter = 15 * time.Minute +) + +const ( + statusOK = 0 + statusW = 1 + statusCrit = 2 +) + +// Tenant ist ein Mandant aus Sicht des Monitorings. +type Tenant struct { + ID string + Commercial bool // Community: keine Vertragsfristen, kein Mahnwesen + Status string + ValidUntil time.Time + DunningLevel int + OverdueSince time.Time +} + +// Node ist ein Knoten aus Sicht des Monitorings. +type Node struct { + ID string + Status string // online, offline, drained + OfflineSince time.Time + MaintenanceActive bool + MaintenanceUntil time.Time // Nullwert = unbefristet + MaintenanceReason string // intern – Monitoring ist intern + LeaseUntil time.Time // Nullwert = keine Lease +} + +// State ist alles, was ausgegeben wird. +type State struct { + Tenants []Tenant + Nodes []Node + Violations24h int + // OfflineCrit: ab wann ein getrennter Knoten CRIT ist (Default 15 min). + OfflineCrit time.Duration + // Location: Zeitzone für angezeigte Zeitpunkte (Default UTC). + Location *time.Location +} + +var label = map[int]string{statusOK: "OK", statusW: "WARN", statusCrit: "CRIT"} + +const day = 24 * time.Hour + +// Render liefert die Ausgabe, sortiert nach Mandant bzw. Knoten. +func Render(s State, now time.Time) string { + var b strings.Builder + line := func(status int, service, metrics, text string) { + fmt.Fprintf(&b, "%d %q %s %s - %s\n", status, service, metrics, label[status], ascii(text)) + } + + tenants := slices.Clone(s.Tenants) + slices.SortFunc(tenants, func(a, b Tenant) int { return strings.Compare(a.ID, b.ID) }) + for _, t := range tenants { + if !t.Commercial { + continue + } + renderContract(line, t, now) + renderPayment(line, t, now) + } + + nodes := slices.Clone(s.Nodes) + slices.SortFunc(nodes, func(a, b Node) int { return strings.Compare(a.ID, b.ID) }) + crit := s.OfflineCrit + if crit <= 0 { + crit = OfflineCritAfter + } + loc := s.Location + if loc == nil { + loc = time.UTC + } + for _, n := range nodes { + renderNode(line, n, now, crit, loc) + } + + if s.Violations24h == 0 { + line(statusOK, "Sultan_Policy_Violations", "violations_24h=0;1", "keine Policy-Verstoesse in 24 h") + } else { + line(statusW, "Sultan_Policy_Violations", fmt.Sprintf("violations_24h=%d;1", s.Violations24h), + fmt.Sprintf("%d Policy-Verstoesse in 24 h (siehe Server-Log)", s.Violations24h)) + } + return b.String() +} + +func renderContract(line func(int, string, string, string), t Tenant, now time.Time) { + svc := "Sultan_Tenant_" + t.ID + days := int(math.Floor(t.ValidUntil.Sub(now).Hours() / 24)) + metrics := func(d int) string { + return fmt.Sprintf("days_left=%d;%d;%d", max(d, 0), ContractWarnDays, ContractCritDays) + } + switch { + case t.Status == "expired" || !now.Before(t.ValidUntil): + since := int(math.Ceil(now.Sub(t.ValidUntil).Hours() / 24)) + line(statusCrit, svc, metrics(0), fmt.Sprintf("Abo seit %d Tagen abgelaufen! Status: %s", since, t.Status)) + case t.Status == "quarantined": + line(statusCrit, svc, metrics(days), "Mandant in Quarantaene") + case days <= ContractCritDays: + line(statusCrit, svc, metrics(days), fmt.Sprintf("Abo laeuft in %d Tagen ab! Verlaengerung erforderlich.", days)) + case days <= ContractWarnDays: + line(statusW, svc, metrics(days), fmt.Sprintf("Abo laeuft in %d Tagen ab! Verlaengerung erforderlich.", days)) + default: + line(statusOK, svc, metrics(days), fmt.Sprintf("Abo aktiv bis %s (%d Tage verbleibend)", t.ValidUntil.UTC().Format(time.DateOnly), days)) + } +} + +func renderPayment(line func(int, string, string, string), t Tenant, now time.Time) { + svc := "Sultan_Payment_" + t.ID + metrics := fmt.Sprintf("dunning_level=%d;1;2", t.DunningLevel) + if t.DunningLevel == 0 { + line(statusOK, svc, metrics, "keine offene Zahlung") + return + } + text := fmt.Sprintf("Mahnstufe %d (Zahlung seit %d Tagen offen)", t.DunningLevel, int(now.Sub(t.OverdueSince)/day)) + if t.DunningLevel >= 3 { + text += " - Kunde offline" + } + status := statusW + if t.DunningLevel >= 2 { + status = statusCrit + } + line(status, svc, metrics, text) +} + +func renderNode(line func(int, string, string, string), n Node, now time.Time, offlineCrit time.Duration, loc *time.Location) { + svc := "Sultan_Node_" + n.ID + leaseHours := 0 + if n.LeaseUntil.After(now) { + leaseHours = int(n.LeaseUntil.Sub(now) / time.Hour) + } + metrics := fmt.Sprintf("lease_hours_left=%d", leaseHours) + switch { + case n.Status == "drained": + line(statusOK, svc, metrics, "drained") + case n.MaintenanceActive: + text := "Wartung unbefristet" + if !n.MaintenanceUntil.IsZero() { + text = "Wartung bis " + n.MaintenanceUntil.In(loc).Format("2006-01-02 15:04 MST") + } + if n.MaintenanceReason != "" { + text += " (" + n.MaintenanceReason + ")" + } + line(statusW, svc, metrics, text) + case n.Status == "offline": + since := now.Sub(n.OfflineSince) + status := statusW + if since >= offlineCrit { + status = statusCrit + } + line(status, svc, metrics, "offline seit "+shortDuration(since)) + default: + line(statusOK, svc, metrics, "online") + } +} + +// shortDuration: unter einer Minute in Sekunden, sonst in Minuten. +func shortDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%d s", int(d/time.Second)) + } + return fmt.Sprintf("%d min", int(d/time.Minute)) +} + +var umlauts = strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "Ä", "Ae", "Ö", "Oe", "Ü", "Ue", "ß", "ss") + +// ascii macht Text Checkmk-tauglich: einzeilig, Umlaute umschrieben, +// übrige Nicht-ASCII-Zeichen ersetzt. +func ascii(s string) string { + s = umlauts.Replace(s) + var b strings.Builder + for _, r := range s { + switch { + case r == '\n' || r == '\r' || r == '\t': + b.WriteByte(' ') + case r < 32 || r == 127: + case r > 127: + b.WriteByte('?') + default: + b.WriteRune(r) + } + } + return b.String() +} diff --git a/pkg/checkmk/checkmk_test.go b/pkg/checkmk/checkmk_test.go new file mode 100644 index 0000000..b692255 --- /dev/null +++ b/pkg/checkmk/checkmk_test.go @@ -0,0 +1,153 @@ +package checkmk + +import ( + "strings" + "testing" + "time" +) + +var now = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) + +func lines(out string) map[string]string { + m := map[string]string{} + for _, l := range strings.Split(strings.TrimSpace(out), "\n") { + start := strings.Index(l, `"`) + end := strings.Index(l[start+1:], `"`) + m[l[start+1:start+1+end]] = l + } + return m +} + +func TestTenants(t *testing.T) { + out := Render(State{Tenants: []Tenant{ + {ID: "kunde_schmidt", Commercial: true, Status: "active", ValidUntil: now.AddDate(0, 0, 74)}, + {ID: "meier_gmbh", Commercial: true, Status: "active", ValidUntil: now.AddDate(0, 0, 25)}, + {ID: "bald", Commercial: true, Status: "active", ValidUntil: now.AddDate(0, 0, 12)}, + {ID: "alt_ag", Commercial: true, Status: "expired", ValidUntil: now.AddDate(0, 0, -1)}, + {ID: "homelab", Commercial: false, Status: "active", ValidUntil: now.AddDate(0, 0, -100)}, + }}, now) + l := lines(out) + want := map[string]string{ + "Sultan_Tenant_kunde_schmidt": `0 "Sultan_Tenant_kunde_schmidt" days_left=74;30;14 OK - Abo aktiv bis 2026-12-15 (74 Tage verbleibend)`, + "Sultan_Tenant_meier_gmbh": `1 "Sultan_Tenant_meier_gmbh" days_left=25;30;14 WARN - Abo laeuft in 25 Tagen ab! Verlaengerung erforderlich.`, + "Sultan_Tenant_bald": `2 "Sultan_Tenant_bald" days_left=12;30;14 CRIT - Abo laeuft in 12 Tagen ab! Verlaengerung erforderlich.`, + "Sultan_Tenant_alt_ag": `2 "Sultan_Tenant_alt_ag" days_left=0;30;14 CRIT - Abo seit 1 Tagen abgelaufen! Status: expired`, + } + for svc, w := range want { + if l[svc] != w { + t.Errorf("%s:\n got: %s\nwant: %s", svc, l[svc], w) + } + } + if _, ok := l["Sultan_Tenant_homelab"]; ok { + t.Error("Community-Mandant mit Vertragsfrist (5c: keine)") + } +} + +func TestPayment(t *testing.T) { + out := Render(State{Tenants: []Tenant{ + {ID: "a", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 0}, + {ID: "b", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 1, OverdueSince: now.AddDate(0, 0, -9)}, + {ID: "c", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 2, OverdueSince: now.AddDate(0, 0, -26)}, + {ID: "d", Commercial: true, Status: "suspended_payment", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 3, OverdueSince: now.AddDate(0, 0, -40)}, + {ID: "h", Commercial: false, Status: "active", ValidUntil: now}, + }}, now) + l := lines(out) + for svc, prefix := range map[string]string{ + "Sultan_Payment_a": `0 "Sultan_Payment_a" dunning_level=0;1;2 OK - keine offene Zahlung`, + "Sultan_Payment_b": `1 "Sultan_Payment_b" dunning_level=1;1;2 WARN - Mahnstufe 1 (Zahlung seit 9 Tagen offen)`, + "Sultan_Payment_c": `2 "Sultan_Payment_c" dunning_level=2;1;2 CRIT - Mahnstufe 2 (Zahlung seit 26 Tagen offen)`, + "Sultan_Payment_d": `2 "Sultan_Payment_d" dunning_level=3;1;2 CRIT - Mahnstufe 3 (Zahlung seit 40 Tagen offen) - Kunde offline`, + } { + if l[svc] != prefix { + t.Errorf("%s:\n got: %s\nwant: %s", svc, l[svc], prefix) + } + } + if _, ok := l["Sultan_Payment_h"]; ok { + t.Error("Community-Mandant mit Mahnwesen") + } +} + +func TestNodes(t *testing.T) { + out := Render(State{Nodes: []Node{ + {ID: "online", Status: "online", LeaseUntil: now.Add(48 * time.Hour)}, + {ID: "wartung", Status: "online", MaintenanceActive: true, MaintenanceUntil: now.Add(90 * time.Minute), MaintenanceReason: "Kernel 6.12", LeaseUntil: now.Add(time.Hour)}, + {ID: "kurz-weg", Status: "offline", OfflineSince: now.Add(-5 * time.Minute), LeaseUntil: now.Add(time.Hour)}, + {ID: "lange-weg", Status: "offline", OfflineSince: now.Add(-20 * time.Minute)}, + {ID: "abgezogen", Status: "drained"}, + }}, now) + l := lines(out) + want := map[string]string{ + "Sultan_Node_online": `0 "Sultan_Node_online" lease_hours_left=48 OK - online`, + "Sultan_Node_wartung": `1 "Sultan_Node_wartung" lease_hours_left=1 WARN - Wartung bis 2026-10-02 13:30 UTC (Kernel 6.12)`, + "Sultan_Node_kurz-weg": `1 "Sultan_Node_kurz-weg" lease_hours_left=1 WARN - offline seit 5 min`, + "Sultan_Node_lange-weg": `2 "Sultan_Node_lange-weg" lease_hours_left=0 CRIT - offline seit 20 min`, + "Sultan_Node_abgezogen": `0 "Sultan_Node_abgezogen" lease_hours_left=0 OK - drained`, + } + for svc, w := range want { + if l[svc] != w { + t.Errorf("%s:\n got: %s\nwant: %s", svc, l[svc], w) + } + } +} + +// Angemeldete Wartung: auch offline nur WARN (geplant), nicht CRIT. +func TestNodeOfflineDuringMaintenance(t *testing.T) { + out := Render(State{Nodes: []Node{{ID: "pi", Status: "offline", OfflineSince: now.Add(-time.Hour), MaintenanceActive: true}}}, now) + if !strings.HasPrefix(strings.TrimSpace(out), `1 "Sultan_Node_pi"`) || !strings.Contains(out, "Wartung unbefristet") { + t.Errorf("out = %s", out) + } +} + +func TestViolations(t *testing.T) { + for n, want := range map[int]string{ + 0: `0 "Sultan_Policy_Violations" violations_24h=0;1 OK - keine Policy-Verstoesse in 24 h`, + 3: `1 "Sultan_Policy_Violations" violations_24h=3;1 WARN - 3 Policy-Verstoesse in 24 h (siehe Server-Log)`, + } { + if got := strings.TrimSpace(Render(State{Violations24h: n}, now)); got != want { + t.Errorf("%d:\n got: %s\nwant: %s", n, got, want) + } + } +} + +func TestOutputIsSortedAndASCII(t *testing.T) { + out := Render(State{ + Tenants: []Tenant{{ID: "b", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0)}, {ID: "a", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0)}}, + Nodes: []Node{{ID: "y", Status: "online"}, {ID: "x", Status: "online", MaintenanceActive: true, MaintenanceReason: "Grün & Ölwechsel\nzweite Zeile"}}, + }, now) + if strings.Index(out, "Sultan_Tenant_a") > strings.Index(out, "Sultan_Tenant_b") || strings.Index(out, "Sultan_Node_x") > strings.Index(out, "Sultan_Node_y") { + t.Errorf("nicht sortiert:\n%s", out) + } + for _, r := range out { + if r > 127 { + t.Fatalf("Nicht-ASCII-Zeichen %q in:\n%s", r, out) + } + } + if strings.Count(out, "\n") != len(lines(out)) { + t.Errorf("Zeilenumbruch aus dem Grund in der Ausgabe:\n%s", out) + } + if !strings.Contains(out, "Gruen & Oelwechsel zweite Zeile") { + t.Errorf("Umlaute nicht umschrieben:\n%s", out) + } +} + +func TestShortOfflineInSeconds(t *testing.T) { + out := Render(State{Nodes: []Node{{ID: "pi", Status: "offline", OfflineSince: now.Add(-45 * time.Second)}}}, now) + if !strings.Contains(out, "offline seit 45 s") { + t.Errorf("out = %s", out) + } +} + +// Wartungsende in der Zeitzone des Servers (wie auf der Lander-Seite). +func TestMaintenanceUntilInLocation(t *testing.T) { + berlin, err := time.LoadLocation("Europe/Berlin") + if err != nil { + t.Skip("Zeitzonendaten fehlen") + } + out := Render(State{ + Location: berlin, + Nodes: []Node{{ID: "pi", Status: "online", MaintenanceActive: true, MaintenanceUntil: now.Add(90 * time.Minute)}}, + }, now) + if !strings.Contains(out, "Wartung bis 2026-10-02 15:30 CEST") { + t.Errorf("out = %s", out) + } +} diff --git a/pkg/cli/agent.go b/pkg/cli/agent.go new file mode 100644 index 0000000..f1a5408 --- /dev/null +++ b/pkg/cli/agent.go @@ -0,0 +1,115 @@ +package cli + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "log/slog" + "os" + + "sultan/pkg/agent" +) + +func runAgent(ctx context.Context, args []string, stderr io.Writer) int { + if len(args) > 0 && args[0] == "maintenance" { + return runAgentMaintenance(ctx, args[1:], stderr) + } + cfg, err := parseAgentFlags(args, stderr, os.Getenv) + if errors.Is(err, flag.ErrHelp) { + return 0 + } + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + cfg.Logger = slog.New(slog.NewTextHandler(stderr, nil)) + a, err := agent.New(cfg) + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + if err := a.Run(ctx); err != nil { + fmt.Fprintln(stderr, "Agent beendet mit Fehler:", err) + return 1 + } + return 0 +} + +// parseAgentFlags liest die Flags von `sultan agent`. SULTAN_TOKEN und +// SULTAN_SERVER dienen als Defaults (curl | sh, Compose; Argumente wären über +// ps für andere Benutzer sichtbar) – Flags gewinnen. +func parseAgentFlags(args []string, output io.Writer, getenv func(string) string) (agent.Config, error) { + fs := flag.NewFlagSet("sultan agent", flag.ContinueOnError) + fs.SetOutput(output) + var cfg agent.Config + fs.StringVar(&cfg.ServerURL, "server", "", "Server-Adresse, z. B. https://ingress.example.com:8443 (Override; sonst aus der Einladung)") + fs.StringVar(&cfg.Token, "token", "", "Einladung von `sultan token invite` (nur beim ersten Start)") + fs.StringVar(&cfg.DataDir, "data-dir", "./suldata", "Datenverzeichnis (node.key, node.json)") + fs.StringVar(&cfg.NodeID, "node-id", "", "Knoten-ID (Default: aus dem Hostnamen)") + fs.StringVar(&cfg.DockerSocket, "docker-socket", "/var/run/docker.sock", `Docker-Socket ("none" = ohne Docker)`) + fs.StringVar(&cfg.BackendHost, "backend-host", "", "Adresse, unter der der Ingress die Dienste erreicht (Default: automatisch)") + fs.StringVar(&cfg.BackendInterface, "backend-interface", "", "alternativ: IP dieses Interfaces verwenden (z. B. wt0 für NetBird)") + fs.StringVar(&cfg.ConfigDir, "config-dir", "/etc/sultan.d", `Dienste als YAML (*.yaml) für Hosts ohne Docker-Labels ("none" = aus)`) + annotateEnv(fs) + if err := fs.Parse(args); err != nil { + return agent.Config{}, err + } + if err := applyEnv(fs, getenv); err != nil { + return agent.Config{}, err + } + if fs.NArg() > 0 { + return agent.Config{}, fmt.Errorf("unerwartetes Argument %q", fs.Arg(0)) + } + return cfg, nil +} + +// runAgentMaintenance: `sultan agent maintenance on|off [--for] [--reason] +// [--public-reason]` – meldet Wartung dieses Knotens beim Server an oder ab, +// z. B. aus einem Patch- oder Reboot-Skript. +func runAgentMaintenance(ctx context.Context, args []string, stderr io.Writer) int { + if len(args) == 0 || (args[0] != "on" && args[0] != "off") { + fmt.Fprintln(stderr, "Aufruf: sultan agent maintenance on|off [--for 4h|0] [--reason TEXT] [--public-reason TEXT] [--data-dir DIR]") + return 2 + } + on := args[0] == "on" + fs := flag.NewFlagSet("sultan agent maintenance", flag.ContinueOnError) + fs.SetOutput(stderr) + dataDir := fs.String("data-dir", "./suldata", "Datenverzeichnis des Agenten") + server := fs.String("server", "", "Server-Adresse (Override)") + forRaw := fs.String("for", "4h", "Dauer (z. B. 30m, 4h, 2d; 0 = unbefristet)") + reason := fs.String("reason", "", "interner Grund (Log, Monitoring)") + publicReason := fs.String("public-reason", "", "öffentlicher Grund – ersetzt den Standardtext der Wartungsseite") + annotateEnv(fs) + if err := fs.Parse(args[1:]); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if err := applyEnv(fs, os.Getenv); err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + d, err := parseMaintenanceFor(*forRaw) + if err != nil { + fmt.Fprintln(stderr, "--for:", err) + return 2 + } + a, err := agent.New(agent.Config{DataDir: *dataDir, ServerURL: *server, Logger: slog.New(slog.NewTextHandler(stderr, nil))}) + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + if err := a.SetMaintenance(ctx, on, d, *reason, *publicReason); err != nil { + fmt.Fprintln(stderr, "Fehler:", err) + return 1 + } + if on { + fmt.Fprintln(stderr, "Wartung angemeldet – Besucher sehen jetzt die Wartungsseite.") + } else { + fmt.Fprintln(stderr, "Wartung beendet.") + } + return 0 +} diff --git a/pkg/cli/agent_test.go b/pkg/cli/agent_test.go new file mode 100644 index 0000000..0a39f19 --- /dev/null +++ b/pkg/cli/agent_test.go @@ -0,0 +1,97 @@ +package cli + +import ( + "bytes" + "context" + "path/filepath" + "strings" + "testing" +) + +func TestAgentFlags(t *testing.T) { + cfg, err := parseAgentFlags(nil, &bytes.Buffer{}, noEnv) + if err != nil { + t.Fatal(err) + } + if cfg.DataDir != "./suldata" || cfg.DockerSocket != "/var/run/docker.sock" || cfg.ServerURL != "" || cfg.Token != "" || + cfg.ConfigDir != "/etc/sultan.d" { + t.Errorf("Defaults: %+v", cfg) + } + cfg, err = parseAgentFlags([]string{ + "--server=https://ingress:8443", "--token", "sltn_enroll_x.y", "--data-dir=/srv/sul", + "--node-id=pi", "--docker-socket=none", "--backend-host=100.64.0.42", "--backend-interface=wt0", + "--config-dir=/srv/sultan.d", + }, &bytes.Buffer{}, noEnv) + if err != nil { + t.Fatal(err) + } + if cfg.ServerURL != "https://ingress:8443" || cfg.Token != "sltn_enroll_x.y" || cfg.DataDir != "/srv/sul" || + cfg.NodeID != "pi" || cfg.DockerSocket != "none" || cfg.BackendHost != "100.64.0.42" || cfg.BackendInterface != "wt0" || + cfg.ConfigDir != "/srv/sultan.d" { + t.Errorf("Overrides: %+v", cfg) + } + if _, err := parseAgentFlags([]string{"--gibts-nicht"}, &bytes.Buffer{}, noEnv); err == nil { + t.Error("unbekanntes Flag akzeptiert") + } +} + +func TestRun_AgentNotEnrolled(t *testing.T) { + dir := filepath.Join(t.TempDir(), "suldata") + code, _, stderr := run(context.Background(), "agent", "--data-dir="+dir, "--docker-socket=none") + if code != 1 || !strings.Contains(stderr, "--token") { + t.Errorf("code=%d stderr=%q", code, stderr) + } +} + +func TestRun_AgentBadConfig(t *testing.T) { + code, _, stderr := run(context.Background(), "agent", "--server=http://ingress:8443", "--token=x") + if code != 2 || !strings.Contains(stderr, "https") { + t.Errorf("http-URL: code=%d stderr=%q", code, stderr) + } + if code, _, _ := run(context.Background(), "agent", "--help"); code != 0 { + t.Errorf("--help: code=%d", code) + } +} + +func noEnv(string) string { return "" } + +// Token und Server auch per Umgebung (curl | sh, Compose) – Flags gewinnen. +func TestAgentFlags_Environment(t *testing.T) { + env := map[string]string{"SULTAN_TOKEN": "sltn_enroll_env", "SULTAN_SERVER": "https://env:8443"} + getenv := func(k string) string { return env[k] } + + cfg, err := parseAgentFlags(nil, &bytes.Buffer{}, getenv) + if err != nil || cfg.Token != "sltn_enroll_env" || cfg.ServerURL != "https://env:8443" { + t.Errorf("aus Umgebung: %+v %v", cfg, err) + } + cfg, _ = parseAgentFlags([]string{"--token=flag", "--server=https://flag:8443"}, &bytes.Buffer{}, getenv) + if cfg.Token != "flag" || cfg.ServerURL != "https://flag:8443" { + t.Errorf("Flags müssen gewinnen: %+v", cfg) + } +} + +func TestRun_AgentMaintenanceCommand(t *testing.T) { + dir := filepath.Join(t.TempDir(), "suldata") + code, _, stderr := run(context.Background(), "agent", "maintenance", "on", "--data-dir="+dir) + if code != 1 || !strings.Contains(stderr, "registriert") { + t.Errorf("nicht registriert: code=%d stderr=%q", code, stderr) + } + for _, args := range [][]string{ + {"agent", "maintenance"}, + {"agent", "maintenance", "vielleicht"}, + {"agent", "maintenance", "on", "--for=bald"}, + } { + if code, _, _ := run(context.Background(), args...); code != 2 { + t.Errorf("%v: code=%d", args, code) + } + } +} + +func TestAgentFlags_EnvironmentAll(t *testing.T) { + cfg, err := parseAgentFlags(nil, &bytes.Buffer{}, envMap(map[string]string{ + "SULTAN_DATA_DIR": "/srv/agent", "SULTAN_NODE_ID": "pi", "SULTAN_CONFIG_DIR": "none", "SULTAN_BACKEND_INTERFACE": "wt0", + })) + if err != nil || cfg.DataDir != "/srv/agent" || cfg.NodeID != "pi" || cfg.ConfigDir != "none" || cfg.BackendInterface != "wt0" { + t.Errorf("aus Umgebung: %+v %v", cfg, err) + } +} diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index ba3328c..6a2d3ff 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -1,8 +1,8 @@ // Package cli implementiert die Befehlszeile von sultan: // // sultan server Registry, Stream-Hub, Policy-Engine, Traefik-Provider -// sultan agent (noch nicht implementiert) -// sultan token (noch nicht implementiert) +// sultan agent Agent: Enrollment, Stream, Docker-Watcher +// sultan token Mandanten, Einladungen, Knoten, Lizenzen verwalten // sultan version // // Run ist ohne Prozessgrenzen testbar; main ruft nur Run auf. @@ -15,6 +15,8 @@ import ( "fmt" "io" "log/slog" + "os" + "strings" "time" "sultan/pkg/server" @@ -27,8 +29,9 @@ const usage = `sultan – verteiltes Ingress-Routing mit ZeroTrust-Service-Disco Befehle: sultan server Server starten (Registry, Agent-Port, Traefik-Provider) - sultan agent Agent starten (noch nicht implementiert) - sultan token Mandanten, Einladungen, Lizenzen verwalten (noch nicht implementiert) + sultan agent Agent starten (Docker-Host oder Bare-Metal) + sultan token Mandanten, Einladungen, Knoten und Lizenzen verwalten + sultan dump-landing eingebaute Lander-Seiten als Vorlagen ausgeben sultan version Version ausgeben sultan help diese Hilfe @@ -51,9 +54,12 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int { return 0 case "server": return runServer(ctx, args[1:], stdout, stderr) - case "agent", "token": - fmt.Fprintf(stderr, "sultan %s: noch nicht implementiert (siehe Roadmap)\n", args[0]) - return 1 + case "token": + return runToken(ctx, args[1:], stdout, stderr) + case "agent": + return runAgent(ctx, args[1:], stderr) + case "dump-landing": + return runDump(args[1:], stdout, stderr) default: fmt.Fprintf(stderr, "unbekannter Befehl %q\n\n%s", args[0], usage) return 2 @@ -61,7 +67,7 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int { } func runServer(ctx context.Context, args []string, stdout, stderr io.Writer) int { - cfg, err := parseServerFlags(args, stderr) + cfg, err := parseServerFlags(args, stderr, os.Getenv) if errors.Is(err, flag.ErrHelp) { return 0 } @@ -83,8 +89,8 @@ func runServer(ctx context.Context, args []string, stdout, stderr io.Writer) int } // parseServerFlags liest die Flags von `sultan server`; Defaults wie in -// server.Config. -func parseServerFlags(args []string, output io.Writer) (server.Config, error) { +// server.Config. Jedes Flag ist auch als SULTAN_ setzbar (Flags gewinnen). +func parseServerFlags(args []string, output io.Writer, getenv func(string) string) (server.Config, error) { fs := flag.NewFlagSet("sultan server", flag.ContinueOnError) fs.SetOutput(output) var cfg server.Config @@ -92,13 +98,34 @@ func parseServerFlags(args []string, output io.Writer) (server.Config, error) { fs.StringVar(&cfg.AgentAddr, "agent-addr", ":8443", "Adresse des Agent-Ports (nur TLS)") fs.StringVar(&cfg.ProviderAddr, "provider-addr", "127.0.0.1:8080", "Adresse für Traefik (/v1/traefik-config, Klartext – im Container :8080 und nur auf 127.0.0.1 veröffentlichen)") - fs.StringVar(&cfg.InterceptorURL, "interceptor-url", "http://127.0.0.1:8081", "Adresse des Sultan-Interceptors/Landers") + fs.Var((*stringList)(&cfg.PublicURLs), "public-url", "Adresse, unter der Agents den Server erreichen (z. B. https://ingress.example.com:8443); mehrfach angebbar (öffentlich, NetBird …); wird gemerkt, die erste steckt in Einladungen") + fs.StringVar(&cfg.LanderAddr, "lander-addr", "127.0.0.1:8081", `Adresse des eingebauten Landers (Hinweis-/Wartungsseiten); "none" = externer Lander`) + fs.StringVar(&cfg.InterceptorURL, "interceptor-url", "", "URL, unter der Traefik den Lander erreicht (Default: --lander-addr)") + fs.StringVar(&cfg.HosterName, "hoster-name", "", "Name des Hosting-Anbieters (Einladungsseiten)") + fs.StringVar(&cfg.HosterURL, "hoster-url", "", "Angebotsseite des Hosting-Anbieters") + fs.StringVar(&cfg.ProURL, "pro-url", "", "Pro-Angebot, verlinkt auf Community-Wartungsseiten") fs.DurationVar(&cfg.RefreshInterval, "refresh", time.Minute, "Intervall der Neubewertung aller Knoten") + fs.Var((*stringList)(&cfg.SlackWebhooks), "notify-slack", "Slack-kompatibler Webhook für Benachrichtigungen (mehrfach angebbar)") + fs.Var((*stringList)(&cfg.NtfyURLs), "notify-ntfy", "ntfy-Topic-URL für Benachrichtigungen, z. B. https://ntfy.sh/sultan (mehrfach angebbar)") + fs.StringVar(&cfg.NtfyToken, "notify-ntfy-token", "", "Access-Token für ntfy") + fs.DurationVar(&cfg.OfflineAlertAfter, "offline-alert-after", 15*time.Minute, "Alarm, wenn ein Knoten so lange getrennt ist (nicht bei Wartung)") + fs.IntVar(&cfg.EnrollLimit, "enroll-limit", 10, "Enrollment-Versuche pro Minute und Client-IP (-1 = unbegrenzt)") + fs.DurationVar(&cfg.LeaseDuration, "lease-duration", 0, "feste Lease-Laufzeit für alle Knoten (0 = je Tier: Community 14 Tage, kommerziell 7 Tage)") + annotateEnv(fs) if err := fs.Parse(args); err != nil { return server.Config{}, err } + if err := applyEnv(fs, getenv); err != nil { + return server.Config{}, err + } if fs.NArg() > 0 { return server.Config{}, fmt.Errorf("unerwartetes Argument %q", fs.Arg(0)) } return cfg, nil } + +// stringList ist ein mehrfach angebbares String-Flag. +type stringList []string + +func (l *stringList) String() string { return strings.Join(*l, ",") } +func (l *stringList) Set(v string) error { *l = append(*l, v); return nil } diff --git a/pkg/cli/cli_test.go b/pkg/cli/cli_test.go index f978513..ee522b1 100644 --- a/pkg/cli/cli_test.go +++ b/pkg/cli/cli_test.go @@ -38,41 +38,42 @@ func TestRun_Version(t *testing.T) { } } -func TestRun_NotYetImplemented(t *testing.T) { - for _, cmd := range []string{"agent", "token"} { - code, _, stderr := run(context.Background(), cmd) - if code != 1 || !strings.Contains(stderr, "noch nicht implementiert") { - t.Errorf("%s: code=%d stderr=%q", cmd, code, stderr) - } - } -} - func TestServerFlags(t *testing.T) { - cfg, err := parseServerFlags(nil, &bytes.Buffer{}) + cfg, err := parseServerFlags(nil, &bytes.Buffer{}, noEnv) if err != nil { t.Fatal(err) } if cfg.DataDir != "./suldata" || cfg.AgentAddr != ":8443" || cfg.ProviderAddr != "127.0.0.1:8080" || - cfg.InterceptorURL != "http://127.0.0.1:8081" || cfg.RefreshInterval != time.Minute { + cfg.LanderAddr != "127.0.0.1:8081" || cfg.InterceptorURL != "" || cfg.RefreshInterval != time.Minute { t.Errorf("Defaults: %+v", cfg) } cfg, err = parseServerFlags([]string{ "--data-dir=/srv/sul", "--agent-addr", ":9443", "--provider-addr=:8080", - "--interceptor-url=http://lander:8081", "--refresh=30s", - }, &bytes.Buffer{}) + "--interceptor-url=http://lander:8081", "--refresh=30s", "--lander-addr=none", + "--hoster-name=Beispiel-Hosting", "--hoster-url=https://hoster.example", "--pro-url=https://hoster.example/pro", + "--public-url=https://ingress.example:8443", "--public-url=https://100.64.0.1:8443", + "--lease-duration=1h", + "--notify-slack=https://hooks.example/a", "--notify-slack=https://hooks.example/b", + "--notify-ntfy=https://ntfy.example/sultan", "--notify-ntfy-token=tk_x", "--offline-alert-after=10m", + }, &bytes.Buffer{}, noEnv) if err != nil { t.Fatal(err) } if cfg.DataDir != "/srv/sul" || cfg.AgentAddr != ":9443" || cfg.ProviderAddr != ":8080" || - cfg.InterceptorURL != "http://lander:8081" || cfg.RefreshInterval != 30*time.Second { + cfg.InterceptorURL != "http://lander:8081" || cfg.RefreshInterval != 30*time.Second || cfg.LanderAddr != "none" || + cfg.HosterName != "Beispiel-Hosting" || cfg.HosterURL != "https://hoster.example" || cfg.ProURL != "https://hoster.example/pro" || + strings.Join(cfg.PublicURLs, ",") != "https://ingress.example:8443,https://100.64.0.1:8443" || cfg.LeaseDuration != time.Hour || + strings.Join(cfg.SlackWebhooks, ",") != "https://hooks.example/a,https://hooks.example/b" || + strings.Join(cfg.NtfyURLs, ",") != "https://ntfy.example/sultan" || cfg.NtfyToken != "tk_x" || + cfg.OfflineAlertAfter != 10*time.Minute { t.Errorf("Overrides: %+v", cfg) } - if _, err := parseServerFlags([]string{"--gibts-nicht"}, &bytes.Buffer{}); err == nil { + if _, err := parseServerFlags([]string{"--gibts-nicht"}, &bytes.Buffer{}, noEnv); err == nil { t.Error("unbekanntes Flag akzeptiert") } - if _, err := parseServerFlags([]string{"überzählig"}, &bytes.Buffer{}); err == nil { + if _, err := parseServerFlags([]string{"überzählig"}, &bytes.Buffer{}, noEnv); err == nil { t.Error("überzähliges Argument akzeptiert") } } @@ -99,7 +100,7 @@ func TestRun_ServerStartsAndStops(t *testing.T) { } done := make(chan result, 1) go func() { - code, _, stderr := run(ctx, "server", "--data-dir="+dir, "--agent-addr=127.0.0.1:0", "--provider-addr=127.0.0.1:0") + code, _, stderr := run(ctx, "server", "--data-dir="+dir, "--agent-addr=127.0.0.1:0", "--provider-addr=127.0.0.1:0", "--lander-addr=127.0.0.1:0") done <- result{code, stderr} }() @@ -137,3 +138,84 @@ func TestRun_ServerStartError(t *testing.T) { t.Errorf("unbrauchbares Datenverzeichnis: code=%d stderr=%q", code, stderr) } } + +func TestRun_DumpLanding(t *testing.T) { + dir := filepath.Join(t.TempDir(), "landing") + code, stdout, stderr := run(context.Background(), "dump-landing", "--out="+dir) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if !strings.Contains(stdout, "maintenance.html") { + t.Errorf("Ausgabe ohne Dateiliste: %q", stdout) + } + if _, err := os.Stat(filepath.Join(dir, "README.txt")); err != nil { + t.Error("README.txt fehlt") + } + if code, _, stderr := run(context.Background(), "dump-landing", "--out="+dir); code != 1 || !strings.Contains(stderr, "--force") { + t.Errorf("ohne --force: code=%d stderr=%q", code, stderr) + } + if code, _, _ := run(context.Background(), "dump-landing", "--out="+dir, "--force"); code != 0 { + t.Errorf("mit --force: code=%d", code) + } + if code, _, _ := run(context.Background(), "dump-landing", "überzählig"); code != 2 { + t.Errorf("überzähliges Argument: code=%d", code) + } +} + +func TestDumpLandingDefaultDir(t *testing.T) { + out, err := parseDumpFlags(nil, &bytes.Buffer{}) + if err != nil || out.dir != "./suldata/landing" || out.force { + t.Errorf("Defaults: %+v %v", out, err) + } +} + +func envMap(m map[string]string) func(string) string { return func(k string) string { return m[k] } } + +// Jedes Flag ist auch als SULTAN_ setzbar; Flags gewinnen, Listen +// sind kommagetrennt und werden durch ein Flag ersetzt, nicht ergänzt. +func TestServerFlags_Environment(t *testing.T) { + env := envMap(map[string]string{ + "SULTAN_DATA_DIR": "/srv/sul", + "SULTAN_PUBLIC_URL": "https://a.example:8443, https://100.64.0.1:8443", + "SULTAN_NOTIFY_NTFY_TOKEN": "tk_geheim", + "SULTAN_REFRESH": "30s", + "SULTAN_ENROLL_LIMIT": "5", + }) + cfg, err := parseServerFlags(nil, &bytes.Buffer{}, env) + if err != nil { + t.Fatal(err) + } + if cfg.DataDir != "/srv/sul" || strings.Join(cfg.PublicURLs, ",") != "https://a.example:8443,https://100.64.0.1:8443" || + cfg.NtfyToken != "tk_geheim" || cfg.RefreshInterval != 30*time.Second || cfg.EnrollLimit != 5 { + t.Errorf("aus Umgebung: %+v", cfg) + } + + cfg, err = parseServerFlags([]string{"--public-url=https://c.example:8443", "--data-dir=/anders"}, &bytes.Buffer{}, env) + if err != nil { + t.Fatal(err) + } + if strings.Join(cfg.PublicURLs, ",") != "https://c.example:8443" || cfg.DataDir != "/anders" { + t.Errorf("Flags müssen gewinnen (Listen ersetzen): %v %q", cfg.PublicURLs, cfg.DataDir) + } + + if _, err := parseServerFlags(nil, &bytes.Buffer{}, envMap(map[string]string{"SULTAN_REFRESH": "bald"})); err == nil || + !strings.Contains(err.Error(), "SULTAN_REFRESH") { + t.Errorf("fehlerhafte Variable nicht benannt: %v", err) + } + var help bytes.Buffer + parseServerFlags([]string{"--help"}, &help, noEnv) + if !strings.Contains(help.String(), "SULTAN_PUBLIC_URL") || !strings.Contains(help.String(), "SULTAN_NOTIFY_NTFY_TOKEN") { + t.Errorf("Hilfe ohne Variablennamen:\n%s", help.String()) + } +} + +func TestServerFlags_EnrollLimit(t *testing.T) { + cfg, _ := parseServerFlags(nil, &bytes.Buffer{}, noEnv) + if cfg.EnrollLimit != 10 { + t.Errorf("Default %d", cfg.EnrollLimit) + } + cfg, _ = parseServerFlags([]string{"--enroll-limit=-1"}, &bytes.Buffer{}, noEnv) // -1 = unbegrenzt + if cfg.EnrollLimit != -1 { + t.Errorf("--enroll-limit=-1: %d", cfg.EnrollLimit) + } +} diff --git a/pkg/cli/dump.go b/pkg/cli/dump.go new file mode 100644 index 0000000..d9f80af --- /dev/null +++ b/pkg/cli/dump.go @@ -0,0 +1,52 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + + "sultan/pkg/lander" +) + +type dumpOptions struct { + dir string + force bool +} + +func parseDumpFlags(args []string, output io.Writer) (dumpOptions, error) { + fs := flag.NewFlagSet("sultan dump-landing", flag.ContinueOnError) + fs.SetOutput(output) + var o dumpOptions + fs.StringVar(&o.dir, "out", "./suldata/landing", "Zielverzeichnis (der Server liest /landing)") + fs.BoolVar(&o.force, "force", false, "vorhandene Dateien überschreiben") + if err := fs.Parse(args); err != nil { + return o, err + } + if fs.NArg() > 0 { + return o, fmt.Errorf("unerwartetes Argument %q", fs.Arg(0)) + } + return o, nil +} + +// runDump schreibt die eingebauten Lander-Seiten als Vorlagen zum Anpassen. +func runDump(args []string, stdout, stderr io.Writer) int { + o, err := parseDumpFlags(args, stderr) + if errors.Is(err, flag.ErrHelp) { + return 0 + } + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + files, err := lander.Dump(o.dir, o.force) + if err != nil { + fmt.Fprintln(stderr, "Fehler:", err) + return 1 + } + for _, f := range files { + fmt.Fprintln(stdout, f) + } + fmt.Fprintln(stderr, "Vorlagen geschrieben – Änderungen wirken sofort, ohne Neustart (siehe README.txt).") + return 0 +} diff --git a/pkg/cli/env.go b/pkg/cli/env.go new file mode 100644 index 0000000..43e1d49 --- /dev/null +++ b/pkg/cli/env.go @@ -0,0 +1,51 @@ +package cli + +import ( + "flag" + "fmt" + "strings" +) + +// envName bildet einen Flag-Namen auf seine Umgebungsvariable ab: +// public-url → SULTAN_PUBLIC_URL. +func envName(flagName string) string { + return "SULTAN_" + strings.ToUpper(strings.ReplaceAll(flagName, "-", "_")) +} + +// annotateEnv ergänzt die Hilfe jedes Flags um den Namen der Variable. +func annotateEnv(fs *flag.FlagSet) { + fs.VisitAll(func(f *flag.Flag) { f.Usage += " [" + envName(f.Name) + "]" }) +} + +// applyEnv setzt nach fs.Parse alle Flags, die nicht auf der Befehlszeile +// standen, aus SULTAN_. Flags gewinnen also; Listen-Flags werden aus +// kommagetrennten Werten gefüllt (und von einem Flag ersetzt, nicht ergänzt). +func applyEnv(fs *flag.FlagSet, getenv func(string) string) error { + explicit := map[string]bool{} + fs.Visit(func(f *flag.Flag) { explicit[f.Name] = true }) + var err error + fs.VisitAll(func(f *flag.Flag) { + if err != nil || explicit[f.Name] { + return + } + name := envName(f.Name) + v := strings.TrimSpace(getenv(name)) + if v == "" { + return + } + values := []string{v} + if _, isList := f.Value.(*stringList); isList { + values = strings.Split(v, ",") + } + for _, item := range values { + if item = strings.TrimSpace(item); item == "" { + continue + } + if e := f.Value.Set(item); e != nil { + err = fmt.Errorf("%s=%q: %w", name, v, e) + return + } + } + }) + return err +} diff --git a/pkg/cli/token.go b/pkg/cli/token.go new file mode 100644 index 0000000..9d6117a --- /dev/null +++ b/pkg/cli/token.go @@ -0,0 +1,639 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "text/tabwriter" + "time" + + "sultan/pkg/auth" + "sultan/pkg/crypto" + "sultan/pkg/license" + "sultan/pkg/policy" + "sultan/pkg/server" + "sultan/pkg/store" + "sultan/pkg/stream" +) + +const tokenUsage = `sultan token – Mandanten, Einladungen, Knoten und Lizenzen verwalten. +Arbeitet direkt auf dem Datenverzeichnis des Servers (auch während er läuft). + + sultan token tenant add --id ID --name NAME --email MAIL --domains a.de,*.b.de + [--tier community|pro|enterprise] [--max-routes 5] [--valid-until JJJJ-MM-TT] + [--features custom_middleware,websockets,tcp_stream] + sultan token tenant list + sultan token tenant set-status --id ID --status active|suspended_payment|quarantined|expired + sultan token tenant delete --id ID (löscht auch Knoten und Einladungen!) + sultan token tenant payment --id ID --overdue-since JJJJ-MM-TT | --paid + (offene Zahlung → Mahnstufen nach 8/25/32 Tagen, nur kommerziell) + sultan token invite --tenant ID [--ttl 24h|7d] [--server-url https://…] + sultan token node list [--tenant ID] + sultan token node maintenance --id ID [--for 4h|0] [--reason TEXT] [--public-reason TEXT] + (Routen bleiben, Besucher sehen die Wartungsseite) + sultan token node drain --id ID (Routen werden entfernt, bis resume) + sultan token node resume --id ID (beendet Wartung und drain) + sultan token node delete --id ID + sultan token node command --id ID --cmd resync|rotate_key [--reason TEXT] + (wird beim nächsten Refresh bzw. Verbinden zugestellt) + sultan token license issue --tenant ID + sultan token purge (verbrauchte/abgelaufene Einladungen löschen) + +Alle Befehle: --data-dir DIR (Default ./suldata) +` + +// usageError: Bedienfehler (Exit-Code 2). +type usageError struct{ error } + +type tokenEnv struct { + ctx context.Context + stdout, stderr io.Writer +} + +type tokenCmd func(env tokenEnv, args []string) error + +var tokenCmds = map[string]tokenCmd{ + "tenant add": tenantAdd, + "tenant list": tenantList, + "tenant set-status": tenantSetStatus, + "tenant delete": tenantDelete, + "tenant payment": tenantPayment, + "invite": invite, + "node list": nodeList, + "node drain": nodeSetStatus(store.NodeDrained), + "node maintenance": nodeMaintenance, + "node resume": nodeResume, + "node command": nodeCommand, + "node delete": nodeDelete, + "license issue": licenseIssue, + "purge": purge, +} + +func runToken(ctx context.Context, args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprint(stderr, tokenUsage) + return 2 + } + if args[0] == "help" || args[0] == "-h" || args[0] == "--help" { + fmt.Fprint(stdout, tokenUsage) + return 0 + } + name, rest := args[0], args[1:] + if (name == "tenant" || name == "node" || name == "license") && len(rest) > 0 { + name, rest = name+" "+rest[0], rest[1:] + } + cmd, ok := tokenCmds[name] + if !ok { + fmt.Fprintf(stderr, "unbekannter Befehl \"token %s\"\n\n%s", name, tokenUsage) + return 2 + } + err := cmd(tokenEnv{ctx: ctx, stdout: stdout, stderr: stderr}, rest) + var ue usageError + switch { + case err == nil: + return 0 + case errors.Is(err, flag.ErrHelp): + return 0 + case errors.As(err, &ue): + fmt.Fprintln(stderr, err) + return 2 + default: + fmt.Fprintln(stderr, "Fehler:", err) + return 1 + } +} + +// flags legt ein FlagSet mit --data-dir an. +func flags(name string, env tokenEnv) (*flag.FlagSet, *string) { + fs := flag.NewFlagSet("sultan token "+name, flag.ContinueOnError) + fs.SetOutput(env.stderr) + dir := fs.String("data-dir", "./suldata", "Datenverzeichnis des Servers") + return fs, dir +} + +func parse(fs *flag.FlagSet, args []string, required ...string) error { + annotateEnv(fs) + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return err + } + return usageError{err} + } + if err := applyEnv(fs, os.Getenv); err != nil { + return usageError{err} + } + if fs.NArg() > 0 { + return usageError{fmt.Errorf("unerwartetes Argument %q", fs.Arg(0))} + } + for _, name := range required { + if fs.Lookup(name).Value.String() == "" { + return usageError{fmt.Errorf("--%s fehlt", name)} + } + } + return nil +} + +// openDataDir öffnet die DB eines bereits initialisierten Datenverzeichnisses. +// Ein nicht initialisiertes wird nie angelegt: Ein Tippfehler bei --data-dir +// würde sonst eine neue Server-Identität (Zertifikat, Master-Key) erzeugen. +func openDataDir(env tokenEnv, dir string) (*store.Store, error) { + for _, f := range []string{"sultan.db", "server.crt"} { + if _, err := os.Stat(filepath.Join(dir, f)); err != nil { + return nil, fmt.Errorf("Datenverzeichnis %s nicht initialisiert (%s fehlt) – zuerst `sultan server` starten oder --data-dir prüfen", dir, f) + } + } + return store.Open(env.ctx, filepath.Join(dir, "sultan.db")) +} + +func tenantAdd(env tokenEnv, args []string) error { + fs, dir := flags("tenant add", env) + id := fs.String("id", "", "Mandanten-ID (a–z, 0–9, - und _)") + name := fs.String("name", "", "Name") + email := fs.String("email", "", "Kontakt-E-Mail") + domains := fs.String("domains", "", "erlaubte Domains, kommagetrennt (z. B. kunde.de,*.kunde.de)") + tier := fs.String("tier", string(store.TierCommunity), "community, pro oder enterprise") + maxRoutes := fs.Int("max-routes", 5, "maximale Anzahl Routen (-1 = unbegrenzt)") + validUntil := fs.String("valid-until", "", "Laufzeitende JJJJ-MM-TT (Default: in einem Jahr)") + featuresRaw := fs.String("features", "", "Features, kommagetrennt (custom_middleware, websockets, tcp_stream)") + if err := parse(fs, args, "id", "name", "email", "domains"); err != nil { + return err + } + until := time.Now().AddDate(1, 0, 0) + if *validUntil != "" { + t, err := parseDate(*validUntil) + if err != nil { + return usageError{err} + } + until = t + } + if !policy.ValidID(*id) || *id == "sultan" { + return fmt.Errorf("Mandanten-ID %q ungültig (a–z, 0–9, - und _, nicht am Rand, kein \"__\", nicht \"sultan\")", *id) + } + var normalized []string + for _, d := range strings.Split(*domains, ",") { + n, err := license.NormalizeDomain(d) + if err != nil { + return fmt.Errorf("Domain %q ungültig (Beispiele: kunde.de, *.kunde.de; keine Wildcard auf TLD)", strings.TrimSpace(d)) + } + normalized = append(normalized, n) + } + + var features []string + for _, f := range strings.Split(*featuresRaw, ",") { + if f = strings.TrimSpace(f); f == "" { + continue + } + switch license.Feature(f) { + case license.FeatureCustomMiddleware, license.FeatureWebsockets, license.FeatureTCPStream: + features = append(features, f) + default: + return fmt.Errorf("unbekanntes Feature %q (custom_middleware, websockets, tcp_stream)", f) + } + } + st, err := openDataDir(env, *dir) + if err != nil { + return err + } + defer st.Close() + t, err := st.CreateTenant(env.ctx, store.Tenant{ + ID: *id, Name: *name, Email: *email, Tier: store.Tier(*tier), MaxRoutes: *maxRoutes, + AllowedDomains: normalized, ValidUntil: until, Features: features, + }) + if err != nil { + return err + } + fmt.Fprintf(env.stdout, "Mandant %s angelegt (%s, gültig bis %s)\n", t.ID, t.Tier, t.ValidUntil.Format(time.DateOnly)) + return nil +} + +func parseDate(s string) (time.Time, error) { + if t, err := time.Parse(time.DateOnly, s); err == nil { + return t, nil + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t, nil + } + return time.Time{}, fmt.Errorf("Datum %q nicht lesbar (JJJJ-MM-TT)", s) +} + +func tenantList(env tokenEnv, args []string) error { + fs, dir := flags("tenant list", env) + if err := parse(fs, args); err != nil { + return err + } + st, err := openDataDir(env, *dir) + if err != nil { + return err + } + defer st.Close() + tenants, err := st.ListTenants(env.ctx) + if err != nil { + return err + } + tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "ID\tNAME\tTIER\tSTATUS\tMAX_ROUTES\tVALID_UNTIL\tPAYMENT\tDOMAINS") + for _, t := range tenants { + payment := "-" + if !t.PaymentOverdueSince.IsZero() { + payment = "offen seit " + t.PaymentOverdueSince.UTC().Format(time.DateOnly) + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\n", t.ID, t.Name, t.Tier, t.Status, t.MaxRoutes, + t.ValidUntil.Format(time.DateOnly), payment, strings.Join(t.AllowedDomains, ",")) + } + return tw.Flush() +} + +func tenantSetStatus(env tokenEnv, args []string) error { + fs, dir := flags("tenant set-status", env) + id := fs.String("id", "", "Mandanten-ID") + status := fs.String("status", "", "active, suspended_payment, quarantined oder expired") + if err := parse(fs, args, "id", "status"); err != nil { + return err + } + st, err := openDataDir(env, *dir) + if err != nil { + return err + } + defer st.Close() + if err := st.SetTenantStatus(env.ctx, *id, store.TenantStatus(*status)); err != nil { + return err + } + fmt.Fprintf(env.stdout, "Mandant %s: Status %s (wirkt beim nächsten Refresh des Servers)\n", *id, *status) + return nil +} + +// tenantPayment setzt bzw. löscht die offene Zahlung (bis zur Admin-API). +func tenantPayment(env tokenEnv, args []string) error { + fs, dir := flags("tenant payment", env) + id := fs.String("id", "", "Mandanten-ID") + since := fs.String("overdue-since", "", "Fälligkeitsdatum der offenen Zahlung (JJJJ-MM-TT)") + paid := fs.Bool("paid", false, "Zahlung eingegangen – Mahnstufe zurücksetzen") + if err := parse(fs, args, "id"); err != nil { + return err + } + if (*since == "") == !*paid { + return usageError{errors.New("genau eines von --overdue-since oder --paid angeben")} + } + var due time.Time + if *since != "" { + t, err := parseDate(*since) + if err != nil { + return usageError{err} + } + due = t + } + st, err := openDataDir(env, *dir) + if err != nil { + return err + } + defer st.Close() + if err := st.SetTenantPayment(env.ctx, *id, due); err != nil { + return err + } + if *paid { + fmt.Fprintf(env.stdout, "Mandant %s: Zahlung eingegangen – Mahnstufe zurückgesetzt\n", *id) + } else { + fmt.Fprintf(env.stdout, "Mandant %s: Zahlung offen seit %s (Mahnstufen ab 8/25/32 Tagen)\n", *id, due.Format(time.DateOnly)) + } + return nil +} + +func tenantDelete(env tokenEnv, args []string) error { + fs, dir := flags("tenant delete", env) + id := fs.String("id", "", "Mandanten-ID") + if err := parse(fs, args, "id"); err != nil { + return err + } + st, err := openDataDir(env, *dir) + if err != nil { + return err + } + defer st.Close() + if err := st.DeleteTenant(env.ctx, *id); err != nil { + return err + } + fmt.Fprintf(env.stdout, "Mandant %s samt Knoten und Einladungen gelöscht\n", *id) + return nil +} + +func invite(env tokenEnv, args []string) error { + fs, dir := flags("invite", env) + tenant := fs.String("tenant", "", "Mandanten-ID") + ttlRaw := fs.String("ttl", "24h", "Gültigkeit der Einladung (z. B. 2h, 24h, 7d)") + serverURL := fs.String("server-url", "", "Server-Adresse für diese Einladung (Default: sultan server --public-url)") + if err := parse(fs, args, "tenant"); err != nil { + return err + } + ttl, err := parseTTL(*ttlRaw) + if err != nil { + return usageError{err} + } + if *serverURL != "" { + u, err := crypto.ValidateServerURL(*serverURL) + if err != nil { + return usageError{err} + } + *serverURL = u + } + st, err := openDataDir(env, *dir) + if err != nil { + return err + } + defer st.Close() + if *serverURL == "" { + urls, err := server.LoadPublicURLs(env.ctx, st) + if err != nil { + return err + } + if len(urls) > 0 { + *serverURL = urls[0] // die übrigen bekommt der Agent per config-Befehl + } + } + cert, err := crypto.LoadOrCreateServerCert(*dir) + if err != nil { + return err + } + tok, err := crypto.NewEnrollmentToken(cert.Fingerprint, *serverURL) + if err != nil { + return err + } + expires := time.Now().Add(ttl) + if err := st.AddEnrollmentToken(env.ctx, tok.Secret, *tenant, expires); err != nil { + return err + } + fmt.Fprintln(env.stdout, tok.String()) + fmt.Fprintf(env.stderr, "Einladung für Mandant %s, einmalig gültig bis %s.\n", *tenant, expires.Format(time.DateTime)) + if tok.ServerURL != "" { + fmt.Fprintf(env.stderr, "Server-Adresse %s ist enthalten. Auf dem Agent-Host:\n SULTAN_TOKEN= sultan agent\n", tok.ServerURL) + } else { + fmt.Fprint(env.stderr, "Auf dem Agent-Host: sultan agent --server=https://:8443 --token=\n"+ + "Tipp: Mit `sultan server --public-url=https://…` (oder invite --server-url) steckt die Adresse in der Einladung.\n") + } + return nil +} + +// parseTTL versteht Go-Dauern (2h, 90m) und zusätzlich Tage (7d). +func parseTTL(s string) (time.Duration, error) { + var d time.Duration + if days, ok := strings.CutSuffix(s, "d"); ok { + n, err := strconv.Atoi(days) + if err != nil { + return 0, fmt.Errorf("--ttl %q nicht lesbar", s) + } + d = time.Duration(n) * 24 * time.Hour + } else { + var err error + if d, err = time.ParseDuration(s); err != nil { + return 0, fmt.Errorf("--ttl %q nicht lesbar (z. B. 2h, 24h, 7d)", s) + } + } + if d <= 0 { + return 0, fmt.Errorf("--ttl muss positiv sein") + } + return d, nil +} + +func nodeList(env tokenEnv, args []string) error { + fs, dir := flags("node list", env) + tenant := fs.String("tenant", "", "nur Knoten dieses Mandanten") + if err := parse(fs, args); err != nil { + return err + } + st, err := openDataDir(env, *dir) + if err != nil { + return err + } + defer st.Close() + nodes, err := st.ListNodes(env.ctx, *tenant) + if err != nil { + return err + } + tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "ID\tTENANT\tHOSTNAME\tSTATUS\tMAINTENANCE\tLEASE\tIP\tLAST_SEEN") + for _, n := range nodes { + lease := "-" + if l, err := st.GetLease(env.ctx, n.ID); err == nil { + lease = "bis " + l.ValidUntil.Local().Format("2006-01-02 15:04") + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", n.ID, n.TenantID, n.Hostname, n.Status, + maintenanceLabel(n.Maintenance), lease, n.IPAddress, n.LastSeen.Local().Format(time.DateTime)) + } + return tw.Flush() +} + +func nodeSetStatus(status store.NodeStatus) tokenCmd { + return func(env tokenEnv, args []string) error { + fs, dir := flags("node", env) + id := fs.String("id", "", "Knoten-ID") + if err := parse(fs, args, "id"); err != nil { + return err + } + st, err := openDataDir(env, *dir) + if err != nil { + return err + } + defer st.Close() + if err := st.SetNodeStatus(env.ctx, *id, status); err != nil { + return err + } + notifyAgent(env, st, *id, stream.CmdDrain, "") + fmt.Fprintf(env.stdout, "Knoten %s: Status %s (wirkt beim nächsten Refresh des Servers)\n", *id, status) + return nil + } +} + +func maintenanceLabel(m *store.Maintenance) string { + switch { + case m == nil: + return "-" + case !m.Active(time.Now()): + return "abgelaufen" + case m.Until.IsZero(): + return "unbefristet" + default: + return "bis " + m.Until.Local().Format("2006-01-02 15:04") + } +} + +// parseMaintenanceFor versteht wie --ttl Go-Dauern und Tage; 0 = unbefristet. +func parseMaintenanceFor(s string) (time.Duration, error) { + if s == "0" { + return 0, nil + } + return parseTTL(s) +} + +func nodeMaintenance(env tokenEnv, args []string) error { + fs, dir := flags("node maintenance", env) + id := fs.String("id", "", "Knoten-ID") + forRaw := fs.String("for", "4h", "Dauer (z. B. 30m, 4h, 2d; 0 = unbefristet)") + reason := fs.String("reason", "", "interner Grund (Log, Monitoring)") + publicReason := fs.String("public-reason", "", "öffentlicher Grund – ersetzt den Standardtext der Wartungsseite") + if err := parse(fs, args, "id"); err != nil { + return err + } + d, err := parseMaintenanceFor(*forRaw) + if err != nil { + return usageError{fmt.Errorf("--for: %w", err)} + } + st, err := openDataDir(env, *dir) + if err != nil { + return err + } + defer st.Close() + m := store.Maintenance{Reason: *reason, PublicReason: *publicReason} + if d > 0 { + m.Until = time.Now().Add(d) + } + if err := st.SetNodeMaintenance(env.ctx, *id, m); err != nil { + return err + } + notifyAgent(env, st, *id, stream.CmdMaintenance, *reason) + fmt.Fprintf(env.stdout, "Knoten %s in Wartung (%s) – wirkt beim nächsten Refresh des Servers (≤ 1 min)\n", + *id, maintenanceLabel(&store.Maintenance{Since: time.Now(), Until: m.Until})) + return nil +} + +// nodeResume beendet Wartung und drain. +func nodeResume(env tokenEnv, args []string) error { + fs, dir := flags("node resume", env) + id := fs.String("id", "", "Knoten-ID") + if err := parse(fs, args, "id"); err != nil { + return err + } + st, err := openDataDir(env, *dir) + if err != nil { + return err + } + defer st.Close() + n, err := st.GetNode(env.ctx, *id) + if err != nil { + return err + } + if n.Status == store.NodeDrained { + // online setzt erst der Stream. + if err := st.SetNodeStatus(env.ctx, *id, store.NodeOffline); err != nil { + return err + } + } + if n.Maintenance != nil { + if err := st.ClearNodeMaintenance(env.ctx, *id); err != nil { + return err + } + } + notifyAgent(env, st, *id, stream.CmdResume, "") + fmt.Fprintf(env.stdout, "Knoten %s: Wartung/drain beendet – wirkt beim nächsten Refresh des Servers\n", *id) + return nil +} + +// notifyAgent reiht einen Hinweis für den Agenten ein (bester Versuch). +func notifyAgent(env tokenEnv, st *store.Store, nodeID, command, reason string) { + if err := st.EnqueueNodeCommand(env.ctx, nodeID, command, reason); err != nil { + fmt.Fprintln(env.stderr, "Hinweis an den Agenten nicht eingereiht:", err) + } +} + +// nodeCommand reiht einen Befehl ein. Nur die aktiven Befehle sind erlaubt; +// drain/maintenance/resume erzeugen ihre Hinweise selbst. +func nodeCommand(env tokenEnv, args []string) error { + fs, dir := flags("node command", env) + id := fs.String("id", "", "Knoten-ID") + cmd := fs.String("cmd", "", "resync oder rotate_key") + reason := fs.String("reason", "", "Grund (Log)") + if err := parse(fs, args, "id", "cmd"); err != nil { + return err + } + if *cmd != stream.CmdResync && *cmd != stream.CmdRotateKey { + return usageError{fmt.Errorf("--cmd %q: erlaubt sind resync und rotate_key", *cmd)} + } + st, err := openDataDir(env, *dir) + if err != nil { + return err + } + defer st.Close() + if err := st.EnqueueNodeCommand(env.ctx, *id, *cmd, *reason); err != nil { + return err + } + fmt.Fprintf(env.stdout, "Befehl %s für %s eingereiht – Zustellung beim nächsten Refresh bzw. Verbinden\n", *cmd, *id) + return nil +} + +func nodeDelete(env tokenEnv, args []string) error { + fs, dir := flags("node delete", env) + id := fs.String("id", "", "Knoten-ID") + if err := parse(fs, args, "id"); err != nil { + return err + } + st, err := openDataDir(env, *dir) + if err != nil { + return err + } + defer st.Close() + if err := st.DeleteNode(env.ctx, *id); err != nil { + return err + } + fmt.Fprintf(env.stdout, "Knoten %s gelöscht (Agent muss neu eingeladen werden)\n", *id) + return nil +} + +func licenseIssue(env tokenEnv, args []string) error { + fs, dir := flags("license issue", env) + tenant := fs.String("tenant", "", "Mandanten-ID") + if err := parse(fs, args, "tenant"); err != nil { + return err + } + st, err := openDataDir(env, *dir) + if err != nil { + return err + } + defer st.Close() + t, err := st.GetTenant(env.ctx, *tenant) + if err != nil { + return err + } + master, err := auth.ReadPrivateKey(filepath.Join(*dir, "master.key")) + if err != nil { + return err + } + l, err := (&license.Issuer{Key: master}).Issue(license.License{ + TenantID: t.ID, + Tier: license.Tier(t.Tier), + MaxRoutes: t.MaxRoutes, + AllowedDomains: t.AllowedDomains, + ValidUntil: t.ValidUntil, + }) + if err != nil { + return err + } + out, err := json.MarshalIndent(l, "", " ") + if err != nil { + return err + } + fmt.Fprintln(env.stdout, string(out)) + return nil +} + +func purge(env tokenEnv, args []string) error { + fs, dir := flags("purge", env) + if err := parse(fs, args); err != nil { + return err + } + st, err := openDataDir(env, *dir) + if err != nil { + return err + } + defer st.Close() + n, err := st.PurgeEnrollmentTokens(env.ctx, 0) + if err != nil { + return err + } + fmt.Fprintf(env.stdout, "%d Einladung(en) gelöscht\n", n) + return nil +} diff --git a/pkg/cli/token_test.go b/pkg/cli/token_test.go new file mode 100644 index 0000000..71596d1 --- /dev/null +++ b/pkg/cli/token_test.go @@ -0,0 +1,506 @@ +package cli + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "sultan/pkg/auth" + "sultan/pkg/crypto" + "sultan/pkg/license" + "sultan/pkg/server" + "sultan/pkg/store" +) + +// initDataDir initialisiert ein Datenverzeichnis so, wie es der erste +// Serverstart tut. +func initDataDir(t *testing.T) string { + t.Helper() + dir := filepath.Join(t.TempDir(), "suldata") + s, err := server.New(context.Background(), server.Config{DataDir: dir, Logger: slog.New(slog.DiscardHandler)}) + if err != nil { + t.Fatal(err) + } + s.Shutdown(context.Background()) + return dir +} + +func openStore(t *testing.T, dir string) *store.Store { + t.Helper() + st, err := store.Open(context.Background(), filepath.Join(dir, "sultan.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +// tok ruft `sultan token …` mit --data-dir auf. +func tok(t *testing.T, dir string, args ...string) (int, string, string) { + t.Helper() + full := append([]string{"token"}, args...) + // --data-dir hinter den Unterbefehl hängen (z. B. "tenant add --data-dir=…"). + full = append(full, "--data-dir="+dir) + return run(context.Background(), full...) +} + +func mustTok(t *testing.T, dir string, args ...string) string { + t.Helper() + code, stdout, stderr := tok(t, dir, args...) + if code != 0 { + t.Fatalf("sultan token %s: code=%d stderr=%s", strings.Join(args, " "), code, stderr) + } + return stdout +} + +func addTenant(t *testing.T, dir, id string, extra ...string) { + t.Helper() + mustTok(t, dir, append([]string{"tenant", "add", "--id=" + id, "--name=Kunde " + id, "--email=" + id + "@example.com", "--domains=" + id + ".de,*." + id + ".de"}, extra...)...) +} + +func TestToken_Usage(t *testing.T) { + code, _, stderr := run(context.Background(), "token") + if code != 2 { + t.Errorf("ohne Unterbefehl: code=%d", code) + } + for _, want := range []string{"tenant add", "invite", "node list", "node maintenance", "node resume", "license issue", "purge"} { + if !strings.Contains(stderr, want) { + t.Errorf("Hilfe ohne %q:\n%s", want, stderr) + } + } + if code, _, _ := run(context.Background(), "token", "zaubern"); code != 2 { + t.Errorf("unbekannter Unterbefehl: code=%d", code) + } + if code, _, _ := run(context.Background(), "token", "tenant"); code != 2 { + t.Errorf("tenant ohne Aktion: code=%d", code) + } +} + +// Ein Tippfehler bei --data-dir darf keine neue Server-Identität erzeugen. +func TestToken_RequiresInitializedDataDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), "vertippt") + code, _, stderr := tok(t, dir, "tenant", "list") + if code != 1 || !strings.Contains(stderr, "nicht initialisiert") { + t.Errorf("code=%d stderr=%q", code, stderr) + } + if _, err := os.Stat(dir); err == nil { + t.Error("Datenverzeichnis wurde angelegt") + } +} + +func TestToken_TenantAddAndList(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde", "--tier=pro", "--max-routes=20", "--valid-until=2027-12-31") + + st := openStore(t, dir) + tn, err := st.GetTenant(context.Background(), "kunde") + if err != nil { + t.Fatal(err) + } + if tn.Tier != store.TierPro || tn.MaxRoutes != 20 || tn.Status != store.TenantActive || + !tn.ValidUntil.Equal(time.Date(2027, 12, 31, 0, 0, 0, 0, time.UTC)) || + strings.Join(tn.AllowedDomains, ",") != "kunde.de,*.kunde.de" { + t.Errorf("gespeichert: %+v", tn) + } + + out := mustTok(t, dir, "tenant", "list") + for _, want := range []string{"ID", "kunde", "pro", "active", "2027-12-31", "*.kunde.de"} { + if !strings.Contains(out, want) { + t.Errorf("Liste ohne %q:\n%s", want, out) + } + } +} + +func TestToken_TenantAddDefaults(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "homelab") + tn, _ := openStore(t, dir).GetTenant(context.Background(), "homelab") + if tn.Tier != store.TierCommunity || tn.MaxRoutes != 5 { + t.Errorf("Defaults: tier=%s max_routes=%d", tn.Tier, tn.MaxRoutes) + } + if d := time.Until(tn.ValidUntil); d < 364*24*time.Hour || d > 366*24*time.Hour { + t.Errorf("Default-Laufzeit: %v", d) + } +} + +func TestToken_TenantAddDomainsNormalized(t *testing.T) { + dir := initDataDir(t) + mustTok(t, dir, "tenant", "add", "--id=k", "--name=K", "--email=k@x.de", "--domains= Shop.DE. , *.Firma.de") + tn, _ := openStore(t, dir).GetTenant(context.Background(), "k") + if strings.Join(tn.AllowedDomains, ",") != "shop.de,*.firma.de" { + t.Errorf("Domains: %v", tn.AllowedDomains) + } +} + +func TestToken_TenantAddErrors(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde") + tests := []struct { + name string + args []string + code int + }{ + {"doppelt", []string{"tenant", "add", "--id=kunde", "--name=K", "--email=k@x.de", "--domains=k.de"}, 1}, + {"ID fehlt", []string{"tenant", "add", "--name=K", "--email=k@x.de", "--domains=k.de"}, 2}, + {"Domains fehlen", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de"}, 2}, + {"Domain ungültig", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de", "--domains=*.de"}, 1}, + {"ID ungültig", []string{"tenant", "add", "--id=a__b", "--name=K", "--email=k@x.de", "--domains=k.de"}, 1}, + {"Tier ungültig", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de", "--domains=k.de", "--tier=gold"}, 1}, + {"Datum ungültig", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de", "--domains=k.de", "--valid-until=morgen"}, 2}, + } + for _, tt := range tests { + if code, _, stderr := tok(t, dir, tt.args...); code != tt.code { + t.Errorf("%s: code=%d (erwartet %d) stderr=%q", tt.name, code, tt.code, stderr) + } + } +} + +func TestToken_TenantSetStatusAndDelete(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde") + mustTok(t, dir, "tenant", "set-status", "--id=kunde", "--status=suspended_payment") + tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde") + if tn.Status != store.TenantSuspendedPayment { + t.Errorf("Status = %s", tn.Status) + } + if code, _, _ := tok(t, dir, "tenant", "set-status", "--id=kunde", "--status=pausiert"); code != 1 { + t.Errorf("ungültiger Status: code=%d", code) + } + if code, _, _ := tok(t, dir, "tenant", "set-status", "--id=fehlt", "--status=active"); code != 1 { + t.Errorf("unbekannter Mandant: code=%d", code) + } + + mustTok(t, dir, "tenant", "delete", "--id=kunde") + if _, err := openStore(t, dir).GetTenant(context.Background(), "kunde"); err == nil { + t.Error("Mandant nicht gelöscht") + } + if code, _, _ := tok(t, dir, "tenant", "delete", "--id=kunde"); code != 1 { + t.Errorf("zweites Löschen: code=%d", code) + } +} + +// Die Einladung muss den Fingerprint des echten Server-Zertifikats tragen +// und beim Server einlösbar sein. +func TestToken_Invite(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde") + + code, stdout, stderr := tok(t, dir, "invite", "--tenant=kunde") + if code != 0 { + t.Fatalf("invite: code=%d stderr=%s", code, stderr) + } + lines := strings.Split(strings.TrimSpace(stdout), "\n") + if len(lines) != 1 { + t.Fatalf("stdout muss genau den Token enthalten: %q", stdout) + } + inv, err := crypto.ParseEnrollmentToken(lines[0]) + if err != nil { + t.Fatalf("Token nicht lesbar: %v", err) + } + cert, _ := crypto.LoadOrCreateServerCert(dir) + if inv.Fingerprint != cert.Fingerprint { + t.Error("Token trägt nicht den Fingerprint des Server-Zertifikats") + } + if !strings.Contains(stderr, "kunde") { + t.Errorf("Hinweis auf stderr fehlt: %q", stderr) + } + + st := openStore(t, dir) + if _, err := st.Enroll(context.Background(), inv.Secret, store.Node{ID: "n1", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"}); err != nil { + t.Errorf("Token nicht einlösbar: %v", err) + } +} + +func TestToken_InviteErrors(t *testing.T) { + dir := initDataDir(t) + if code, _, _ := tok(t, dir, "invite", "--tenant=fehlt"); code != 1 { + t.Errorf("unbekannter Mandant: code=%d", code) + } + if code, _, _ := tok(t, dir, "invite"); code != 2 { + t.Errorf("ohne --tenant: code=%d", code) + } + addTenant(t, dir, "kunde") + for _, ttl := range []string{"--ttl=0s", "--ttl=-1h", "--ttl=bald"} { + if code, _, _ := tok(t, dir, "invite", "--tenant=kunde", ttl); code != 2 { + t.Errorf("%s: code=%d", ttl, code) + } + } + if code, _, _ := tok(t, dir, "invite", "--tenant=kunde", "--ttl=7d"); code != 0 { + t.Errorf("--ttl=7d (Tage) nicht akzeptiert: code=%d", code) + } +} + +func TestToken_Nodes(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde") + st := openStore(t, dir) + if _, err := st.CreateNode(context.Background(), store.Node{ID: "node-1", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "192.168.1.20"}); err != nil { + t.Fatal(err) + } + + out := mustTok(t, dir, "node", "list") + for _, want := range []string{"ID", "node-1", "kunde", "pi", "online", "192.168.1.20"} { + if !strings.Contains(out, want) { + t.Errorf("Liste ohne %q:\n%s", want, out) + } + } + if out := mustTok(t, dir, "node", "list", "--tenant=andere"); strings.Contains(out, "node-1") { + t.Errorf("Filter nach Mandant greift nicht:\n%s", out) + } + + mustTok(t, dir, "node", "drain", "--id=node-1") + if n, _ := st.GetNode(context.Background(), "node-1"); n.Status != store.NodeDrained { + t.Errorf("drain: %s", n.Status) + } + mustTok(t, dir, "node", "resume", "--id=node-1") + if n, _ := st.GetNode(context.Background(), "node-1"); n.Status != store.NodeOffline { + t.Errorf("resume nach drain: %s (erwartet offline; online setzt erst der Stream)", n.Status) + } + mustTok(t, dir, "node", "delete", "--id=node-1") + if _, err := st.GetNode(context.Background(), "node-1"); err == nil { + t.Error("Knoten nicht gelöscht") + } + if code, _, _ := tok(t, dir, "node", "drain", "--id=fehlt"); code != 1 { + t.Errorf("unbekannter Knoten: code=%d", code) + } +} + +func TestToken_LicenseIssue(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde", "--tier=enterprise", "--max-routes=-1", "--valid-until=2027-06-30") + + stdout := mustTok(t, dir, "license", "issue", "--tenant=kunde") + master, err := auth.ReadPrivateKey(filepath.Join(dir, "master.key")) + if err != nil { + t.Fatal(err) + } + l, err := license.Parse([]byte(stdout), master.Public().(ed25519.PublicKey)) + if err != nil { + t.Fatalf("Lizenz ungültig: %v\n%s", err, stdout) + } + if l.TenantID != "kunde" || l.Tier != license.TierEnterprise || l.MaxRoutes != -1 || !l.AllowsDomain("www.kunde.de") { + t.Errorf("Lizenz: %+v", l) + } + if !json.Valid([]byte(stdout)) { + t.Error("stdout ist kein reines JSON") + } + if code, _, _ := tok(t, dir, "license", "issue", "--tenant=fehlt"); code != 1 { + t.Errorf("unbekannter Mandant: code=%d", code) + } +} + +func TestToken_Purge(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde") + inv := strings.TrimSpace(mustTok(t, dir, "invite", "--tenant=kunde")) + parsed, _ := crypto.ParseEnrollmentToken(inv) + st := openStore(t, dir) + st.Enroll(context.Background(), parsed.Secret, store.Node{ID: "n1", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"}) + + out := mustTok(t, dir, "purge") + if !strings.Contains(out, "1") { + t.Errorf("purge-Ausgabe: %q", out) + } +} + +// Funktioniert auch, während der Server läuft (gleiche SQLite-DB, WAL). +func TestToken_WhileServerRuns(t *testing.T) { + dir := initDataDir(t) + s, err := server.New(context.Background(), server.Config{ + DataDir: dir, AgentAddr: "127.0.0.1:0", ProviderAddr: "127.0.0.1:0", LanderAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler), + }) + if err != nil { + t.Fatal(err) + } + if err := s.Start(); err != nil { + t.Fatal(err) + } + defer s.Shutdown(context.Background()) + + addTenant(t, dir, "kunde") + inv := strings.TrimSpace(mustTok(t, dir, "invite", "--tenant=kunde")) + parsed, err := crypto.ParseEnrollmentToken(inv) + if err != nil || parsed.Fingerprint != s.Fingerprint() { + t.Errorf("Token passt nicht zum laufenden Server: %v", err) + } +} + +func setPublicURL(t *testing.T, dir, url string) { + t.Helper() + s, err := server.New(context.Background(), server.Config{DataDir: dir, PublicURLs: []string{url, "https://100.64.0.1:8443"}, Logger: slog.New(slog.DiscardHandler)}) + if err != nil { + t.Fatal(err) + } + s.Shutdown(context.Background()) +} + +func TestToken_InviteEmbedsPublicURL(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde") + + // Ohne öffentliche Adresse: Token ohne URL, Hinweis auf --public-url. + code, stdout, stderr := tok(t, dir, "invite", "--tenant=kunde") + inv, _ := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout)) + if code != 0 || inv.ServerURL != "" || !strings.Contains(stderr, "--public-url") { + t.Errorf("ohne Adresse: code=%d url=%q stderr=%q", code, inv.ServerURL, stderr) + } + + setPublicURL(t, dir, "https://ingress.example:8443") + code, stdout, stderr = tok(t, dir, "invite", "--tenant=kunde") + inv, err := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout)) + if code != 0 || err != nil || inv.ServerURL != "https://ingress.example:8443" { + t.Errorf("mit gemerkter Adresse: code=%d %+v %v", code, inv, err) + } + if !strings.Contains(stderr, "SULTAN_TOKEN=") { + t.Errorf("Hinweis ohne fertige Befehlszeile: %q", stderr) + } + + // Pro Einladung überschreibbar, z. B. NetBird-Adresse für diesen Knoten. + _, stdout, _ = tok(t, dir, "invite", "--tenant=kunde", "--server-url=https://100.64.0.1:8443") + if inv, _ := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout)); inv.ServerURL != "https://100.64.0.1:8443" { + t.Errorf("--server-url: %+v", inv) + } + if code, _, _ := tok(t, dir, "invite", "--tenant=kunde", "--server-url=http://x:8443"); code != 2 { + t.Errorf("ungültige --server-url: code=%d", code) + } +} + +func TestToken_NodeMaintenance(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde") + st := openStore(t, dir) + st.CreateNode(context.Background(), store.Node{ID: "pi", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "1.2.3.4"}) + + out := mustTok(t, dir, "node", "maintenance", "--id=pi", "--for=2h", "--reason=Kernel-Update", "--public-reason=Sicherheitsupdate") + if !strings.Contains(out, "pi") { + t.Errorf("Ausgabe: %q", out) + } + n, _ := st.GetNode(context.Background(), "pi") + if m := n.Maintenance; m == nil || m.Reason != "Kernel-Update" || m.PublicReason != "Sicherheitsupdate" || + time.Until(m.Until) < 119*time.Minute || time.Until(m.Until) > 121*time.Minute { + t.Errorf("gespeichert: %+v", n.Maintenance) + } + if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "MAINTENANCE") || !strings.Contains(list, "bis ") { + t.Errorf("Liste ohne Wartung:\n%s", list) + } + + mustTok(t, dir, "node", "maintenance", "--id=pi") // Default 4h + if n, _ := st.GetNode(context.Background(), "pi"); time.Until(n.Maintenance.Until) < 239*time.Minute { + t.Errorf("Default-Dauer: %v", time.Until(n.Maintenance.Until)) + } + mustTok(t, dir, "node", "maintenance", "--id=pi", "--for=0") + if n, _ := st.GetNode(context.Background(), "pi"); !n.Maintenance.Until.IsZero() { + t.Error("--for=0 nicht unbefristet") + } + if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "unbefristet") { + t.Errorf("Liste:\n%s", list) + } + + mustTok(t, dir, "node", "resume", "--id=pi") + if n, _ := st.GetNode(context.Background(), "pi"); n.Maintenance != nil { + t.Error("resume beendet die Wartung nicht") + } + for _, args := range [][]string{ + {"node", "maintenance", "--id=pi", "--for=bald"}, + {"node", "maintenance", "--id=pi", "--for=-1h"}, + {"node", "maintenance"}, + } { + if code, _, _ := tok(t, dir, args...); code != 2 { + t.Errorf("%v: code=%d", args, code) + } + } + if code, _, _ := tok(t, dir, "node", "maintenance", "--id=fehlt"); code != 1 { + t.Errorf("unbekannter Knoten: code=%d", code) + } +} + +func TestToken_NodeCommand(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde") + st := openStore(t, dir) + st.CreateNode(context.Background(), store.Node{ID: "pi", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "1.2.3.4"}) + + mustTok(t, dir, "node", "command", "--id=pi", "--cmd=rotate_key", "--reason=Routine") + mustTok(t, dir, "node", "drain", "--id=pi") // erzeugt zusätzlich einen Hinweis an den Agent + cmds, _ := st.PendingNodeCommands(context.Background(), "pi") + if len(cmds) != 2 || cmds[0].Command != "rotate_key" || cmds[0].Reason != "Routine" || cmds[1].Command != "drain" { + t.Errorf("Warteschlange: %+v", cmds) + } + if code, _, _ := tok(t, dir, "node", "command", "--id=pi", "--cmd=rm -rf /"); code != 2 { + t.Errorf("beliebiger Befehl: code=%d", code) + } + if code, _, _ := tok(t, dir, "node", "command", "--id=fehlt", "--cmd=resync"); code != 1 { + t.Errorf("unbekannter Knoten: code=%d", code) + } +} + +func TestToken_NodeListShowsLease(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde") + st := openStore(t, dir) + st.CreateNode(context.Background(), store.Node{ID: "pi", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "1.2.3.4"}) + if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "LEASE") { + t.Errorf("Spalte LEASE fehlt:\n%s", list) + } + st.PutLease(context.Background(), store.Lease{NodeID: "pi", IssuedAt: time.Now(), RenewAfter: time.Now().Add(time.Hour), + ValidUntil: time.Date(2030, 1, 2, 3, 4, 0, 0, time.Local), Document: "{}"}) + if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "2030-01-02 03:04") { + t.Errorf("Lease-Ende fehlt:\n%s", list) + } +} + +func TestToken_TenantAddFeatures(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde", "--features=custom_middleware,websockets") + tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde") + if strings.Join(tn.Features, ",") != "custom_middleware,websockets" { + t.Errorf("Features: %v", tn.Features) + } + if code, _, _ := tok(t, dir, "tenant", "add", "--id=x", "--name=x", "--email=x@x.de", "--domains=x.de", "--features=teleport"); code != 1 { + t.Errorf("unbekanntes Feature: code=%d", code) + } +} + +func TestToken_TenantPayment(t *testing.T) { + dir := initDataDir(t) + addTenant(t, dir, "kunde", "--tier=pro") + st := openStore(t, dir) + + mustTok(t, dir, "tenant", "payment", "--id=kunde", "--overdue-since=2026-09-01") + if tn, _ := st.GetTenant(context.Background(), "kunde"); !tn.PaymentOverdueSince.Equal(time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) { + t.Errorf("offen seit: %v", tn.PaymentOverdueSince) + } + if list := mustTok(t, dir, "tenant", "list"); !strings.Contains(list, "PAYMENT") || !strings.Contains(list, "offen seit 2026-09-01") { + t.Errorf("Liste ohne Zahlungsstatus:\n%s", list) + } + mustTok(t, dir, "tenant", "payment", "--id=kunde", "--paid") + if tn, _ := st.GetTenant(context.Background(), "kunde"); !tn.PaymentOverdueSince.IsZero() { + t.Error("--paid setzt nicht zurück") + } + for _, args := range [][]string{ + {"tenant", "payment", "--id=kunde"}, + {"tenant", "payment", "--id=kunde", "--paid", "--overdue-since=2026-09-01"}, + {"tenant", "payment", "--id=kunde", "--overdue-since=gestern"}, + } { + if code, _, _ := tok(t, dir, args...); code != 2 { + t.Errorf("%v: code=%d", args, code) + } + } + if code, _, _ := tok(t, dir, "tenant", "payment", "--id=fehlt", "--paid"); code != 1 { + t.Errorf("unbekannter Mandant: code=%d", code) + } +} + +func TestToken_DataDirFromEnvironment(t *testing.T) { + dir := initDataDir(t) + t.Setenv("SULTAN_DATA_DIR", dir) + if code, _, stderr := run(context.Background(), "token", "tenant", "list"); code != 0 { + t.Errorf("code=%d stderr=%s", code, stderr) + } +} diff --git a/pkg/crypto/cert.go b/pkg/crypto/cert.go index c6c9325..608e3b8 100644 --- a/pkg/crypto/cert.go +++ b/pkg/crypto/cert.go @@ -3,12 +3,12 @@ // // - Server: selbstsigniertes ECDSA-P-256-Zertifikat, einmalig erzeugt und // dauerhaft im Datenverzeichnis abgelegt; nur TLS 1.3. -// - Agent: Zertifikats-Pinning über den SHA-256-Fingerprint -// (known_servers). Einen blinden Erstkontakt gibt es nicht: Beim ersten -// Verbinden muss der Fingerprint aus dem Enrollment-Token passen. -// - Enrollment-Token: sltn_enroll_.. Der Server -// speichert nur das Secret (gehasht), der Agent nutzt den Fingerprint -// für den Erstkontakt. +// - Agent: Zertifikats-Pinning über den SHA-256-Fingerprint, unabhängig +// von der Adresse. Einen blinden Erstkontakt gibt es nicht: Der +// Fingerprint kommt aus der Einladung, danach aus node.json. +// - Enrollment-Token: sltn_enroll_.[.]. +// Der Server speichert nur das Secret (gehasht), der Agent nutzt +// Fingerprint und Adresse für den Erstkontakt. package crypto import ( diff --git a/pkg/crypto/pin.go b/pkg/crypto/pin.go index ea06edc..0fd7f86 100644 --- a/pkg/crypto/pin.go +++ b/pkg/crypto/pin.go @@ -1,142 +1,45 @@ package crypto import ( - "bufio" - "bytes" "crypto/tls" "errors" "fmt" - "os" - "strings" - "sync" ) var ( - // ErrUnknownServer: Kein Pin und kein erwarteter Fingerprint – ein - // ungeprüfter Erstkontakt wird verweigert. - ErrUnknownServer = errors.New("crypto: unbekannter Server – Fingerprint aus dem Enrollment-Token erforderlich") - // ErrFingerprintMismatch: Das Server-Zertifikat passt nicht zum Pin bzw. - // zum erwarteten Fingerprint (möglicher Man-in-the-Middle). + // ErrUnknownServer: Kein Fingerprint bekannt – ein ungeprüfter Kontakt + // wird verweigert (kein blindes TOFU). + ErrUnknownServer = errors.New("crypto: unbekannter Server – Fingerprint aus der Einladung erforderlich") + // ErrFingerprintMismatch: Das Server-Zertifikat passt nicht zum Pin + // (möglicher Man-in-the-Middle). ErrFingerprintMismatch = errors.New("crypto: Server-Fingerprint stimmt nicht – möglicher Man-in-the-Middle") - // ErrCorruptKnownServers: Die known_servers-Datei ist nicht lesbar. - ErrCorruptKnownServers = errors.New("crypto: known_servers beschädigt") ) -// KnownServers ist die Pin-Datei des Agenten (analog zu SSH known_hosts). -// Format: eine Zeile " ", # für Kommentare. Einträge -// werden nur angehängt, nie stillschweigend geändert. -type KnownServers struct { - Path string - mu sync.Mutex -} - -// Lookup liefert den Pin für server. Eine fehlende Datei bedeutet: kein Pin. -func (k *KnownServers) Lookup(server string) (Fingerprint, bool, error) { - k.mu.Lock() - defer k.mu.Unlock() - return k.lookup(server) -} - -func (k *KnownServers) lookup(server string) (Fingerprint, bool, error) { - data, err := os.ReadFile(k.Path) - if errors.Is(err, os.ErrNotExist) { - return "", false, nil - } - if err != nil { - return "", false, err - } - sc := bufio.NewScanner(bytes.NewReader(data)) - for line := 1; sc.Scan(); line++ { - text := strings.TrimSpace(sc.Text()) - if text == "" || strings.HasPrefix(text, "#") { - continue - } - fields := strings.Fields(text) - if len(fields) != 2 { - return "", false, fmt.Errorf("%w: %s Zeile %d", ErrCorruptKnownServers, k.Path, line) - } - fp, err := ParseFingerprint(fields[1]) - if err != nil { - return "", false, fmt.Errorf("%w: %s Zeile %d: %w", ErrCorruptKnownServers, k.Path, line, err) - } - if fields[0] == server { - return fp, true, nil - } - } - return "", false, sc.Err() -} - -// Add pinnt fp für server. Ist bereits ein anderer Fingerprint gepinnt, -// liefert Add ErrFingerprintMismatch und ändert nichts. -func (k *KnownServers) Add(server string, fp Fingerprint) error { - if len(strings.Fields(server)) != 1 || strings.TrimSpace(server) != server { - return fmt.Errorf("crypto: ungültiger Servername %q", server) - } - if _, err := ParseFingerprint(string(fp)); err != nil { - return err - } - k.mu.Lock() - defer k.mu.Unlock() - pinned, ok, err := k.lookup(server) - if err != nil { - return err - } - if ok { - if pinned != fp { - return fmt.Errorf("%w: %s ist auf %s gepinnt, nicht %s", ErrFingerprintMismatch, server, pinned, fp) - } - return nil - } - f, err := os.OpenFile(k.Path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600) - if err != nil { - return err - } - _, err = fmt.Fprintf(f, "%s %s\n", server, fp) - if cerr := f.Close(); err == nil { - err = cerr - } - return err -} - -// ClientTLSConfig liefert die TLS-Konfiguration des Agenten für server -// (host:port aus der --server-URL). Statt einer CA prüft sie den -// Fingerprint: +// PinnedTLSConfig liefert die TLS-Konfiguration des Agenten. Statt einer CA +// wird der Fingerprint des Server-Zertifikats geprüft – unabhängig von der +// Adresse, über die der Server erreicht wird (DNS, interne IP, NetBird): +// Derselbe Server hat überall denselben Fingerprint. // -// - Pin vorhanden: Das Zertifikat muss dazu passen (expected wird ignoriert). -// - Kein Pin, expected gesetzt (Fingerprint aus dem Enrollment-Token): -// Das Zertifikat muss dazu passen und wird dann gepinnt. -// - Kein Pin, kein expected: Verbindung wird verweigert (ErrUnknownServer). -// -// Die Prüfung läuft im TLS-Handshake, also bevor irgendein Request-Byte -// (Token, Signatur, Body) gesendet wird. Nur TLS 1.3. -func ClientTLSConfig(server string, ks *KnownServers, expected Fingerprint) *tls.Config { +// Beim Erstkontakt kommt want aus der Einladung, danach aus node.json. Die +// Prüfung läuft im TLS-Handshake, also bevor irgendein Request-Byte (Token, +// Signatur, Body) gesendet wird. Nur TLS 1.3. +func PinnedTLSConfig(want Fingerprint) *tls.Config { return &tls.Config{ MinVersion: tls.VersionTLS13, - // Die CA-Prüfung entfällt bewusst; VerifyConnection ersetzt sie - // durch die strengere Fingerprint-Prüfung. + // Die CA- und Hostnamen-Prüfung entfällt bewusst; VerifyConnection + // ersetzt sie durch die strengere Fingerprint-Prüfung. InsecureSkipVerify: true, VerifyConnection: func(cs tls.ConnectionState) error { + if want == "" { + return ErrUnknownServer + } if len(cs.PeerCertificates) == 0 { return fmt.Errorf("%w: kein Zertifikat", ErrFingerprintMismatch) } - actual := FingerprintOf(cs.PeerCertificates[0]) - pinned, ok, err := ks.Lookup(server) - if err != nil { - return err + if got := FingerprintOf(cs.PeerCertificates[0]); got != want { + return fmt.Errorf("%w: Server präsentiert %s, erwartet %s", ErrFingerprintMismatch, got, want) } - if ok { - if actual != pinned { - return fmt.Errorf("%w: %s präsentiert %s, gepinnt ist %s", ErrFingerprintMismatch, server, actual, pinned) - } - return nil - } - if expected == "" { - return fmt.Errorf("%w (%s präsentiert %s)", ErrUnknownServer, server, actual) - } - if actual != expected { - return fmt.Errorf("%w: %s präsentiert %s, Token erwartet %s", ErrFingerprintMismatch, server, actual, expected) - } - return ks.Add(server, actual) + return nil }, } } diff --git a/pkg/crypto/pin_test.go b/pkg/crypto/pin_test.go index 8c3ee90..53af246 100644 --- a/pkg/crypto/pin_test.go +++ b/pkg/crypto/pin_test.go @@ -7,78 +7,10 @@ import ( "log" "net/http" "net/http/httptest" - "os" - "path/filepath" - "strings" "sync/atomic" "testing" ) -func newKnownServers(t *testing.T) *KnownServers { - t.Helper() - return &KnownServers{Path: filepath.Join(t.TempDir(), "known_servers")} -} - -func TestKnownServers(t *testing.T) { - ks := newKnownServers(t) - sc, _ := LoadOrCreateServerCert(t.TempDir()) - other, _ := LoadOrCreateServerCert(t.TempDir()) - - if _, ok, err := ks.Lookup("ingress:8443"); ok || err != nil { - t.Fatalf("leere Datei: ok=%v err=%v", ok, err) - } - if err := ks.Add("ingress:8443", sc.Fingerprint); err != nil { - t.Fatalf("Add: %v", err) - } - if fp, ok, err := ks.Lookup("ingress:8443"); !ok || err != nil || fp != sc.Fingerprint { - t.Errorf("Lookup = %q, %v, %v", fp, ok, err) - } - if err := ks.Add("ingress:8443", sc.Fingerprint); err != nil { - t.Errorf("gleichen Eintrag erneut hinzufügen: %v", err) - } - if err := ks.Add("ingress:8443", other.Fingerprint); !errors.Is(err, ErrFingerprintMismatch) { - t.Errorf("Pin überschreiben: erwartet ErrFingerprintMismatch, bekommen %v", err) - } - if err := ks.Add("anderer:8443", other.Fingerprint); err != nil { - t.Errorf("zweiter Server: %v", err) - } - - data, _ := os.ReadFile(ks.Path) - if n := strings.Count(string(data), "ingress:8443"); n != 1 { - t.Errorf("ingress:8443 steht %d-mal in der Datei", n) - } - info, _ := os.Stat(ks.Path) - if info.Mode().Perm() != 0o600 { - t.Errorf("Dateirechte %o, erwartet 600", info.Mode().Perm()) - } -} - -func TestKnownServers_FileFormat(t *testing.T) { - ks := newKnownServers(t) - sc, _ := LoadOrCreateServerCert(t.TempDir()) - content := "# Kommentar\n\n ingress:8443 " + string(sc.Fingerprint) + " \n" - os.WriteFile(ks.Path, []byte(content), 0o600) - if fp, ok, err := ks.Lookup("ingress:8443"); !ok || err != nil || fp != sc.Fingerprint { - t.Errorf("Lookup = %q, %v, %v", fp, ok, err) - } - - for name, bad := range map[string]string{ - "ein Feld": "ingress:8443\n", - "drei Felder": "ingress:8443 " + string(sc.Fingerprint) + " x\n", - "kaputter Fingerprint": "ingress:8443 abc\n", - } { - t.Run(name, func(t *testing.T) { - os.WriteFile(ks.Path, []byte(bad), 0o600) - if _, _, err := ks.Lookup("ingress:8443"); !errors.Is(err, ErrCorruptKnownServers) { - t.Errorf("erwartet ErrCorruptKnownServers, bekommen %v", err) - } - }) - } - if err := ks.Add("mit leerzeichen", sc.Fingerprint); err == nil { - t.Error("Servername mit Leerzeichen akzeptiert") - } -} - // tlsServer startet einen Test-Server mit dem Zertifikat aus einem eigenen // Datenverzeichnis und zählt, ob ein Request ihn erreicht. func tlsServer(t *testing.T) (*httptest.Server, ServerCert, *atomic.Int32) { @@ -98,96 +30,63 @@ func tlsServer(t *testing.T) (*httptest.Server, ServerCert, *atomic.Int32) { return srv, sc, &hits } -func get(t *testing.T, srv *httptest.Server, cfg *tls.Config) error { +func get(t *testing.T, url string, cfg *tls.Config) error { t.Helper() c := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}} - resp, err := c.Get(srv.URL) + resp, err := c.Get(url) if err == nil { resp.Body.Close() } return err } -func TestClientTLS_FirstContactWithExpectedFingerprint(t *testing.T) { +func TestPinnedTLS_Matches(t *testing.T) { srv, sc, hits := tlsServer(t) - ks := newKnownServers(t) - addr := srv.Listener.Addr().String() - - if err := get(t, srv, ClientTLSConfig(addr, ks, sc.Fingerprint)); err != nil { - t.Fatalf("erster Kontakt mit korrektem Fingerprint: %v", err) + if err := get(t, srv.URL, PinnedTLSConfig(sc.Fingerprint)); err != nil { + t.Fatalf("passender Fingerprint: %v", err) } if hits.Load() != 1 { t.Error("Request kam nicht an") } - if fp, ok, _ := ks.Lookup(addr); !ok || fp != sc.Fingerprint { - t.Errorf("Pin nicht gespeichert: %q %v", fp, ok) - } - // Folgekontakt ohne Token-Fingerprint: der Pin genügt. - if err := get(t, srv, ClientTLSConfig(addr, ks, "")); err != nil { - t.Errorf("Folgekontakt mit Pin: %v", err) +} + +// Der Pin hängt am Server, nicht an der Adresse: derselbe Server unter einer +// anderen Adresse (hier localhost statt 127.0.0.1) ist weiterhin vertrauenswürdig. +func TestPinnedTLS_AddressIndependent(t *testing.T) { + srv, sc, _ := tlsServer(t) + port := srv.Listener.Addr().(interface{ String() string }).String() + port = port[len("127.0.0.1"):] + if err := get(t, "https://localhost"+port, PinnedTLSConfig(sc.Fingerprint)); err != nil { + t.Errorf("andere Adresse, gleicher Server: %v", err) } } -// Kein blindes TOFU: Ohne Pin und ohne erwarteten Fingerprint wird nicht verbunden. -func TestClientTLS_NoBlindFirstContact(t *testing.T) { - srv, _, hits := tlsServer(t) - ks := newKnownServers(t) - addr := srv.Listener.Addr().String() - - if err := get(t, srv, ClientTLSConfig(addr, ks, "")); !errors.Is(err, ErrUnknownServer) { - t.Errorf("erwartet ErrUnknownServer, bekommen %v", err) - } - if hits.Load() != 0 { - t.Error("Request hat den Server erreicht") - } - if _, ok, _ := ks.Lookup(addr); ok { - t.Error("unbekannter Server wurde gepinnt") - } -} - -// Angreifer beim Erstkontakt: Er präsentiert sein eigenes Zertifikat, der -// Token enthält aber den Fingerprint des echten Servers. -func TestClientTLS_MITMOnFirstContact(t *testing.T) { +// Angreifer mit eigenem Zertifikat: Abbruch im Handshake, kein Request +// (also auch kein Token, keine Signatur) erreicht ihn. +func TestPinnedTLS_Mismatch(t *testing.T) { mitm, _, hits := tlsServer(t) real, _ := LoadOrCreateServerCert(t.TempDir()) - ks := newKnownServers(t) - addr := mitm.Listener.Addr().String() - - if err := get(t, mitm, ClientTLSConfig(addr, ks, real.Fingerprint)); !errors.Is(err, ErrFingerprintMismatch) { + if err := get(t, mitm.URL, PinnedTLSConfig(real.Fingerprint)); !errors.Is(err, ErrFingerprintMismatch) { t.Errorf("erwartet ErrFingerprintMismatch, bekommen %v", err) } - if hits.Load() != 0 { - t.Error("Request (inkl. Token) hat den Angreifer erreicht") - } - if _, ok, _ := ks.Lookup(addr); ok { - t.Error("Angreifer wurde gepinnt") - } -} - -// Angreifer nach dem Erstkontakt: Der Pin gewinnt, auch wenn ein (neuer) -// Token-Fingerprint auf den Angreifer passen würde. -func TestClientTLS_PinWinsLater(t *testing.T) { - mitm, mitmCert, hits := tlsServer(t) - real, _ := LoadOrCreateServerCert(t.TempDir()) - ks := newKnownServers(t) - addr := mitm.Listener.Addr().String() - if err := ks.Add(addr, real.Fingerprint); err != nil { - t.Fatal(err) - } - - for _, expected := range []Fingerprint{"", mitmCert.Fingerprint} { - if err := get(t, mitm, ClientTLSConfig(addr, ks, expected)); !errors.Is(err, ErrFingerprintMismatch) { - t.Errorf("expected=%q: erwartet ErrFingerprintMismatch, bekommen %v", expected, err) - } - } if hits.Load() != 0 { t.Error("Request hat den Angreifer erreicht") } } -func TestClientTLSConfig_OnlyTLS13(t *testing.T) { - cfg := ClientTLSConfig("x:1", newKnownServers(t), "") - if cfg.MinVersion != tls.VersionTLS13 { - t.Errorf("MinVersion = %x, erwartet TLS 1.3", cfg.MinVersion) +// Kein blindes Vertrauen: ohne erwarteten Fingerprint keine Verbindung. +func TestPinnedTLS_NoFingerprint(t *testing.T) { + srv, _, hits := tlsServer(t) + if err := get(t, srv.URL, PinnedTLSConfig("")); !errors.Is(err, ErrUnknownServer) { + t.Errorf("erwartet ErrUnknownServer, bekommen %v", err) + } + if hits.Load() != 0 { + t.Error("Request erreichte den Server") + } +} + +func TestPinnedTLSConfig_OnlyTLS13(t *testing.T) { + if cfg := PinnedTLSConfig("x"); cfg.MinVersion != tls.VersionTLS13 { + t.Errorf("MinVersion = %x", cfg.MinVersion) } } diff --git a/pkg/crypto/token.go b/pkg/crypto/token.go index e7c0e4c..7d6cf72 100644 --- a/pkg/crypto/token.go +++ b/pkg/crypto/token.go @@ -5,49 +5,80 @@ import ( "encoding/base64" "errors" "fmt" + "net" + "net/url" + "strconv" "strings" ) // TokenPrefix kennzeichnet Enrollment-Tokens. const TokenPrefix = "sltn_enroll_" -// ErrInvalidToken: Der Token-String hat nicht das Format -// sltn_enroll_.. -var ErrInvalidToken = errors.New("crypto: ungültiger Enrollment-Token") +var ( + // ErrInvalidToken: Der Token-String hat nicht das Format + // sltn_enroll_.[.]. + ErrInvalidToken = errors.New("crypto: ungültiger Enrollment-Token") + // ErrInvalidServerURL: keine reine https://host[:port]-Adresse. + ErrInvalidServerURL = errors.New("crypto: ungültige Server-URL") +) + +const maxServerURLLen = 255 // EnrollmentToken ist die Einladung für einen neuen Agenten. // // Secret (inkl. Präfix) ist der geheime Teil: Der Server speichert davon nur // den Hash (store.AddEnrollmentToken), der Agent schickt es beim Enrollment. // Fingerprint ist nicht geheim; er sagt dem Agenten, welches -// Server-Zertifikat er beim Erstkontakt erwarten muss. +// Server-Zertifikat er erwarten muss. ServerURL (optional, nicht geheim) ist +// die Adresse, unter der der Agent den Server erreicht – eine falsche +// Adresse führt dank Fingerprint-Prüfung höchstens zu einem +// Verbindungsfehler, nie zu einem falschen Server. type EnrollmentToken struct { Secret string Fingerprint Fingerprint + ServerURL string } -// String liefert die Form, die der Admin an den Agenten weitergibt. -func (t EnrollmentToken) String() string { return t.Secret + "." + string(t.Fingerprint) } +// String liefert die Form, die der Admin an den Agenten weitergibt. Alle +// Teile sind Base64url – der Token ist ohne Quoting shell-sicher. +func (t EnrollmentToken) String() string { + s := t.Secret + "." + string(t.Fingerprint) + if t.ServerURL != "" { + s += "." + base64.RawURLEncoding.EncodeToString([]byte(t.ServerURL)) + } + return s +} // NewEnrollmentToken erzeugt einen Token mit 256 Bit Zufall für den Server -// mit Fingerprint fp. -func NewEnrollmentToken(fp Fingerprint) (EnrollmentToken, error) { +// mit Fingerprint fp; serverURL ist optional ("" = nicht einbetten). +func NewEnrollmentToken(fp Fingerprint, serverURL string) (EnrollmentToken, error) { if _, err := ParseFingerprint(string(fp)); err != nil { return EnrollmentToken{}, err } + if serverURL != "" { + u, err := ValidateServerURL(serverURL) + if err != nil { + return EnrollmentToken{}, err + } + serverURL = u + } var b [32]byte if _, err := rand.Read(b[:]); err != nil { return EnrollmentToken{}, err } - return EnrollmentToken{Secret: TokenPrefix + base64.RawURLEncoding.EncodeToString(b[:]), Fingerprint: fp}, nil + return EnrollmentToken{ + Secret: TokenPrefix + base64.RawURLEncoding.EncodeToString(b[:]), + Fingerprint: fp, + ServerURL: serverURL, + }, nil } // ParseEnrollmentToken zerlegt einen Token-String. Leerraum am Rand (beim // Kopieren) wird ignoriert. func ParseEnrollmentToken(s string) (EnrollmentToken, error) { parts := strings.Split(strings.TrimSpace(s), ".") - if len(parts) != 2 { - return EnrollmentToken{}, fmt.Errorf("%w: erwartet %s.", ErrInvalidToken, TokenPrefix) + if len(parts) != 2 && len(parts) != 3 { + return EnrollmentToken{}, fmt.Errorf("%w: erwartet %s.[.]", ErrInvalidToken, TokenPrefix) } secret, rawFP := parts[0], parts[1] body, ok := strings.CutPrefix(secret, TokenPrefix) @@ -61,5 +92,51 @@ func ParseEnrollmentToken(s string) (EnrollmentToken, error) { if err != nil { return EnrollmentToken{}, fmt.Errorf("%w: %w", ErrInvalidToken, err) } - return EnrollmentToken{Secret: secret, Fingerprint: fp}, nil + tok := EnrollmentToken{Secret: secret, Fingerprint: fp} + if len(parts) == 3 { + raw, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil || len(raw) == 0 { + return EnrollmentToken{}, fmt.Errorf("%w: Server-URL beschädigt", ErrInvalidToken) + } + if tok.ServerURL, err = ValidateServerURL(string(raw)); err != nil { + return EnrollmentToken{}, fmt.Errorf("%w: %w", ErrInvalidToken, err) + } + } + return tok, nil +} + +// ValidateServerURL prüft eine Server-Adresse und normalisiert sie: nur +// https, nur Host und optional Port (kein Benutzer, Pfad, Query, Fragment), +// Host klein, ohne Schrägstrich am Ende. +func ValidateServerURL(raw string) (string, error) { + bad := func(why string) (string, error) { + return "", fmt.Errorf("%w: %q – %s (erwartet https://host[:port])", ErrInvalidServerURL, raw, why) + } + if len(raw) > maxServerURLLen { + return bad("zu lang") + } + u, err := url.Parse(strings.TrimSpace(raw)) + switch { + case err != nil: + return bad("nicht lesbar") + case u.Scheme != "https": + return bad("nur https") + case u.Hostname() == "": + return bad("Host fehlt") + case u.User != nil: + return bad("keine Zugangsdaten in der URL") + case u.Opaque != "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.ForceQuery || u.Fragment != "": + return bad("kein Pfad, keine Query") + } + host := strings.ToLower(u.Hostname()) + if port := u.Port(); port != "" { + if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 { + return bad("ungültiger Port") + } + return "https://" + net.JoinHostPort(host, port), nil + } + if strings.Contains(host, ":") { + host = "[" + host + "]" + } + return "https://" + host, nil } diff --git a/pkg/crypto/token_test.go b/pkg/crypto/token_test.go index 1b8f341..2cc9554 100644 --- a/pkg/crypto/token_test.go +++ b/pkg/crypto/token_test.go @@ -1,6 +1,7 @@ package crypto import ( + "encoding/base64" "errors" "strings" "testing" @@ -24,7 +25,7 @@ func TestFingerprint_ParseRoundTrip(t *testing.T) { func TestEnrollmentToken_RoundTrip(t *testing.T) { sc, _ := LoadOrCreateServerCert(t.TempDir()) - tok, err := NewEnrollmentToken(sc.Fingerprint) + tok, err := NewEnrollmentToken(sc.Fingerprint, "") if err != nil { t.Fatalf("NewEnrollmentToken: %v", err) } @@ -52,7 +53,7 @@ func TestEnrollmentToken_Unique(t *testing.T) { sc, _ := LoadOrCreateServerCert(t.TempDir()) seen := map[string]bool{} for range 100 { - tok, err := NewEnrollmentToken(sc.Fingerprint) + tok, err := NewEnrollmentToken(sc.Fingerprint, "") if err != nil { t.Fatal(err) } @@ -65,7 +66,7 @@ func TestEnrollmentToken_Unique(t *testing.T) { func TestEnrollmentToken_Errors(t *testing.T) { sc, _ := LoadOrCreateServerCert(t.TempDir()) - good, _ := NewEnrollmentToken(sc.Fingerprint) + good, _ := NewEnrollmentToken(sc.Fingerprint, "") secretPart := strings.TrimPrefix(good.Secret, TokenPrefix) fp := string(sc.Fingerprint) @@ -86,7 +87,80 @@ func TestEnrollmentToken_Errors(t *testing.T) { } }) } - if _, err := NewEnrollmentToken("kaputt"); !errors.Is(err, ErrInvalidFingerprint) { + if _, err := NewEnrollmentToken("kaputt", ""); !errors.Is(err, ErrInvalidFingerprint) { t.Errorf("NewEnrollmentToken mit kaputtem Fingerprint: %v", err) } } + +func TestEnrollmentToken_WithServerURL(t *testing.T) { + sc, _ := LoadOrCreateServerCert(t.TempDir()) + tok, err := NewEnrollmentToken(sc.Fingerprint, "https://Ingress.example.de:8443/") + if err != nil { + t.Fatal(err) + } + if tok.ServerURL != "https://ingress.example.de:8443" { + t.Errorf("URL nicht normalisiert: %q", tok.ServerURL) + } + s := tok.String() + if parts := strings.Split(s, "."); len(parts) != 3 { + t.Fatalf("erwartet drei Teile: %q", s) + } + if strings.ContainsAny(s, ":/?=+ ") { + t.Errorf("Token nicht shell-/kopiersicher: %q", s) + } + parsed, err := ParseEnrollmentToken(s) + if err != nil { + t.Fatalf("ParseEnrollmentToken: %v", err) + } + if parsed != tok { + t.Errorf("Round-Trip: %+v vs %+v", parsed, tok) + } + + // Ohne URL: zweiteilig wie bisher. + plain, _ := NewEnrollmentToken(sc.Fingerprint, "") + if parsed, err := ParseEnrollmentToken(plain.String()); err != nil || parsed.ServerURL != "" { + t.Errorf("Token ohne URL: %+v %v", parsed, err) + } + if _, err := NewEnrollmentToken(sc.Fingerprint, "http://ingress:8443"); !errors.Is(err, ErrInvalidServerURL) { + t.Errorf("http-URL: %v", err) + } +} + +func TestEnrollmentToken_InvalidServerURLPart(t *testing.T) { + sc, _ := LoadOrCreateServerCert(t.TempDir()) + base, _ := NewEnrollmentToken(sc.Fingerprint, "") + enc := func(u string) string { return base.String() + "." + base64.RawURLEncoding.EncodeToString([]byte(u)) } + for name, s := range map[string]string{ + "kein base64url": base.String() + ".!!!", + "leer": base.String() + ".", + "http": enc("http://ingress:8443"), + "mit Pfad": enc("https://ingress:8443/admin"), + "mit Zugangsdaten": enc("https://user:pw@ingress:8443"), + "zu lang": enc("https://" + strings.Repeat("a", 300) + ".de"), + } { + if _, err := ParseEnrollmentToken(s); !errors.Is(err, ErrInvalidToken) { + t.Errorf("%s: erwartet ErrInvalidToken, bekommen %v", name, err) + } + } +} + +func TestValidateServerURL(t *testing.T) { + valid := map[string]string{ + "https://ingress.x.de:8443": "https://ingress.x.de:8443", + "https://ingress.x.de:8443/": "https://ingress.x.de:8443", + "https://Ingress.X.de": "https://ingress.x.de", + "https://[fd00::1]:8443": "https://[fd00::1]:8443", + "https://100.64.0.1:8443": "https://100.64.0.1:8443", + } + for in, want := range valid { + if got, err := ValidateServerURL(in); err != nil || got != want { + t.Errorf("ValidateServerURL(%q) = %q, %v; erwartet %q", in, got, err, want) + } + } + for _, bad := range []string{"", "ingress:8443", "http://ingress:8443", "https://", "https://u:p@x:8443", + "https://x:8443/pfad", "https://x:8443?q=1", "https://x:8443#frag", "https://x:99999", "ftp://x"} { + if _, err := ValidateServerURL(bad); !errors.Is(err, ErrInvalidServerURL) { + t.Errorf("ValidateServerURL(%q): erwartet ErrInvalidServerURL, bekommen %v", bad, err) + } + } +} diff --git a/pkg/docker/docker.go b/pkg/docker/docker.go new file mode 100644 index 0000000..34d7bba --- /dev/null +++ b/pkg/docker/docker.go @@ -0,0 +1,140 @@ +// Package docker ist ein minimaler Client für die Docker Engine API über +// den Unix-Socket – genau das, was der Agent braucht: laufende Container +// mit Labels und Ports auflisten und Container-Events verfolgen. Bewusst +// ohne Docker-SDK (schwere Abhängigkeit für zwei Endpunkte). +package docker + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" +) + +// Container ist ein Eintrag aus GET /containers/json. +type Container struct { + ID string `json:"Id"` + Names []string `json:"Names"` + State string `json:"State"` + Labels map[string]string `json:"Labels"` + Ports []Port `json:"Ports"` +} + +// Name liefert den Containernamen ohne führenden Schrägstrich. +func (c Container) Name() string { + if len(c.Names) == 0 { + return c.ID + } + return strings.TrimPrefix(c.Names[0], "/") +} + +// Port ist eine Portzuordnung; PublicPort 0 = nicht veröffentlicht. +type Port struct { + IP string `json:"IP,omitempty"` + PrivatePort uint16 `json:"PrivatePort"` + PublicPort uint16 `json:"PublicPort,omitempty"` + Type string `json:"Type"` +} + +// Event ist ein Eintrag aus GET /events. +type Event struct { + Type string `json:"Type"` + Action string `json:"Action"` + Actor Actor `json:"Actor"` +} + +// Actor beschreibt das betroffene Objekt eines Events. +type Actor struct { + ID string `json:"ID"` + Attributes map[string]string `json:"Attributes,omitempty"` +} + +// Client spricht die Engine API über Socket (z. B. /var/run/docker.sock). +type Client struct { + Socket string +} + +func (c *Client) http() *http.Client { + return &http.Client{Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", c.Socket) + }, + }} +} + +func (c *Client) get(ctx context.Context, path string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://docker"+path, nil) + if err != nil { + return nil, err + } + resp, err := c.http().Do(req) + if err != nil { + return nil, fmt.Errorf("docker: %s: %w", path, err) + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + resp.Body.Close() + return nil, fmt.Errorf("docker: %s: Status %d: %s", path, resp.StatusCode, strings.TrimSpace(string(body))) + } + return resp, nil +} + +// List liefert die laufenden Container. +func (c *Client) List(ctx context.Context) ([]Container, error) { + resp, err := c.get(ctx, "/containers/json") + if err != nil { + return nil, err + } + defer resp.Body.Close() + var out []Container + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("docker: Containerliste: %w", err) + } + return out, nil +} + +// Events verfolgt Container-Events, bis ctx endet oder der Stream abbricht. +// Der Event-Kanal wird danach geschlossen; errc liefert genau einen Wert +// (ctx.Err() bei Abbruch, sonst den Grund). +func (c *Client) Events(ctx context.Context) (<-chan Event, <-chan error) { + events := make(chan Event) + errc := make(chan error, 1) + go func() { + defer close(events) + filters := url.QueryEscape(`{"type":["container"]}`) + resp, err := c.get(ctx, "/events?filters="+filters) + if err != nil { + errc <- err + return + } + defer resp.Body.Close() + dec := json.NewDecoder(resp.Body) + for { + var ev Event + if err := dec.Decode(&ev); err != nil { + switch { + case ctx.Err() != nil: + errc <- ctx.Err() + case errors.Is(err, io.EOF): + errc <- errors.New("docker: Event-Stream beendet") + default: + errc <- fmt.Errorf("docker: Event-Stream: %w", err) + } + return + } + select { + case events <- ev: + case <-ctx.Done(): + errc <- ctx.Err() + return + } + } + }() + return events, errc +} diff --git a/pkg/docker/docker_test.go b/pkg/docker/docker_test.go new file mode 100644 index 0000000..76cffb8 --- /dev/null +++ b/pkg/docker/docker_test.go @@ -0,0 +1,154 @@ +package docker + +import ( + "context" + "encoding/json" + "net" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +// fakeEngine bedient die Docker-API über einen Unix-Socket. +type fakeEngine struct { + path string + events chan Event // was der Server über /events streamt + lastQ chan string +} + +func newFakeEngine(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) *fakeEngine { + t.Helper() + // Unix-Socket-Pfade sind kurz begrenzt; TempDir kann zu lang sein. + dir, err := os.MkdirTemp("", "dk") + if err != nil { + t.Fatal(err) + } + f := &fakeEngine{path: filepath.Join(dir, "d.sock"), events: make(chan Event, 8), lastQ: make(chan string, 8)} + ln, err := net.Listen("unix", f.path) + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + mux.HandleFunc("/containers/json", handler) + mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) { + f.lastQ <- r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + enc := json.NewEncoder(w) + for { + select { + case <-r.Context().Done(): + return + case ev, ok := <-f.events: + if !ok { + return + } + enc.Encode(ev) + w.(http.Flusher).Flush() + } + } + }) + srv := &http.Server{Handler: mux} + go srv.Serve(ln) + t.Cleanup(func() { srv.Close(); os.RemoveAll(dir) }) + return f +} + +const containersJSON = `[ + {"Id":"abc","Names":["/wordpress"],"State":"running", + "Labels":{"traefik.enable":"true","traefik.http.routers.wp.rule":"Host(` + "`kunde.de`" + `)"}, + "Ports":[{"IP":"0.0.0.0","PrivatePort":80,"PublicPort":8081,"Type":"tcp"},{"PrivatePort":443,"Type":"tcp"}]}, + {"Id":"def","Names":["/db"],"State":"running","Labels":{},"Ports":[]} +]` + +func TestList(t *testing.T) { + f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(containersJSON)) + }) + got, err := (&Client{Socket: f.path}).List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + want := []Container{ + { + ID: "abc", Names: []string{"/wordpress"}, State: "running", + Labels: map[string]string{"traefik.enable": "true", "traefik.http.routers.wp.rule": "Host(`kunde.de`)"}, + Ports: []Port{{IP: "0.0.0.0", PrivatePort: 80, PublicPort: 8081, Type: "tcp"}, {PrivatePort: 443, Type: "tcp"}}, + }, + {ID: "def", Names: []string{"/db"}, State: "running", Labels: map[string]string{}, Ports: []Port{}}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("List =\n%+v\nerwartet\n%+v", got, want) + } + if got[0].Name() != "wordpress" { + t.Errorf("Name() = %q", got[0].Name()) + } +} + +func TestList_Errors(t *testing.T) { + f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message":"kaputt"}`, http.StatusInternalServerError) + }) + if _, err := (&Client{Socket: f.path}).List(context.Background()); err == nil || !strings.Contains(err.Error(), "500") { + t.Errorf("Status 500: %v", err) + } + if _, err := (&Client{Socket: "/gibt/es/nicht.sock"}).List(context.Background()); err == nil { + t.Error("fehlender Socket: erwartet Fehler") + } +} + +func TestEvents(t *testing.T) { + f := newFakeEngine(t, func(http.ResponseWriter, *http.Request) {}) + ctx, cancel := context.WithCancel(context.Background()) + events, errc := (&Client{Socket: f.path}).Events(ctx) + + q := <-f.lastQ + if !strings.Contains(q, "filters=") || !strings.Contains(q, "container") { + t.Errorf("Events ohne Container-Filter: %q", q) + } + f.events <- Event{Type: "container", Action: "start", Actor: Actor{ID: "abc", Attributes: map[string]string{"name": "wordpress"}}} + f.events <- Event{Type: "container", Action: "die", Actor: Actor{ID: "abc"}} + + for _, want := range []string{"start", "die"} { + select { + case ev := <-events: + if ev.Action != want || ev.Actor.ID != "abc" { + t.Errorf("Event = %+v, erwartet %s", ev, want) + } + case <-time.After(2 * time.Second): + t.Fatalf("Event %s nicht empfangen", want) + } + } + + cancel() + select { + case _, ok := <-events: + if ok { + t.Error("Kanal nach Abbruch nicht geschlossen") + } + case <-time.After(2 * time.Second): + t.Fatal("Events endet nicht nach Abbruch") + } + if err := <-errc; err != nil && err != context.Canceled { + t.Errorf("Fehler nach Abbruch: %v", err) + } +} + +// Endet der Event-Stream serverseitig, meldet Events das über errc. +func TestEvents_StreamEnds(t *testing.T) { + f := newFakeEngine(t, func(http.ResponseWriter, *http.Request) {}) + events, errc := (&Client{Socket: f.path}).Events(context.Background()) + <-f.lastQ + close(f.events) + for range events { + } + if err := <-errc; err == nil { + t.Error("erwartet Fehler bei beendetem Stream") + } +} diff --git a/pkg/lander/lander.go b/pkg/lander/lander.go new file mode 100644 index 0000000..b2964be --- /dev/null +++ b/pkg/lander/lander.go @@ -0,0 +1,332 @@ +// Package lander ist der Sultan-Interceptor (Spec 5.2): Traefik leitet +// Router, deren Mandant bzw. Dienst nicht "normal" ist, hierher um und +// teilt die Stufe per Header mit (X-Sultan-Stage, -Tenant, -Tier). +// +// Je Stufe gibt es eine kleine HTML-Seite (Deutsch und Englisch), eingebaut +// oder als eigene html/template-Vorlage aus Dir. CSS und Grafik einer +// eigenen Vorlage werden eingebettet: Bei watermark/firemark ist das Cookie +// sultan_ack schon gesetzt, ein separater Request für Assets ginge über den +// Durchlass-Router ans Backend des Kunden. +package lander + +import ( + "bytes" + "embed" + "encoding/base64" + "fmt" + "html/template" + "log/slog" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +//go:embed templates +var builtinFS embed.FS + +// Stages sind die Stufen mit eigener Seite. +var Stages = []string{"watermark", "firemark", "paywall", "landing", "maintenance"} + +// MaxAssetSize begrenzt CSS und Grafik einer eigenen Vorlage. +const MaxAssetSize = 512 << 10 + +// AckCookie muss zu policy.AckCookie passen (Durchlass-Router). +const AckCookie = "sultan_ack" + +var builtin = template.Must(template.ParseFS(builtinFS, "templates/*.html")) + +// Lander liefert die Seiten aus. Der Nullwert funktioniert (eingebaute +// Seiten, Default-Zeiten); Felder nach der ersten Verwendung nicht ändern. +type Lander struct { + Dir string // eigene Vorlagen (z. B. ./suldata/landing); leer = nur eingebaut + HosterName string // für Einladung (paywall/landing) + HosterURL string // Angebotsseite des Hosters + ProURL string // Pro-Hinweis auf Community-Wartungsseiten + WatermarkDelay time.Duration // Default 5s + FiremarkDelay time.Duration // Default 10s + AckTTL time.Duration // Default 60min: Gültigkeit des Cookies sultan_ack + Location *time.Location // Zeitzone für das angezeigte Wartungsende (Default: lokal) + Logger *slog.Logger + + now func() time.Time + + mu sync.Mutex + cache map[string]*customPage +} + +// PageData sind die Werte, die Vorlagen nutzen können (siehe README.txt). +type PageData struct { + Stage string + Tenant string + Tier string + Community bool + Target string + RefreshSeconds int + HosterName string + HosterURL string + ProURL string + PublicReason string // Wartung: ersetzt den Standardtext + UntilDE string // Wartung: voraussichtliches Ende, z. B. 02.10.2026 15:30 + UntilEN string // dasselbe, z. B. 2026-10-02 15:30 + CSS template.CSS + Image template.URL +} + +type stageInfo struct { + status int + delay func(*Lander) time.Duration // nil = keine Weiterleitung, kein Cookie + headers map[string]string +} + +var stages = map[string]stageInfo{ + "watermark": {http.StatusOK, func(l *Lander) time.Duration { return or(l.WatermarkDelay, 5*time.Second) }, nil}, + "firemark": {http.StatusOK, func(l *Lander) time.Duration { return or(l.FiremarkDelay, 10*time.Second) }, nil}, + "paywall": {http.StatusPaymentRequired, nil, nil}, + "landing": {http.StatusPaymentRequired, nil, nil}, + "maintenance": {http.StatusServiceUnavailable, nil, map[string]string{"Retry-After": "60"}}, +} + +func or(d, def time.Duration) time.Duration { + if d > 0 { + return d + } + return def +} + +func (l *Lander) log() *slog.Logger { + if l.Logger != nil { + return l.Logger + } + return slog.Default() +} + +func (l *Lander) ServeHTTP(w http.ResponseWriter, r *http.Request) { + stage := r.Header.Get("X-Sultan-Stage") + info, ok := stages[stage] + if !ok { + http.Error(w, "unbekannte Stufe", http.StatusBadRequest) + return + } + tier := r.Header.Get("X-Sultan-Tier") + data := PageData{ + Stage: stage, + Tenant: r.Header.Get("X-Sultan-Tenant"), + Tier: tier, + Community: tier == "community", + Target: localTarget(r.URL), + HosterName: l.HosterName, + HosterURL: l.HosterURL, + ProURL: l.ProURL, + } + if info.delay != nil { + data.RefreshSeconds = int(info.delay(l) / time.Second) + } + retryAfter := "" + if stage == "maintenance" { + retryAfter = l.maintenanceDetails(r, &data) + } + + body := l.render(stage, data) + + h := w.Header() + h.Set("Content-Type", "text/html; charset=utf-8") + h.Set("Cache-Control", "no-store") + for k, v := range info.headers { + h.Set(k, v) + } + if retryAfter != "" { + h.Set("Retry-After", retryAfter) + } + if info.delay != nil { + http.SetCookie(w, &http.Cookie{ + Name: AckCookie, + Value: stage, + Path: "/", + MaxAge: int(or(l.AckTTL, time.Hour) / time.Second), + HttpOnly: true, + Secure: r.Header.Get("X-Forwarded-Proto") == "https", + SameSite: http.SameSiteLaxMode, + }) + } + w.WriteHeader(info.status) + w.Write(body) +} + +const maxPublicReason = 300 + +// maintenanceDetails übernimmt öffentlichen Grund und Ende aus den Headern +// der Policy und liefert Retry-After (Sekunden bis zum Ende, 60 s bis 1 Tag). +func (l *Lander) maintenanceDetails(r *http.Request, data *PageData) string { + if raw := r.Header.Get("X-Sultan-Public-Reason"); raw != "" { + reason, err := url.QueryUnescape(raw) + if err != nil { + reason = raw + } + reason = strings.TrimSpace(reason) + if runes := []rune(reason); len(runes) > maxPublicReason { + reason = string(runes[:maxPublicReason]) + "…" + } + data.PublicReason = reason + } + now := time.Now() + if l.now != nil { + now = l.now() + } + until, err := time.Parse(time.RFC3339, r.Header.Get("X-Sultan-Until")) + if err != nil || !until.After(now) { + return "" + } + loc := l.Location + if loc == nil { + loc = time.Local + } + data.UntilDE = until.In(loc).Format("02.01.2006 15:04") + data.UntilEN = until.In(loc).Format("2006-01-02 15:04") + secs := int(until.Sub(now) / time.Second) + return strconv.Itoa(min(max(secs, 60), 86400)) +} + +// localTarget liefert den Pfad (+Query) des ursprünglichen Requests als +// Weiterleitungsziel – nur lokal: kein Host, kein Schema, kein "//" oder +// "\" am Anfang (sonst offene Weiterleitung auf fremde Seiten). +func localTarget(u *url.URL) string { + if u == nil { + return "/" + } + p := u.EscapedPath() + if !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") || strings.Contains(u.Path, `\`) { + return "/" + } + if u.RawQuery != "" { + return p + "?" + u.RawQuery + } + return p +} + +// render nutzt die eigene Vorlage der Stufe, sonst (oder bei jedem Fehler) +// die eingebaute. +func (l *Lander) render(stage string, data PageData) []byte { + if page := l.customPage(stage); page != nil { + data.CSS, data.Image = page.css, page.image + var buf bytes.Buffer + err := page.tmpl.Execute(&buf, data) + if err == nil { + return buf.Bytes() + } + l.log().Error("Lander-Vorlage fehlerhaft – eingebaute Seite verwendet", "stage", stage, "err", err) + data.CSS, data.Image = "", "" + } + var buf bytes.Buffer + if err := builtin.ExecuteTemplate(&buf, stage+".html", data); err != nil { + l.log().Error("eingebaute Lander-Seite", "stage", stage, "err", err) + return []byte("" + stage + "") + } + return buf.Bytes() +} + +type customPage struct { + signature string + tmpl *template.Template // nil: keine (gültige) eigene Vorlage + css template.CSS + image template.URL +} + +// customPage lädt die eigene Vorlage einer Stufe und lädt neu, sobald sich +// eine der Dateien ändert (Name, Größe, Zeitstempel). +func (l *Lander) customPage(stage string) *customPage { + if l.Dir == "" { + return nil + } + files := []string{stage + ".html", stage + ".css", stage + ".svg", stage + ".webp"} + var sig strings.Builder + for _, f := range files { + if info, err := os.Stat(filepath.Join(l.Dir, f)); err == nil { + fmt.Fprintf(&sig, "%s:%d:%d;", f, info.Size(), info.ModTime().UnixNano()) + } + } + l.mu.Lock() + defer l.mu.Unlock() + if c := l.cache[stage]; c != nil && c.signature == sig.String() { + if c.tmpl == nil { + return nil + } + return c + } + page := &customPage{signature: sig.String()} + if l.cache == nil { + l.cache = map[string]*customPage{} + } + l.cache[stage] = page + + htmlPath := filepath.Join(l.Dir, stage+".html") + src, err := os.ReadFile(htmlPath) + if err != nil { + return nil // keine eigene Vorlage + } + tmpl, err := template.New(stage).Parse(string(src)) + if err != nil { + l.log().Error("Lander-Vorlage fehlerhaft – eingebaute Seite verwendet", "file", htmlPath, "err", err) + return nil + } + page.tmpl = tmpl + if css, ok := l.readAsset(filepath.Join(l.Dir, stage+".css")); ok { + page.css = template.CSS(css) + } + for ext, mime := range map[string]string{".svg": "image/svg+xml", ".webp": "image/webp"} { + if img, ok := l.readAsset(filepath.Join(l.Dir, stage+ext)); ok { + page.image = template.URL("data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(img)) + } + } + return page +} + +func (l *Lander) readAsset(path string) ([]byte, bool) { + info, err := os.Stat(path) + if err != nil { + return nil, false + } + if info.Size() > MaxAssetSize { + l.log().Warn("Lander-Asset zu groß – ignoriert", "file", path, "max_bytes", MaxAssetSize) + return nil, false + } + data, err := os.ReadFile(path) + return data, err == nil +} + +// Dump schreibt die eingebauten Seiten und eine README.txt als Vorlagen nach +// dir. Bestehende Dateien werden nur mit force überschrieben (sonst +// os.ErrExist, ohne etwas zu schreiben). +func Dump(dir string, force bool) ([]string, error) { + names := []string{"README.txt"} + for _, s := range Stages { + names = append(names, s+".html") + } + if !force { + for _, n := range names { + if _, err := os.Stat(filepath.Join(dir, n)); err == nil { + return nil, fmt.Errorf("%s existiert bereits (überschreiben mit --force): %w", filepath.Join(dir, n), os.ErrExist) + } + } + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + var written []string + for _, n := range names { + data, err := builtinFS.ReadFile("templates/" + n) + if err != nil { + return written, err + } + path := filepath.Join(dir, n) + if err := os.WriteFile(path, data, 0o644); err != nil { + return written, err + } + written = append(written, path) + } + return written, nil +} diff --git a/pkg/lander/lander_test.go b/pkg/lander/lander_test.go new file mode 100644 index 0000000..26207d8 --- /dev/null +++ b/pkg/lander/lander_test.go @@ -0,0 +1,374 @@ +package lander + +import ( + "bytes" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func request(t *testing.T, l *Lander, stage, tier, target string, header ...string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(http.MethodGet, target, nil) + r.Host = "kunde.de" + if stage != "" { + r.Header.Set("X-Sultan-Stage", stage) + } + r.Header.Set("X-Sultan-Tenant", "kunde") + if tier != "" { + r.Header.Set("X-Sultan-Tier", tier) + } + for i := 0; i+1 < len(header); i += 2 { + r.Header.Set(header[i], header[i+1]) + } + rec := httptest.NewRecorder() + l.ServeHTTP(rec, r) + return rec +} + +func newLander(t *testing.T) *Lander { + return &Lander{Logger: slog.New(slog.DiscardHandler), HosterName: "Beispiel-Hosting", HosterURL: "https://hoster.example", ProURL: "https://hoster.example/pro"} +} + +func TestStages(t *testing.T) { + tests := []struct { + stage string + status int + refresh string // erwarteter meta-refresh-Inhalt, leer = keiner + cookie bool + }{ + {"watermark", 200, "5;url=/shop?x=1", true}, + {"firemark", 200, "10;url=/shop?x=1", true}, + {"paywall", 402, "", false}, + {"landing", 402, "", false}, + {"maintenance", 503, "", false}, + } + for _, tt := range tests { + t.Run(tt.stage, func(t *testing.T) { + rec := request(t, newLander(t), tt.stage, "pro", "/shop?x=1") + if rec.Code != tt.status { + t.Errorf("Status %d, erwartet %d", rec.Code, tt.status) + } + if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" { + t.Errorf("Content-Type %q", ct) + } + if rec.Header().Get("Cache-Control") != "no-store" { + t.Error("Seite darf nicht gecacht werden") + } + body := rec.Body.String() + if tt.refresh != "" && !strings.Contains(body, `http-equiv="refresh" content="`+tt.refresh+`"`) { + t.Errorf("meta refresh %q fehlt:\n%s", tt.refresh, body) + } + if tt.refresh == "" && strings.Contains(body, "refresh") { + t.Error("Stufe ohne Durchlass darf nicht weiterleiten") + } + cookie := rec.Header().Get("Set-Cookie") + if tt.cookie { + for _, want := range []string{"sultan_ack=" + tt.stage, "Max-Age=3600", "HttpOnly", "SameSite=Lax", "Path=/"} { + if !strings.Contains(cookie, want) { + t.Errorf("Cookie %q ohne %q", cookie, want) + } + } + } else if cookie != "" { + t.Errorf("unerwartetes Cookie %q", cookie) + } + if !strings.Contains(body, `lang="de"`) || !strings.Contains(body, `lang="en"`) { + t.Error("Text muss auf Deutsch und Englisch vorliegen") + } + }) + } +} + +func TestMaintenanceRetryAfterAndProHint(t *testing.T) { + l := newLander(t) + rec := request(t, l, "maintenance", "community", "/") + if rec.Header().Get("Retry-After") != "60" { + t.Errorf("Retry-After %q", rec.Header().Get("Retry-After")) + } + if !strings.Contains(rec.Body.String(), "https://hoster.example/pro") { + t.Error("Community-Wartungsseite ohne Pro-Hinweis") + } + if strings.Contains(request(t, l, "maintenance", "pro", "/").Body.String(), "https://hoster.example/pro") { + t.Error("Pro-Kunde bekommt Pro-Werbung") + } +} + +func TestPaywallInvitesToHoster(t *testing.T) { + body := request(t, newLander(t), "paywall", "pro", "/").Body.String() + if !strings.Contains(body, "Beispiel-Hosting") || !strings.Contains(body, "https://hoster.example") { + t.Errorf("Einladung des Hosters fehlt:\n%s", body) + } +} + +func TestSecureCookieBehindHTTPS(t *testing.T) { + rec := request(t, newLander(t), "watermark", "pro", "/", "X-Forwarded-Proto", "https") + if !strings.Contains(rec.Header().Get("Set-Cookie"), "Secure") { + t.Errorf("Cookie ohne Secure: %q", rec.Header().Get("Set-Cookie")) + } +} + +func TestConfigurableDelays(t *testing.T) { + l := newLander(t) + l.WatermarkDelay, l.FiremarkDelay, l.AckTTL = 2*time.Second, 7*time.Second, 15*time.Minute + if body := request(t, l, "watermark", "pro", "/").Body.String(); !strings.Contains(body, `content="2;url=/"`) { + t.Errorf("Wartezeit Stufe 1:\n%s", body) + } + rec := request(t, l, "firemark", "pro", "/") + if !strings.Contains(rec.Body.String(), `content="7;url=/"`) || !strings.Contains(rec.Header().Get("Set-Cookie"), "Max-Age=900") { + t.Errorf("Stufe 2: %q / %s", rec.Header().Get("Set-Cookie"), rec.Body.String()) + } +} + +func TestUnknownOrMissingStage(t *testing.T) { + for _, stage := range []string{"", "normal", "kaputt"} { + if rec := request(t, newLander(t), stage, "pro", "/"); rec.Code != http.StatusBadRequest { + t.Errorf("Stufe %q: Status %d", stage, rec.Code) + } + } +} + +// Werte aus Headern und URL werden nie roh ausgegeben. +func TestEscaping(t *testing.T) { + rec := request(t, newLander(t), "watermark", "pro", `/a">`, + "X-Sultan-Tenant", "") + body := rec.Body.String() + if strings.Contains(body, ""+strings.Repeat("a", 400))) + body := rec.Body.String() + if strings.Contains(body, "