software steht so weit.
jetzt build + docker build und testen
This commit is contained in:
1 parent
42f0c0accb
commit
ca6cdcca63
90 files changed
+10278
-460
No files matched your search
@@ -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 `<data-dir>/server.key` (0600) und `server.crt`. Gültigkeit 20 Jahre, weil Agents den Fingerprint pinnen. Fehlt nur eine der beiden Dateien, startet der Server nicht, statt still ein neues Zertifikat zu erzeugen (das würde alle Pins brechen).
|
||||
* **Fingerprint:** SHA-256 über das DER-Zertifikat, Base64url ohne Padding (43 Zeichen).
|
||||
* **Vertrauensmodell: Pinning mit geprüftem Erstkontakt** (statt reinem TOFU, siehe 3.4):
|
||||
* Der Agent speichert den Fingerprint je Server (`host:port`) in `known_servers` (Format wie SSH `known_hosts`: `<server> <fingerprint>`, 0600; Einträge werden nur angehängt, nie still geändert).
|
||||
* **Erstkontakt:** Der Fingerprint muss zu dem im Enrollment-Token eingebetteten Wert passen (3.2), erst dann wird gepinnt.
|
||||
* **Folgekontakte:** Das Zertifikat muss zum Pin passen. Der Pin gewinnt immer, auch gegen einen später gelieferten Token-Fingerprint.
|
||||
* **Ohne Pin und ohne Token-Fingerprint** wird nicht verbunden (kein blindes TOFU).
|
||||
* Die Prüfung erfolgt im TLS-Handshake, also bevor Token, Signatur oder Body gesendet werden.
|
||||
* **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>.<fingerprint>`
|
||||
* `secret`: 256 Bit Zufall (Base64url). Der Server speichert nur den SHA-256 von `sltn_enroll_<secret>`, einmalig verwendbar, mit Ablaufzeit.
|
||||
* `fingerprint`: Fingerprint des Server-Zertifikats (3.1). Nicht geheim; er sichert den Erstkontakt des Agenten ab.
|
||||
* Der Admin überträgt den Token auf einem separaten Weg (z. B. Copy-Paste aus dem Server-Terminal) an den Agenten.
|
||||
* **Enrollment-Token:** `sltn_enroll_<secret>.<fingerprint>[.<server-url>]`. 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_<secret>`, 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 `<data-dir>/server.key` erzeugt ein neues Zertifikat und sperrt alle Agents aus. Deshalb muss `server.key` gesichert werden. *(Geplant:)* geordneter Zertifikatswechsel, bei dem der Server das neue Zertifikat vorab über den gepinnten Kanal ankündigt.
|
||||
* **Zweite Schicht (geplant):** Server→Agent-Events werden mit einem Server-Signaturschlüssel signiert, den der Agent beim Enrollment lernt. Die Echtheit von `node_command` hängt dann nicht mehr allein am TLS-Pin.
|
||||
* **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 <title> (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.
|
||||
@@ -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/
|
||||
@@ -30,3 +30,6 @@ go.work.sum
|
||||
/suldata/
|
||||
suldata/
|
||||
/sultan
|
||||
|
||||
# Build-Ausgaben (deploy/build.sh)
|
||||
/dist/
|
||||
@@ -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.
|
||||
|
||||
+33
@@ -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"]
|
||||
@@ -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).
|
||||
Executable
+26
@@ -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"
|
||||
@@ -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=
|
||||
@@ -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!
|
||||
@@ -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
|
||||
@@ -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!
|
||||
Executable
+42
@@ -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}"
|
||||
Executable
+43
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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</title></head><body><h2>Impressum</h2></body></html>"))
|
||||
}))
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <title> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 </TITLE></head>
|
||||
<body><h1>Hallo</h1><h2 class="fuss"><a href="/impressum">Impressum</a></h2></body></html>`
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <Tag> 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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, ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// Package checkmk erzeugt die Ausgabe für Checkmk im Local-Check-Format
|
||||
// (Spec 6.1): `<status> "<service>" <metriken> <text>` 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+38
-11
@@ -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_<FLAG> 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 }
|
||||
+99
-17
@@ -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_<FLAG> 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)
|
||||
}
|
||||
}
|
||||
@@ -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 <data-dir>/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
|
||||
}
|
||||
@@ -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_<FLAG>. 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
|
||||
}
|
||||
@@ -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=<token> sultan agent\n", tok.ServerURL)
|
||||
} else {
|
||||
fmt.Fprint(env.stderr, "Auf dem Agent-Host: sultan agent --server=https://<ingress>:8443 --token=<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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -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_<secret>.<fingerprint>. 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_<secret>.<fingerprint>[.<server-url>].
|
||||
// Der Server speichert nur das Secret (gehasht), der Agent nutzt
|
||||
// Fingerprint und Adresse für den Erstkontakt.
|
||||
package crypto
|
||||
|
||||
import (
|
||||
|
||||
+20
-117
@@ -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 "<server> <fingerprint>", # für Kommentare. Einträge
|
||||
// werden nur angehängt, nie stillschweigend geändert.
|
||||
type KnownServers struct {
|
||||
Path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Lookup liefert den Pin für server. Eine fehlende Datei bedeutet: kein Pin.
|
||||
func (k *KnownServers) Lookup(server string) (Fingerprint, bool, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
return k.lookup(server)
|
||||
}
|
||||
|
||||
func (k *KnownServers) lookup(server string) (Fingerprint, bool, error) {
|
||||
data, err := os.ReadFile(k.Path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||
for line := 1; sc.Scan(); line++ {
|
||||
text := strings.TrimSpace(sc.Text())
|
||||
if text == "" || strings.HasPrefix(text, "#") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(text)
|
||||
if len(fields) != 2 {
|
||||
return "", false, fmt.Errorf("%w: %s Zeile %d", ErrCorruptKnownServers, k.Path, line)
|
||||
}
|
||||
fp, err := ParseFingerprint(fields[1])
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("%w: %s Zeile %d: %w", ErrCorruptKnownServers, k.Path, line, err)
|
||||
}
|
||||
if fields[0] == server {
|
||||
return fp, true, nil
|
||||
}
|
||||
}
|
||||
return "", false, sc.Err()
|
||||
}
|
||||
|
||||
// Add pinnt fp für server. Ist bereits ein anderer Fingerprint gepinnt,
|
||||
// liefert Add ErrFingerprintMismatch und ändert nichts.
|
||||
func (k *KnownServers) Add(server string, fp Fingerprint) error {
|
||||
if len(strings.Fields(server)) != 1 || strings.TrimSpace(server) != server {
|
||||
return fmt.Errorf("crypto: ungültiger Servername %q", server)
|
||||
}
|
||||
if _, err := ParseFingerprint(string(fp)); err != nil {
|
||||
return err
|
||||
}
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
pinned, ok, err := k.lookup(server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
if pinned != fp {
|
||||
return fmt.Errorf("%w: %s ist auf %s gepinnt, nicht %s", ErrFingerprintMismatch, server, pinned, fp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f, err := os.OpenFile(k.Path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(f, "%s %s\n", server, fp)
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ClientTLSConfig liefert die TLS-Konfiguration des Agenten für server
|
||||
// (host:port aus der --server-URL). Statt einer CA prüft sie den
|
||||
// Fingerprint:
|
||||
// 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 ok {
|
||||
if actual != pinned {
|
||||
return fmt.Errorf("%w: %s präsentiert %s, gepinnt ist %s", ErrFingerprintMismatch, server, actual, pinned)
|
||||
if got := FingerprintOf(cs.PeerCertificates[0]); got != want {
|
||||
return fmt.Errorf("%w: Server präsentiert %s, erwartet %s", ErrFingerprintMismatch, got, want)
|
||||
}
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
+32
-133
@@ -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)
|
||||
}
|
||||
}
|
||||
+88
-11
@@ -5,49 +5,80 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TokenPrefix kennzeichnet Enrollment-Tokens.
|
||||
const TokenPrefix = "sltn_enroll_"
|
||||
|
||||
var (
|
||||
// ErrInvalidToken: Der Token-String hat nicht das Format
|
||||
// sltn_enroll_<secret>.<fingerprint>.
|
||||
var ErrInvalidToken = errors.New("crypto: ungültiger Enrollment-Token")
|
||||
// sltn_enroll_<secret>.<fingerprint>[.<server-url>].
|
||||
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<secret>.<fingerprint>", ErrInvalidToken, TokenPrefix)
|
||||
if len(parts) != 2 && len(parts) != 3 {
|
||||
return EnrollmentToken{}, fmt.Errorf("%w: erwartet %s<secret>.<fingerprint>[.<server-url>]", 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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("<!doctype html><title>" + stage + "</title>")
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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"><script>alert(1)</script>`,
|
||||
"X-Sultan-Tenant", "<script>böse</script>")
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, "<script>") {
|
||||
t.Errorf("ungefilterte Ausgabe:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, dir, name, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomTemplateWithInlinedAssets(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, dir, "maintenance.html",
|
||||
`<html><head><style>{{.CSS}}</style></head><body><img src="{{.Image}}"><p>Wartung bei {{.Tenant}} ({{.Tier}})</p></body></html>`)
|
||||
writeFile(t, dir, "maintenance.css", "body { color: red; }")
|
||||
writeFile(t, dir, "maintenance.svg", `<svg xmlns="http://www.w3.org/2000/svg"/>`)
|
||||
l := newLander(t)
|
||||
l.Dir = dir
|
||||
|
||||
rec := request(t, l, "maintenance", "pro", "/")
|
||||
body := rec.Body.String()
|
||||
if rec.Code != 503 || !strings.Contains(body, "Wartung bei kunde (pro)") {
|
||||
t.Errorf("eigene Vorlage nicht verwendet: %d\n%s", rec.Code, body)
|
||||
}
|
||||
if !strings.Contains(body, "body { color: red; }") {
|
||||
t.Error("CSS nicht eingebettet")
|
||||
}
|
||||
// html/template schreibt "+" im Attribut als + – der Browser liest es als "+".
|
||||
if !strings.Contains(body, `src="data:image/svg+xml;base64,`) {
|
||||
t.Errorf("Grafik nicht als data-URI eingebettet:\n%s", body)
|
||||
}
|
||||
// Andere Stufen ohne eigene Vorlage: eingebaut.
|
||||
if body := request(t, l, "paywall", "pro", "/").Body.String(); !strings.Contains(body, "Beispiel-Hosting") {
|
||||
t.Error("eingebaute Seite für paywall fehlt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomTemplateWebP(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, dir, "landing.html", `<img src="{{.Image}}">`)
|
||||
writeFile(t, dir, "landing.webp", "RIFF0000WEBP")
|
||||
l := newLander(t)
|
||||
l.Dir = dir
|
||||
if body := request(t, l, "landing", "pro", "/").Body.String(); !strings.Contains(body, `src="data:image/webp;base64,`) {
|
||||
t.Errorf("WebP nicht eingebettet: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Eine kaputte Vorlage darf die Hinweisseite nie ausfallen lassen.
|
||||
func TestBrokenTemplateFallsBackToBuiltin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, dir, "maintenance.html", `{{.GibtsNicht`)
|
||||
var logs bytes.Buffer
|
||||
l := newLander(t)
|
||||
l.Dir, l.Logger = dir, slog.New(slog.NewTextHandler(&logs, nil))
|
||||
|
||||
rec := request(t, l, "maintenance", "pro", "/")
|
||||
if rec.Code != 503 || !strings.Contains(rec.Body.String(), `lang="de"`) {
|
||||
t.Errorf("keine eingebaute Seite: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(logs.String(), "maintenance.html") {
|
||||
t.Errorf("Fehler nicht protokolliert: %s", logs.String())
|
||||
}
|
||||
|
||||
// Ausführungsfehler (unbekanntes Feld) ebenfalls.
|
||||
writeFile(t, dir, "maintenance.html", `{{.GibtsNicht}}`)
|
||||
rec = request(t, l, "maintenance", "pro", "/")
|
||||
if rec.Code != 503 || !strings.Contains(rec.Body.String(), `lang="de"`) {
|
||||
t.Errorf("Ausführungsfehler: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizedAssetIgnored(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, dir, "maintenance.html", `<img src="{{.Image}}">ok`)
|
||||
writeFile(t, dir, "maintenance.svg", strings.Repeat("x", MaxAssetSize+1))
|
||||
l := newLander(t)
|
||||
l.Dir = dir
|
||||
body := request(t, l, "maintenance", "pro", "/").Body.String()
|
||||
if strings.Contains(body, "base64") || !strings.Contains(body, "ok") {
|
||||
t.Errorf("zu große Grafik eingebettet: %.200s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplatesReloadedOnChange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, dir, "maintenance.html", `Version 1`)
|
||||
l := newLander(t)
|
||||
l.Dir = dir
|
||||
if body := request(t, l, "maintenance", "pro", "/").Body.String(); body != "Version 1" {
|
||||
t.Fatalf("body = %q", body)
|
||||
}
|
||||
writeFile(t, dir, "maintenance.html", `Version 2 – länger`)
|
||||
if body := request(t, l, "maintenance", "pro", "/").Body.String(); body != "Version 2 – länger" {
|
||||
t.Errorf("Änderung nicht übernommen: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDump(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "landing")
|
||||
files, err := Dump(dir, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Dump: %v", err)
|
||||
}
|
||||
for _, stage := range Stages {
|
||||
if _, err := os.Stat(filepath.Join(dir, stage+".html")); err != nil {
|
||||
t.Errorf("%s.html fehlt", stage)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "README.txt")); err != nil {
|
||||
t.Error("README.txt fehlt")
|
||||
}
|
||||
if len(files) != len(Stages)+1 {
|
||||
t.Errorf("geschriebene Dateien: %v", files)
|
||||
}
|
||||
|
||||
// Ausgegebene Vorlagen rendern genauso wie die eingebauten.
|
||||
builtin := newLander(t)
|
||||
custom := newLander(t)
|
||||
custom.Dir = dir
|
||||
for _, stage := range Stages {
|
||||
a := request(t, builtin, stage, "community", "/x").Body.String()
|
||||
b := request(t, custom, stage, "community", "/x").Body.String()
|
||||
if a != b {
|
||||
t.Errorf("%s: Ausgabe weicht ab", stage)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := Dump(dir, false); !errors.Is(err, os.ErrExist) {
|
||||
t.Errorf("Überschreiben ohne force: %v", err)
|
||||
}
|
||||
writeFile(t, dir, "maintenance.html", "eigen")
|
||||
if _, err := Dump(dir, true); err != nil {
|
||||
t.Fatalf("Dump mit force: %v", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(dir, "maintenance.html")); string(b) == "eigen" {
|
||||
t.Error("force hat nicht überschrieben")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyMethodGetsPage(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/formular", strings.NewReader("a=b"))
|
||||
r.Header.Set("X-Sultan-Stage", "maintenance")
|
||||
rec := httptest.NewRecorder()
|
||||
newLander(t).ServeHTTP(rec, r)
|
||||
if rec.Code != 503 {
|
||||
t.Errorf("POST: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Das Weiterleitungsziel ist immer ein lokaler Pfad: keine fremden Hosts
|
||||
// (//evil), keine Schemata (javascript:), keine Backslash-Tricks.
|
||||
func TestRefreshTargetIsLocal(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"/shop?x=1": "/shop?x=1",
|
||||
"//evil.example/x": "/",
|
||||
"/\\evil.example": "/",
|
||||
"/a%2F%2Fb": "/a%2F%2Fb",
|
||||
"/pfad mit leer": "/pfad%20mit%20leer",
|
||||
}
|
||||
for target, want := range tests {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.URL.Path, r.URL.RawPath, r.URL.RawQuery = "", "", ""
|
||||
u, _ := r.URL.Parse(target)
|
||||
if strings.HasPrefix(target, "//") {
|
||||
u.Host, u.Path = "", target // so wie es bei einem Request "GET //evil.example/x" ankommt
|
||||
}
|
||||
r.URL = u
|
||||
r.Header.Set("X-Sultan-Stage", "watermark")
|
||||
rec := httptest.NewRecorder()
|
||||
newLander(t).ServeHTTP(rec, r)
|
||||
if !strings.Contains(rec.Body.String(), `content="5;url=`+want+`"`) {
|
||||
t.Errorf("%q: erwartet Ziel %q in\n%s", target, want, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if got := localTarget(nil); got != "/" {
|
||||
t.Errorf("localTarget(nil) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenancePublicReasonAndUntil(t *testing.T) {
|
||||
berlin, _ := time.LoadLocation("Europe/Berlin")
|
||||
l := newLander(t)
|
||||
l.Location = berlin
|
||||
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
l.now = func() time.Time { return now }
|
||||
until := now.Add(90 * time.Minute) // 15:30 in Berlin (MESZ)
|
||||
|
||||
rec := request(t, l, "maintenance", "pro", "/",
|
||||
"X-Sultan-Public-Reason", url.QueryEscape("Sicherheitsupdate (CVE-1234) – Sicherheit nehmen wir ernst."),
|
||||
"X-Sultan-Until", until.Format(time.RFC3339))
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Sicherheitsupdate (CVE-1234) – Sicherheit nehmen wir ernst.") {
|
||||
t.Errorf("öffentlicher Grund fehlt:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "Bitte versuchen Sie es in einer Minute") {
|
||||
t.Error("Standardtext trotz öffentlichem Grund")
|
||||
}
|
||||
if !strings.Contains(body, "02.10.2026 15:30") || !strings.Contains(body, "2026-10-02 15:30") {
|
||||
t.Errorf("voraussichtliches Ende fehlt:\n%s", body)
|
||||
}
|
||||
if rec.Header().Get("Retry-After") != "5400" {
|
||||
t.Errorf("Retry-After %q, erwartet Sekunden bis zum Ende", rec.Header().Get("Retry-After"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceHeaderHandling(t *testing.T) {
|
||||
l := newLander(t)
|
||||
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
l.now = func() time.Time { return now }
|
||||
tests := []struct {
|
||||
name, until, retry string
|
||||
}{
|
||||
{"kein Ende", "", "60"},
|
||||
{"unlesbar", "morgen", "60"},
|
||||
{"vergangen", now.Add(-time.Hour).Format(time.RFC3339), "60"},
|
||||
{"sehr fern", now.Add(30 * 24 * time.Hour).Format(time.RFC3339), "86400"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
rec := request(t, l, "maintenance", "pro", "/", "X-Sultan-Until", tt.until)
|
||||
if got := rec.Header().Get("Retry-After"); got != tt.retry {
|
||||
t.Errorf("%s: Retry-After %q, erwartet %q", tt.name, got, tt.retry)
|
||||
}
|
||||
}
|
||||
// Eingeschleustes HTML im Grund wird maskiert, überlange Gründe gekürzt.
|
||||
rec := request(t, l, "maintenance", "pro", "/", "X-Sultan-Public-Reason", url.QueryEscape("<script>x</script>"+strings.Repeat("a", 400)))
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, "<script>x") || strings.Contains(body, strings.Repeat("a", 301)) {
|
||||
t.Errorf("Grund nicht maskiert/gekürzt: %.300s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
Sultan – eigene Lander-Seiten
|
||||
=============================
|
||||
|
||||
Pro Stufe eine Vorlage <stufe>.html (Go html/template), optional <stufe>.css
|
||||
und eine Grafik <stufe>.svg oder <stufe>.webp (je höchstens 512 KiB).
|
||||
CSS und Grafik werden in die Seite eingebettet – es gibt keine weiteren URLs.
|
||||
|
||||
Stufen: watermark, firemark, paywall, landing, maintenance
|
||||
Fehlt eine Vorlage oder ist sie fehlerhaft, gilt die eingebaute Seite.
|
||||
Änderungen wirken sofort (kein Neustart nötig).
|
||||
|
||||
Verfügbare Werte:
|
||||
{{.Stage}} Stufe
|
||||
{{.Tenant}} Mandanten-ID
|
||||
{{.Tier}} community | pro | enterprise
|
||||
{{.Community}} true bei Tier community
|
||||
{{.Target}} lokaler Pfad der ursprünglichen Seite (für Weiterleitung/Link)
|
||||
{{.RefreshSeconds}} Wartezeit der Weiterleitung (watermark/firemark)
|
||||
{{.HosterName}} Name des Hosting-Anbieters (sultan server --hoster-name)
|
||||
{{.HosterURL}} Angebotsseite des Anbieters (--hoster-url)
|
||||
{{.ProURL}} Pro-Angebot für Community (--pro-url)
|
||||
{{.PublicReason}} öffentlicher Wartungsgrund (maintenance, falls angegeben)
|
||||
{{.UntilDE}} voraussichtliches Ende, z. B. 02.10.2026 15:30 (maintenance)
|
||||
{{.UntilEN}} dasselbe, z. B. 2026-10-02 15:30
|
||||
{{.CSS}} Inhalt von <stufe>.css – in <style>{{.CSS}}</style>
|
||||
{{.Image}} Grafik als data:-URI – in <img src="{{.Image}}">
|
||||
|
||||
Syntax: https://pkg.go.dev/html/template (z. B. {{if .ProURL}}…{{end}})
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta http-equiv="refresh" content="{{.RefreshSeconds}};url={{.Target}}">
|
||||
<title>Wichtiger Hinweis / Important notice</title>
|
||||
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
|
||||
</head>
|
||||
<body>
|
||||
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
|
||||
<section lang="de">
|
||||
<h1>Wichtiger Hinweis</h1>
|
||||
<p>Für diese Website ist beim Hosting-Anbieter noch etwas offen. Wenn der Betreiber es nicht bald klärt,
|
||||
wird die Website abgeschaltet. Bis dahin ist sie erreichbar – Sie werden in {{.RefreshSeconds}} Sekunden weitergeleitet.</p>
|
||||
<p><a href="{{.Target}}">Weiter zur Website</a></p>
|
||||
</section>
|
||||
<section lang="en">
|
||||
<h1>Important notice</h1>
|
||||
<p>Something is still open with the hosting provider of this website. If the owner does not sort it out soon,
|
||||
the website will be switched off. Until then it is available – you will be redirected in {{.RefreshSeconds}} seconds.</p>
|
||||
<p><a href="{{.Target}}">Continue to the website</a></p>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Abgelaufen / Expired</title>
|
||||
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
|
||||
</head>
|
||||
<body>
|
||||
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
|
||||
<section lang="de">
|
||||
<h1>Das Hosting dieser Website ist abgelaufen</h1>
|
||||
<p>Die Website ist deshalb gerade nicht erreichbar. Sobald der Betreiber verlängert, ist sie wieder da.
|
||||
{{if .HosterName}}{{.HosterName}}{{else}}Der Hosting-Anbieter{{end}} freut sich, wenn Sie sich in der Zwischenzeit umsehen.</p>
|
||||
{{if .HosterURL}}<p><a href="{{.HosterURL}}">Zu den Angeboten</a></p>{{end}}
|
||||
</section>
|
||||
<section lang="en">
|
||||
<h1>The hosting of this website has expired</h1>
|
||||
<p>That is why the website is not available right now. As soon as the owner renews, it will be back.
|
||||
{{if .HosterName}}{{.HosterName}}{{else}}The hosting provider{{end}} would be happy if you have a look around in the meantime.</p>
|
||||
{{if .HosterURL}}<p><a href="{{.HosterURL}}">See the offers</a></p>{{end}}
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Gleich wieder da / Back soon</title>
|
||||
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
|
||||
</head>
|
||||
<body>
|
||||
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
|
||||
<section lang="de">
|
||||
<h1>Gleich wieder da</h1>
|
||||
{{if .PublicReason}}<p>{{.PublicReason}}</p>{{else}}<p>Diese Website ist gerade nicht erreichbar – vermutlich wird sie gewartet oder neu gestartet.
|
||||
Bitte versuchen Sie es in einer Minute noch einmal.</p>{{end}}
|
||||
{{if .UntilDE}}<p>Voraussichtlich wieder da: {{.UntilDE}}</p>{{end}}
|
||||
{{if and .Community .ProURL}}<p>Sie möchten so etwas gar nicht erst erleben? <a href="{{.ProURL}}">Hier gibt es den Pro-Service</a>.</p>{{end}}
|
||||
</section>
|
||||
<section lang="en">
|
||||
<h1>Back in a moment</h1>
|
||||
{{if .PublicReason}}<p>{{.PublicReason}}</p>{{else}}<p>This website is not available right now – it is probably being maintained or restarted.
|
||||
Please try again in a minute.</p>{{end}}
|
||||
{{if .UntilEN}}<p>Expected back: {{.UntilEN}}</p>{{end}}
|
||||
{{if and .Community .ProURL}}<p>Want to avoid this altogether? <a href="{{.ProURL}}">Get the pro service here</a>.</p>{{end}}
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Derzeit offline / Currently offline</title>
|
||||
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
|
||||
</head>
|
||||
<body>
|
||||
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
|
||||
<section lang="de">
|
||||
<h1>Diese Website ist derzeit offline</h1>
|
||||
<p>Der Betreiber hat sein Hosting nicht verlängert. Schade – aber vielleicht passt ja etwas für Sie:
|
||||
{{if .HosterName}}{{.HosterName}}{{else}}Der Hosting-Anbieter{{end}} lädt Sie herzlich ein, sich umzusehen, was hier alles möglich ist.</p>
|
||||
{{if .HosterURL}}<p><a href="{{.HosterURL}}">Zu den Angeboten</a></p>{{end}}
|
||||
</section>
|
||||
<section lang="en">
|
||||
<h1>This website is currently offline</h1>
|
||||
<p>The owner has not renewed their hosting. A pity – but maybe there is something for you:
|
||||
{{if .HosterName}}{{.HosterName}}{{else}}The hosting provider{{end}} warmly invites you to have a look at what is possible here.</p>
|
||||
{{if .HosterURL}}<p><a href="{{.HosterURL}}">See the offers</a></p>{{end}}
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta http-equiv="refresh" content="{{.RefreshSeconds}};url={{.Target}}">
|
||||
<title>Hinweis / Notice</title>
|
||||
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
|
||||
</head>
|
||||
<body>
|
||||
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
|
||||
<section lang="de">
|
||||
<h1>Kurzer Hinweis</h1>
|
||||
<p>Der Betreiber dieser Website hat eine Nachricht von seinem Hosting-Anbieter erhalten, um die er sich kümmern sollte.
|
||||
Die Website ist ganz normal erreichbar – Sie werden in {{.RefreshSeconds}} Sekunden automatisch weitergeleitet.</p>
|
||||
<p><a href="{{.Target}}">Gleich weiter zur Website</a></p>
|
||||
</section>
|
||||
<section lang="en">
|
||||
<h1>Just a short notice</h1>
|
||||
<p>The owner of this website has received a message from their hosting provider that needs attention.
|
||||
The website works as usual – you will be redirected automatically in {{.RefreshSeconds}} seconds.</p>
|
||||
<p><a href="{{.Target}}">Continue to the website now</a></p>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -66,3 +66,7 @@ func (l License) AllowsDomain(host string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NormalizeDomain prüft und normalisiert einen Eintrag für allowed_domains
|
||||
// (klein, ohne Punkt am Ende; Wildcard nur vorne, nicht auf einer TLD).
|
||||
func NormalizeDomain(p string) (string, error) { return normalizePattern(p) }
|
||||
@@ -55,3 +55,12 @@ func strings63() string {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestNormalizeDomain_Exported(t *testing.T) {
|
||||
if got, err := NormalizeDomain(" Shop.DE. "); err != nil || got != "shop.de" {
|
||||
t.Errorf("NormalizeDomain = %q, %v", got, err)
|
||||
}
|
||||
if _, err := NormalizeDomain("*.de"); err == nil {
|
||||
t.Error("*.de akzeptiert")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Laufzeiten der Leases (Spec 4.1a).
|
||||
const (
|
||||
CommunityLease = 14 * 24 * time.Hour
|
||||
CommunityRenewAfter = 7 * 24 * time.Hour
|
||||
CommunityRenewJitter = 12 * time.Hour
|
||||
CommercialLease = 7 * 24 * time.Hour
|
||||
CommercialMaintenance = 10 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// LeaseTerms sind Erneuerungs- und Ablaufzeitpunkt einer Lease.
|
||||
type LeaseTerms struct {
|
||||
RenewAfter time.Time
|
||||
ValidUntil time.Time
|
||||
}
|
||||
|
||||
// TermsFor berechnet die Lease-Zeiten. Kommerziell: 7 Tage (in Wartung 10),
|
||||
// Erneuerung nach 2/3, höchstens bis zum Vertragsende; nach Vertragsende
|
||||
// gibt es keine Lease (ok=false). Community: 14 Tage, Erneuerung nach
|
||||
// 7 Tagen ± Streuung (jitter liefert einen Wert in [-max, +max]; nil =
|
||||
// zufällig), Vertragsende ohne Bedeutung. override > 0 setzt eine feste
|
||||
// Laufzeit (Tests, Betrieb), Erneuerung dann nach 2/3.
|
||||
func TermsFor(tier Tier, maintenance bool, licenseUntil, now time.Time, override time.Duration,
|
||||
jitter func(max time.Duration) time.Duration) (LeaseTerms, bool) {
|
||||
commercial := tier != TierCommunity
|
||||
if commercial && !now.Before(licenseUntil) {
|
||||
return LeaseTerms{}, false
|
||||
}
|
||||
var t LeaseTerms
|
||||
switch {
|
||||
case override > 0:
|
||||
t.ValidUntil = now.Add(override)
|
||||
case !commercial:
|
||||
if jitter == nil {
|
||||
jitter = randomJitter
|
||||
}
|
||||
return LeaseTerms{
|
||||
ValidUntil: now.Add(CommunityLease),
|
||||
RenewAfter: now.Add(CommunityRenewAfter + jitter(CommunityRenewJitter)),
|
||||
}, true
|
||||
case maintenance:
|
||||
t.ValidUntil = now.Add(CommercialMaintenance)
|
||||
default:
|
||||
t.ValidUntil = now.Add(CommercialLease)
|
||||
}
|
||||
if commercial && t.ValidUntil.After(licenseUntil) {
|
||||
t.ValidUntil = licenseUntil
|
||||
}
|
||||
t.RenewAfter = now.Add(t.ValidUntil.Sub(now) * 2 / 3)
|
||||
return t, true
|
||||
}
|
||||
|
||||
func randomJitter(max time.Duration) time.Duration {
|
||||
return time.Duration(rand.Int64N(int64(2*max)+1)) - max
|
||||
}
|
||||
|
||||
// Lease ist die signierte, kurzlaufende Kopie der Lizenz für einen Knoten.
|
||||
type Lease struct {
|
||||
NodeID string `json:"node_id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Tier Tier `json:"tier"`
|
||||
MaxRoutes int `json:"max_routes"`
|
||||
AllowedDomains []string `json:"allowed_domains"`
|
||||
Features []Feature `json:"features"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
RenewAfter time.Time `json:"renew_after"`
|
||||
ValidUntil time.Time `json:"valid_until"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// Eigenes Präfix: Eine Lease-Signatur gilt nie als Lizenz-Signatur und umgekehrt.
|
||||
const leaseSignatureVersion = "SULTAN-LEASE-V1\n"
|
||||
|
||||
func (l Lease) message() []byte {
|
||||
c := l
|
||||
c.Signature = ""
|
||||
c.IssuedAt, c.RenewAfter, c.ValidUntil = c.IssuedAt.UTC(), c.RenewAfter.UTC(), c.ValidUntil.UTC()
|
||||
b, _ := json.Marshal(c)
|
||||
return append([]byte(leaseSignatureVersion), b...)
|
||||
}
|
||||
|
||||
// IssueLease stellt eine Lease für nodeID aus den Werten von l aus.
|
||||
func (i *Issuer) IssueLease(l License, nodeID string, terms LeaseTerms, now time.Time) (Lease, error) {
|
||||
if len(i.Key) != ed25519.PrivateKeySize {
|
||||
return Lease{}, fmt.Errorf("license: Issuer ohne gültigen Master-Key")
|
||||
}
|
||||
if strings.TrimSpace(nodeID) == "" {
|
||||
return Lease{}, fmt.Errorf("%w: node_id fehlt", ErrInvalid)
|
||||
}
|
||||
utc := func(t time.Time) time.Time { return t.Round(0).UTC() }
|
||||
lease := Lease{
|
||||
NodeID: nodeID,
|
||||
TenantID: l.TenantID,
|
||||
Tier: l.Tier,
|
||||
MaxRoutes: l.MaxRoutes,
|
||||
AllowedDomains: append([]string{}, l.AllowedDomains...),
|
||||
Features: append([]Feature{}, l.Features...),
|
||||
IssuedAt: utc(now),
|
||||
RenewAfter: utc(terms.RenewAfter),
|
||||
ValidUntil: utc(terms.ValidUntil),
|
||||
}
|
||||
lease.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(i.Key, lease.message()))
|
||||
return lease, nil
|
||||
}
|
||||
|
||||
// Verify prüft die Signatur mit dem Public Key des Master-Keys.
|
||||
func (l Lease) Verify(pub ed25519.PublicKey) error {
|
||||
sig, err := base64.StdEncoding.DecodeString(l.Signature)
|
||||
if err != nil || len(sig) != ed25519.SignatureSize || len(pub) != ed25519.PublicKeySize {
|
||||
return ErrInvalidSignature
|
||||
}
|
||||
if !ed25519.Verify(pub, l.message(), sig) {
|
||||
return ErrInvalidSignature
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Expired meldet, ob die Lease zum Zeitpunkt now abgelaufen ist.
|
||||
func (l Lease) Expired(now time.Time) bool { return !now.Before(l.ValidUntil) }
|
||||
|
||||
// ParseLease liest und prüft eine Lease (unbekannte Felder sind Fehler).
|
||||
func ParseLease(data []byte, pub ed25519.PublicKey) (Lease, error) {
|
||||
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||
dec.DisallowUnknownFields()
|
||||
var l Lease
|
||||
if err := dec.Decode(&l); err != nil {
|
||||
return Lease{}, fmt.Errorf("%w: %v", ErrInvalid, err)
|
||||
}
|
||||
if l.NodeID == "" || l.ValidUntil.IsZero() {
|
||||
return Lease{}, fmt.Errorf("%w: node_id oder valid_until fehlt", ErrInvalid)
|
||||
}
|
||||
if l.AllowedDomains == nil {
|
||||
l.AllowedDomains = []string{}
|
||||
}
|
||||
if l.Features == nil {
|
||||
l.Features = []Feature{}
|
||||
}
|
||||
if err := l.Verify(pub); err != nil {
|
||||
return Lease{}, err
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var t0 = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
const day = 24 * time.Hour
|
||||
|
||||
func noJitter(time.Duration) time.Duration { return 0 }
|
||||
|
||||
func TestLeaseTerms(t *testing.T) {
|
||||
far := t0.AddDate(1, 0, 0)
|
||||
tests := []struct {
|
||||
name string
|
||||
tier Tier
|
||||
maintenance bool
|
||||
licenseUntil time.Time
|
||||
valid, renew time.Duration // relativ zu t0
|
||||
ok bool
|
||||
}{
|
||||
{"community", TierCommunity, false, far, 14 * day, 7 * day, true},
|
||||
{"community ignoriert Vertragsende", TierCommunity, false, t0.Add(day), 14 * day, 7 * day, true},
|
||||
{"pro", TierPro, false, far, 7 * day, 7 * day * 2 / 3, true},
|
||||
{"pro in Wartung", TierPro, true, far, 10 * day, 10 * day * 2 / 3, true},
|
||||
{"enterprise am Vertragsende gekappt", TierEnterprise, false, t0.Add(3 * day), 3 * day, 2 * day, true},
|
||||
{"Vertrag abgelaufen", TierPro, false, t0, 0, 0, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
terms, ok := TermsFor(tt.tier, tt.maintenance, tt.licenseUntil, t0, 0, noJitter)
|
||||
if ok != tt.ok {
|
||||
t.Errorf("%s: ok=%v", tt.name, ok)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !terms.ValidUntil.Equal(t0.Add(tt.valid)) || !terms.RenewAfter.Equal(t0.Add(tt.renew)) {
|
||||
t.Errorf("%s: gültig bis +%v, erneuern ab +%v; erwartet +%v / +%v",
|
||||
tt.name, terms.ValidUntil.Sub(t0), terms.RenewAfter.Sub(t0), tt.valid, tt.renew)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Community-Erneuerung gestreut (± 12 h), damit nicht alle Agents gleichzeitig erneuern.
|
||||
func TestLeaseTerms_CommunityJitter(t *testing.T) {
|
||||
var maxSeen time.Duration
|
||||
jitter := func(max time.Duration) time.Duration { maxSeen = max; return -5 * time.Hour }
|
||||
terms, _ := TermsFor(TierCommunity, false, t0.AddDate(1, 0, 0), t0, 0, jitter)
|
||||
if maxSeen != 12*time.Hour {
|
||||
t.Errorf("Streuung ±%v, erwartet ±12h", maxSeen)
|
||||
}
|
||||
if !terms.RenewAfter.Equal(t0.Add(7*day - 5*time.Hour)) {
|
||||
t.Errorf("RenewAfter = +%v", terms.RenewAfter.Sub(t0))
|
||||
}
|
||||
// Ohne explizite Funktion: echte Streuung, aber innerhalb der Grenzen.
|
||||
for range 100 {
|
||||
terms, _ := TermsFor(TierCommunity, false, t0.AddDate(1, 0, 0), t0, 0, nil)
|
||||
if d := terms.RenewAfter.Sub(t0); d < 7*day-12*time.Hour || d > 7*day+12*time.Hour {
|
||||
t.Fatalf("Streuung außerhalb: +%v", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override (Tests, Betrieb): feste Laufzeit, Erneuerung nach 2/3, Vertragsende gilt weiter.
|
||||
func TestLeaseTerms_Override(t *testing.T) {
|
||||
terms, ok := TermsFor(TierCommunity, false, t0.AddDate(1, 0, 0), t0, 300*time.Millisecond, noJitter)
|
||||
if !ok || !terms.ValidUntil.Equal(t0.Add(300*time.Millisecond)) || !terms.RenewAfter.Equal(t0.Add(200*time.Millisecond)) {
|
||||
t.Errorf("Override: %+v", terms)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAndParseLease(t *testing.T) {
|
||||
iss, pub := newIssuer(t)
|
||||
lic := sample()
|
||||
terms := LeaseTerms{RenewAfter: t0.Add(4 * day), ValidUntil: t0.Add(7 * day)}
|
||||
l, err := iss.IssueLease(lic, "node-1", terms, t0)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueLease: %v", err)
|
||||
}
|
||||
if l.NodeID != "node-1" || l.TenantID != lic.TenantID || !l.IssuedAt.Equal(t0) || !l.ValidUntil.Equal(terms.ValidUntil) ||
|
||||
l.MaxRoutes != lic.MaxRoutes || len(l.AllowedDomains) != 2 {
|
||||
t.Errorf("Lease: %+v", l)
|
||||
}
|
||||
doc, _ := json.Marshal(l)
|
||||
parsed, err := ParseLease(doc, pub)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLease: %v", err)
|
||||
}
|
||||
if parsed.NodeID != "node-1" || !parsed.ValidUntil.Equal(terms.ValidUntil) {
|
||||
t.Errorf("geparst: %+v", parsed)
|
||||
}
|
||||
if !l.Expired(terms.ValidUntil) || l.Expired(terms.ValidUntil.Add(-time.Second)) {
|
||||
t.Error("Expired falsch")
|
||||
}
|
||||
|
||||
for name, tamper := range map[string]func(*Lease){
|
||||
"Knoten": func(l *Lease) { l.NodeID = "node-2" },
|
||||
"Laufzeit": func(l *Lease) { l.ValidUntil = l.ValidUntil.Add(time.Hour) },
|
||||
"Domains": func(l *Lease) { l.AllowedDomains = append([]string(nil), "bank.de") },
|
||||
"Erneuerung": func(l *Lease) { l.RenewAfter = l.RenewAfter.Add(time.Hour) },
|
||||
} {
|
||||
c := l
|
||||
tamper(&c)
|
||||
if err := c.Verify(pub); !errors.Is(err, ErrInvalidSignature) {
|
||||
t.Errorf("%s manipuliert: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Domänentrennung: Eine Lease ist keine Lizenz und umgekehrt.
|
||||
func TestLeaseIsNotALicense(t *testing.T) {
|
||||
iss, pub := newIssuer(t)
|
||||
lic, _ := iss.Issue(sample())
|
||||
lease, _ := iss.IssueLease(sample(), "node-1", LeaseTerms{RenewAfter: t0.Add(day), ValidUntil: t0.Add(2 * day)}, t0)
|
||||
|
||||
licDoc, _ := json.Marshal(lic)
|
||||
if _, err := ParseLease(licDoc, pub); err == nil {
|
||||
t.Error("Lizenz als Lease akzeptiert")
|
||||
}
|
||||
asLicense := License{
|
||||
TenantID: lease.TenantID, Tier: lease.Tier, MaxRoutes: lease.MaxRoutes, AllowedDomains: lease.AllowedDomains,
|
||||
Features: lease.Features, ValidUntil: lease.ValidUntil, Signature: lease.Signature,
|
||||
}
|
||||
if err := asLicense.Verify(pub); !errors.Is(err, ErrInvalidSignature) {
|
||||
t.Error("Lease-Signatur als Lizenz-Signatur akzeptiert")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Package notify verschickt Benachrichtigungen des Servers (Fristen,
|
||||
// Mahnstufen, Offline-Alarme, Policy-Verstöße) an Webhooks: Slack-kompatibel
|
||||
// (auch Mattermost, Rocket.Chat, Discord-Slack-Endpunkt) und ntfy.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Severity ist die Dringlichkeit einer Nachricht.
|
||||
type Severity int
|
||||
|
||||
const (
|
||||
Info Severity = iota
|
||||
Warning
|
||||
Critical
|
||||
)
|
||||
|
||||
// Message ist eine Benachrichtigung; Body darf Slack-Markdown enthalten.
|
||||
type Message struct {
|
||||
Title string
|
||||
Body string
|
||||
Severity Severity
|
||||
}
|
||||
|
||||
// Target ist ein Benachrichtigungsziel.
|
||||
type Target interface {
|
||||
Send(ctx context.Context, m Message) error
|
||||
}
|
||||
|
||||
// ErrNoTargets: Multi ohne Ziele.
|
||||
var ErrNoTargets = errors.New("notify: keine Ziele konfiguriert")
|
||||
|
||||
var client = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
func post(ctx context.Context, url, contentType string, body []byte, headers map[string]string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notify: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
return fmt.Errorf("notify: %s antwortet %d: %s", url, resp.StatusCode, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var emoji = map[Severity]string{Info: ":information_source:", Warning: ":warning:", Critical: ":rotating_light:"}
|
||||
|
||||
// Slack sendet an einen Slack-kompatiblen Incoming Webhook ({"text": …}).
|
||||
type Slack struct{ URL string }
|
||||
|
||||
func (s Slack) Send(ctx context.Context, m Message) error {
|
||||
body, _ := json.Marshal(map[string]string{"text": fmt.Sprintf("%s *%s*\n%s", emoji[m.Severity], m.Title, m.Body)})
|
||||
return post(ctx, s.URL, "application/json", body, nil)
|
||||
}
|
||||
|
||||
var ntfyPriority = map[Severity][2]string{
|
||||
Info: {"default", "information_source"},
|
||||
Warning: {"high", "warning"},
|
||||
Critical: {"urgent", "rotating_light"},
|
||||
}
|
||||
|
||||
// Ntfy sendet an ein ntfy-Topic (URL inkl. Topic), optional mit Access-Token.
|
||||
type Ntfy struct {
|
||||
URL string
|
||||
Token string
|
||||
}
|
||||
|
||||
func (n Ntfy) Send(ctx context.Context, m Message) error {
|
||||
p := ntfyPriority[m.Severity]
|
||||
headers := map[string]string{"Title": m.Title, "Priority": p[0], "Tags": p[1], "Markdown": "yes"}
|
||||
if n.Token != "" {
|
||||
headers["Authorization"] = "Bearer " + n.Token
|
||||
}
|
||||
return post(ctx, n.URL, "text/markdown; charset=utf-8", []byte(m.Body), headers)
|
||||
}
|
||||
|
||||
// Multi sendet an alle Ziele; Fehler einzelner Ziele werden gesammelt
|
||||
// gemeldet, halten die übrigen aber nicht auf.
|
||||
type Multi []Target
|
||||
|
||||
func (m Multi) Send(ctx context.Context, msg Message) error {
|
||||
if len(m) == 0 {
|
||||
return ErrNoTargets
|
||||
}
|
||||
var errs []error
|
||||
for _, t := range m {
|
||||
if err := t.Send(ctx, msg); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type received struct {
|
||||
mu sync.Mutex
|
||||
reqs []*http.Request
|
||||
body []string
|
||||
}
|
||||
|
||||
func (r *received) handler(status int) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
r.mu.Lock()
|
||||
r.reqs = append(r.reqs, req)
|
||||
r.body = append(r.body, string(b))
|
||||
r.mu.Unlock()
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
}
|
||||
|
||||
var msg = Message{
|
||||
Title: "Vertrag kunde läuft ab",
|
||||
Body: "Noch *14 Tage* bis 2026-12-15.",
|
||||
Severity: Critical,
|
||||
}
|
||||
|
||||
func TestSlack(t *testing.T) {
|
||||
var got received
|
||||
srv := httptest.NewServer(got.handler(200))
|
||||
defer srv.Close()
|
||||
if err := (Slack{URL: srv.URL}).Send(context.Background(), msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ct := got.reqs[0].Header.Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type %q", ct)
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal([]byte(got.body[0]), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := payload["text"]
|
||||
if !strings.Contains(text, "*Vertrag kunde läuft ab*") || !strings.Contains(text, "Noch *14 Tage*") || !strings.Contains(text, ":rotating_light:") {
|
||||
t.Errorf("text = %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNtfy(t *testing.T) {
|
||||
var got received
|
||||
srv := httptest.NewServer(got.handler(200))
|
||||
defer srv.Close()
|
||||
if err := (Ntfy{URL: srv.URL + "/sultan", Token: "tk_geheim"}).Send(context.Background(), msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := got.reqs[0]
|
||||
if r.URL.Path != "/sultan" || got.body[0] != msg.Body {
|
||||
t.Errorf("Pfad %q, Body %q", r.URL.Path, got.body[0])
|
||||
}
|
||||
for h, want := range map[string]string{
|
||||
"Title": "Vertrag kunde läuft ab",
|
||||
"Priority": "urgent",
|
||||
"Tags": "rotating_light",
|
||||
"Markdown": "yes",
|
||||
"Authorization": "Bearer tk_geheim",
|
||||
} {
|
||||
if r.Header.Get(h) != want {
|
||||
t.Errorf("%s = %q, erwartet %q", h, r.Header.Get(h), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeverityMapping(t *testing.T) {
|
||||
for sev, want := range map[Severity][2]string{
|
||||
Info: {"default", "information_source"},
|
||||
Warning: {"high", "warning"},
|
||||
Critical: {"urgent", "rotating_light"},
|
||||
} {
|
||||
var got received
|
||||
srv := httptest.NewServer(got.handler(200))
|
||||
(Ntfy{URL: srv.URL}).Send(context.Background(), Message{Title: "x", Body: "y", Severity: sev})
|
||||
srv.Close()
|
||||
if got.reqs[0].Header.Get("Priority") != want[0] || got.reqs[0].Header.Get("Tags") != want[1] {
|
||||
t.Errorf("%v: Priority %q Tags %q", sev, got.reqs[0].Header.Get("Priority"), got.reqs[0].Header.Get("Tags"))
|
||||
}
|
||||
if got.reqs[0].Header.Get("Authorization") != "" {
|
||||
t.Error("Authorization ohne Token gesetzt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrors(t *testing.T) {
|
||||
var got received
|
||||
bad := httptest.NewServer(got.handler(500))
|
||||
defer bad.Close()
|
||||
for _, target := range []Target{Slack{URL: bad.URL}, Ntfy{URL: bad.URL}, Slack{URL: "http://127.0.0.1:1"}} {
|
||||
if err := target.Send(context.Background(), msg); err == nil {
|
||||
t.Errorf("%T: erwartet Fehler", target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Multi sendet an alle Ziele; ein Fehler bei einem Ziel hält die anderen nicht auf.
|
||||
func TestMulti(t *testing.T) {
|
||||
var a, b received
|
||||
okA := httptest.NewServer(a.handler(200))
|
||||
okB := httptest.NewServer(b.handler(200))
|
||||
bad := httptest.NewServer((&received{}).handler(500))
|
||||
defer okA.Close()
|
||||
defer okB.Close()
|
||||
defer bad.Close()
|
||||
|
||||
m := Multi{Slack{URL: okA.URL}, Ntfy{URL: bad.URL}, Ntfy{URL: okB.URL}}
|
||||
err := m.Send(context.Background(), msg)
|
||||
if err == nil {
|
||||
t.Error("Fehler eines Ziels nicht gemeldet")
|
||||
}
|
||||
if len(a.reqs) != 1 || len(b.reqs) != 1 {
|
||||
t.Errorf("nicht alle Ziele erreicht: %d/%d", len(a.reqs), len(b.reqs))
|
||||
}
|
||||
if !errors.Is(Multi{}.Send(context.Background(), msg), ErrNoTargets) {
|
||||
t.Error("leeres Multi: erwartet ErrNoTargets")
|
||||
}
|
||||
}
|
||||
+56
-3
@@ -21,6 +21,7 @@ package policy
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -61,8 +62,23 @@ const (
|
||||
HeaderStage = "X-Sultan-Stage"
|
||||
HeaderTenant = "X-Sultan-Tenant"
|
||||
HeaderTier = "X-Sultan-Tier" // z. B. für den Pro-Hinweis auf Community-Wartungsseiten
|
||||
// HeaderUntil: voraussichtliches Wartungsende (RFC 3339), nur bei befristeter Wartung.
|
||||
HeaderUntil = "X-Sultan-Until"
|
||||
// HeaderPublicReason: öffentlicher Wartungsgrund, URL-kodiert (Header sind
|
||||
// einzeilig; Umlaute kommen so unverändert beim Lander an).
|
||||
HeaderPublicReason = "X-Sultan-Public-Reason"
|
||||
)
|
||||
|
||||
// Maintenance ist eine angemeldete Wartung (Knoten oder Dienst).
|
||||
type Maintenance struct {
|
||||
Until time.Time // Nullwert = unbefristet
|
||||
PublicReason string
|
||||
}
|
||||
|
||||
func (m *Maintenance) active(now time.Time) bool {
|
||||
return m != nil && (m.Until.IsZero() || now.Before(m.Until))
|
||||
}
|
||||
|
||||
// AckCookie setzt der Lander, nachdem er den Mahnhinweis gezeigt hat; der
|
||||
// Wert ist die Stufe. Ein bestätigter Hinweis gilt nicht für eine höhere
|
||||
// Stufe. Das Cookie ist bewusst kein Sicherheitsmerkmal – wer es selbst
|
||||
@@ -124,6 +140,14 @@ type Input struct {
|
||||
// DownServices: Services (Originalnamen), die der Healthcheck des
|
||||
// Agenten als ausgefallen meldet.
|
||||
DownServices map[string]bool
|
||||
// Maintenance: Wartung des ganzen Knotens (nil = keine).
|
||||
Maintenance *Maintenance
|
||||
// ServiceMaintenance: Wartung einzelner Services (Originalnamen), z. B.
|
||||
// per Label sultan.maintenance=true.
|
||||
ServiceMaintenance map[string]Maintenance
|
||||
// Disconnected: Der Knoten ist ungeplant getrennt, seine Lease gilt aber
|
||||
// noch – Besucher sehen die Wartungsseite (ohne Ende und Grund).
|
||||
Disconnected bool
|
||||
}
|
||||
|
||||
// ViolationKind klassifiziert einen Verstoß.
|
||||
@@ -202,7 +226,8 @@ func StageFor(t Tenant, l license.License, now time.Time) Stage {
|
||||
switch {
|
||||
case t.Status == store.TenantQuarantined:
|
||||
return StageBlocked
|
||||
case t.Status == store.TenantExpired || l.Expired(now):
|
||||
// Community wird automatisch neu lizenziert: nur der explizite Status zählt.
|
||||
case t.Status == store.TenantExpired || (l.Tier != license.TierCommunity && l.Expired(now)):
|
||||
return StageLanding
|
||||
case l.Tier == license.TierCommunity:
|
||||
return StageNormal // kein Mahnwesen im Community-Betrieb
|
||||
@@ -226,7 +251,8 @@ func (e *Engine) Apply(in Input) (Result, error) {
|
||||
if e.now != nil {
|
||||
now = e.now
|
||||
}
|
||||
stage := StageFor(in.Tenant, in.License, now())
|
||||
at := now()
|
||||
stage := StageFor(in.Tenant, in.License, at)
|
||||
res := Result{Config: &traefik.Configuration{}, Stage: stage}
|
||||
if stage == StageBlocked || in.NodeDrained || in.Config == nil || in.Config.HTTP == nil {
|
||||
return res, nil
|
||||
@@ -289,8 +315,11 @@ func (e *Engine) Apply(in Input) (Result, error) {
|
||||
out.Services[nr.Service] = copyService(src.Services[r.Service])
|
||||
}
|
||||
|
||||
// Vorrang: Ablauf/Paywall > Wartung (Knoten, Dienst, Healthcheck) >
|
||||
// Mahnhinweis > normal. (Sperre und drain liefern oben gar keine Routen.)
|
||||
routerStage := stage
|
||||
if routerStage == StageNormal && in.DownServices[r.Service] {
|
||||
maint, inMaint := maintenanceFor(in, r.Service, at)
|
||||
if inMaint && (stage == StageNormal || slices.Contains(ackStages, stage)) {
|
||||
routerStage = StageMaintenance
|
||||
}
|
||||
if routerStage != StageNormal {
|
||||
@@ -319,6 +348,14 @@ func (e *Engine) Apply(in Input) (Result, error) {
|
||||
HeaderTenant: in.Tenant.ID,
|
||||
HeaderTier: string(in.License.Tier),
|
||||
}
|
||||
if routerStage == StageMaintenance {
|
||||
if !maint.Until.IsZero() {
|
||||
headers[HeaderUntil] = maint.Until.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if maint.PublicReason != "" {
|
||||
headers[HeaderPublicReason] = url.QueryEscape(maint.PublicReason)
|
||||
}
|
||||
}
|
||||
mwName := "sultan__" + prefix + name
|
||||
out.Middlewares[mwName] = traefik.Middleware{"headers": map[string]any{"customRequestHeaders": headers}}
|
||||
nr.Middlewares = append(slices.Clone(nr.Middlewares), mwName)
|
||||
@@ -336,6 +373,22 @@ func (e *Engine) Apply(in Input) (Result, error) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// maintenanceFor liefert die geltende Wartung eines Services: zuerst die des
|
||||
// Knotens, dann die des Services, dann ein Healthcheck-Ausfall oder ein
|
||||
// getrennter Knoten (beides ohne Grund).
|
||||
func maintenanceFor(in Input, service string, now time.Time) (Maintenance, bool) {
|
||||
if in.Maintenance.active(now) {
|
||||
return *in.Maintenance, true
|
||||
}
|
||||
if m, ok := in.ServiceMaintenance[service]; ok && m.active(now) {
|
||||
return m, true
|
||||
}
|
||||
if in.DownServices[service] || in.Disconnected {
|
||||
return Maintenance{}, true
|
||||
}
|
||||
return Maintenance{}, false
|
||||
}
|
||||
|
||||
func validateInput(in Input) error {
|
||||
switch {
|
||||
case !ValidID(in.Tenant.ID) || in.Tenant.ID == "sultan":
|
||||
|
||||
+143
-1
@@ -2,6 +2,7 @@ package policy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -296,7 +297,10 @@ func TestStageFor(t *testing.T) {
|
||||
}{
|
||||
{"Community aktiv", license.TierCommunity, store.TenantActive, 0, false, StageNormal},
|
||||
{"Community ohne Mahnwesen", license.TierCommunity, store.TenantActive, 2, false, StageNormal},
|
||||
{"Community abgelaufen", license.TierCommunity, store.TenantActive, 0, true, StageLanding},
|
||||
// Community wird automatisch neu lizenziert (5c): kein Ablauf über valid_until …
|
||||
{"Community mit altem valid_until", license.TierCommunity, store.TenantActive, 0, true, StageNormal},
|
||||
// … nur der explizite Status expired führt zur Landingpage.
|
||||
{"Community explizit abgelaufen", license.TierCommunity, store.TenantExpired, 0, false, StageLanding},
|
||||
{"Pro aktiv", license.TierPro, store.TenantActive, 0, false, StageNormal},
|
||||
{"Mahnstufe 1", license.TierPro, store.TenantActive, 1, false, StageWatermark},
|
||||
{"Mahnstufe 2", license.TierPro, store.TenantActive, 2, false, StageFiremark},
|
||||
@@ -585,3 +589,141 @@ func TestValidID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func headersOf(t *testing.T, res Result, router string) map[string]any {
|
||||
t.Helper()
|
||||
mw := res.Config.HTTP.Middlewares["sultan__kunde__node-1__"+router]
|
||||
if mw == nil {
|
||||
t.Fatalf("keine Interceptor-Middleware für %s", router)
|
||||
}
|
||||
return mw["headers"].(map[string]any)["customRequestHeaders"].(map[string]any)
|
||||
}
|
||||
|
||||
func TestApply_NodeMaintenance(t *testing.T) {
|
||||
in := input(t, wpLabels())
|
||||
until := now.Add(2 * time.Hour)
|
||||
in.Maintenance = &Maintenance{Until: until, PublicReason: "Sicherheitsupdate (CVE-1234) – Sicherheit geht vor."}
|
||||
res, err := engine().Apply(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Config.HTTP.Routers["kunde__node-1__wp"].Service != InterceptorService {
|
||||
t.Fatal("Knoten in Wartung nicht zum Interceptor umgeleitet")
|
||||
}
|
||||
h := headersOf(t, res, "wp")
|
||||
if h[HeaderStage] != string(StageMaintenance) || h[HeaderUntil] != until.Format(time.RFC3339) {
|
||||
t.Errorf("Header: %v", h)
|
||||
}
|
||||
// Öffentlicher Grund URL-kodiert (Header sind einzeilig, Traefik reicht Bytes nicht zuverlässig durch).
|
||||
if h[HeaderPublicReason] != url.QueryEscape("Sicherheitsupdate (CVE-1234) – Sicherheit geht vor.") {
|
||||
t.Errorf("öffentlicher Grund: %v", h[HeaderPublicReason])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_NodeMaintenanceUnlimitedAndExpired(t *testing.T) {
|
||||
in := input(t, wpLabels())
|
||||
in.Maintenance = &Maintenance{} // unbefristet, ohne Grund
|
||||
res, _ := engine().Apply(in)
|
||||
h := headersOf(t, res, "wp")
|
||||
if _, ok := h[HeaderUntil]; ok {
|
||||
t.Error("unbefristete Wartung mit Ende-Header")
|
||||
}
|
||||
if _, ok := h[HeaderPublicReason]; ok {
|
||||
t.Error("Wartung ohne öffentlichen Grund mit Grund-Header")
|
||||
}
|
||||
|
||||
in.Maintenance = &Maintenance{Until: now} // gerade abgelaufen
|
||||
res, _ = engine().Apply(in)
|
||||
if res.Config.HTTP.Routers["kunde__node-1__wp"].Service == InterceptorService {
|
||||
t.Error("abgelaufene Wartung leitet noch um")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_ServiceMaintenance(t *testing.T) {
|
||||
labels := wpLabels()
|
||||
labels["traefik.http.routers.wp.service"] = "wp"
|
||||
labels["traefik.http.routers.shop.rule"] = "Host(`shop.kunde.de`)"
|
||||
labels["traefik.http.routers.shop.service"] = "shop"
|
||||
labels["traefik.http.services.shop.loadbalancer.server.port"] = "8080"
|
||||
in := input(t, labels)
|
||||
in.ServiceMaintenance = map[string]Maintenance{"shop": {PublicReason: "Inventur"}}
|
||||
|
||||
res, _ := engine().Apply(in)
|
||||
if res.Config.HTTP.Routers["kunde__node-1__shop"].Service != InterceptorService {
|
||||
t.Error("Dienst in Wartung nicht umgeleitet")
|
||||
}
|
||||
if res.Config.HTTP.Routers["kunde__node-1__wp"].Service == InterceptorService {
|
||||
t.Error("anderer Dienst wurde umgeleitet")
|
||||
}
|
||||
if h := headersOf(t, res, "shop"); h[HeaderPublicReason] != "Inventur" {
|
||||
t.Errorf("Header: %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
// Vorrang: Sperre/drain > Ablauf/Paywall > Wartung > Mahnhinweis > normal.
|
||||
func TestApply_MaintenancePrecedence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Input)
|
||||
stage Stage // Stufe am Router; "" = keine Routen
|
||||
ackSeen bool
|
||||
}{
|
||||
{"Wartung schlägt Mahnstufe 1", func(in *Input) { in.Tenant.DunningLevel = 1 }, StageMaintenance, false},
|
||||
{"Paywall schlägt Wartung", func(in *Input) { in.Tenant.Status = store.TenantSuspendedPayment }, StagePaywall, false},
|
||||
{"Ablauf schlägt Wartung", func(in *Input) { in.Tenant.Status = store.TenantExpired }, StageLanding, false},
|
||||
{"drain schlägt Wartung", func(in *Input) { in.NodeDrained = true }, "", false},
|
||||
{"Quarantäne schlägt Wartung", func(in *Input) { in.Tenant.Status = store.TenantQuarantined }, "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
in := input(t, wpLabels())
|
||||
in.Maintenance = &Maintenance{}
|
||||
tt.mutate(&in)
|
||||
res, err := engine().Apply(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.stage == "" {
|
||||
if res.Config.HTTP != nil {
|
||||
t.Errorf("erwartet keine Routen: %v", keys(res.Config.HTTP.Routers))
|
||||
}
|
||||
return
|
||||
}
|
||||
if h := headersOf(t, res, "wp"); h[HeaderStage] != string(tt.stage) {
|
||||
t.Errorf("Stufe %v, erwartet %s", h[HeaderStage], tt.stage)
|
||||
}
|
||||
if _, ack := res.Config.HTTP.Routers["sultan__kunde__node-1__wp"]; ack != tt.ackSeen {
|
||||
t.Errorf("Durchlass-Router vorhanden=%v", ack)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ungeplant getrennt (Lease noch gültig): Wartungsseite ohne Ende und ohne Grund.
|
||||
func TestApply_DisconnectedNode(t *testing.T) {
|
||||
in := input(t, wpLabels())
|
||||
in.Disconnected = true
|
||||
res, _ := engine().Apply(in)
|
||||
h := headersOf(t, res, "wp")
|
||||
if h[HeaderStage] != string(StageMaintenance) {
|
||||
t.Errorf("Stufe %v", h[HeaderStage])
|
||||
}
|
||||
if _, ok := h[HeaderUntil]; ok {
|
||||
t.Error("getrennter Knoten mit Ende-Header")
|
||||
}
|
||||
|
||||
// Angemeldete Wartung hat Vorrang (deren Grund/Ende wird gezeigt).
|
||||
in.Maintenance = &Maintenance{PublicReason: "Reboot nach Update"}
|
||||
res, _ = engine().Apply(in)
|
||||
if h := headersOf(t, res, "wp"); h[HeaderPublicReason] != url.QueryEscape("Reboot nach Update") {
|
||||
t.Errorf("Grund der Wartung fehlt: %v", h)
|
||||
}
|
||||
|
||||
// Paywall bleibt Paywall.
|
||||
in.Maintenance = nil
|
||||
in.Tenant.Status = store.TenantSuspendedPayment
|
||||
res, _ = engine().Apply(in)
|
||||
if h := headersOf(t, res, "wp"); h[HeaderStage] != string(StagePaywall) {
|
||||
t.Errorf("Paywall verdrängt: %v", h[HeaderStage])
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"sultan/pkg/license"
|
||||
@@ -57,8 +59,19 @@ type NodeContext struct {
|
||||
Tenant policy.Tenant
|
||||
License license.License
|
||||
Drained bool
|
||||
Maintenance *policy.Maintenance // Wartung des Knotens (nil = keine)
|
||||
// Disconnected: Knoten getrennt, Routen werden (noch) gehalten – sie
|
||||
// zeigen die Wartungsseite.
|
||||
Disconnected bool
|
||||
}
|
||||
|
||||
// Labels, mit denen ein Workload (Container, sultan.d-Dienst) seine Services
|
||||
// in Wartung schickt.
|
||||
const (
|
||||
LabelMaintenance = "sultan.maintenance"
|
||||
LabelMaintenancePublicReason = "sultan.maintenance.public_reason"
|
||||
)
|
||||
|
||||
// Directory liefert den NodeContext; für gelöschte/unbekannte Knoten
|
||||
// ErrUnknownNode (ggf. verpackt).
|
||||
type Directory interface {
|
||||
@@ -207,6 +220,7 @@ func (r *Registry) evaluate(ctx context.Context, nodeID string, state NodeState)
|
||||
workloads := slices.Clone(state.Workloads)
|
||||
slices.SortStableFunc(workloads, func(a, b Workload) int { return cmp.Compare(a.Name, b.Name) })
|
||||
merged := &traefik.Configuration{}
|
||||
serviceMaint := map[string]policy.Maintenance{}
|
||||
seen := map[string]bool{}
|
||||
for _, w := range workloads {
|
||||
if seen[w.Name] {
|
||||
@@ -225,6 +239,11 @@ func (r *Registry) evaluate(ctx context.Context, nodeID string, state NodeState)
|
||||
continue
|
||||
}
|
||||
merged = m
|
||||
if maint, ok := workloadMaintenance(w.Labels); ok && c.HTTP != nil {
|
||||
for svc := range c.HTTP.Services {
|
||||
serviceMaint[svc] = maint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
down := map[string]bool{}
|
||||
@@ -238,6 +257,9 @@ func (r *Registry) evaluate(ctx context.Context, nodeID string, state NodeState)
|
||||
NodeDrained: nc.Drained,
|
||||
Config: merged,
|
||||
DownServices: down,
|
||||
Maintenance: nc.Maintenance,
|
||||
ServiceMaintenance: serviceMaint,
|
||||
Disconnected: nc.Disconnected,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -249,6 +271,22 @@ func (r *Registry) evaluate(ctx context.Context, nodeID string, state NodeState)
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// workloadMaintenance liest sultan.maintenance(=true) und den optionalen
|
||||
// öffentlichen Grund; Schlüssel ohne Beachtung der Groß-/Kleinschreibung.
|
||||
func workloadMaintenance(labels map[string]string) (policy.Maintenance, bool) {
|
||||
var on bool
|
||||
var m policy.Maintenance
|
||||
for k, v := range labels {
|
||||
switch strings.ToLower(k) {
|
||||
case LabelMaintenance:
|
||||
on, _ = strconv.ParseBool(strings.TrimSpace(v))
|
||||
case LabelMaintenancePublicReason:
|
||||
m.PublicReason = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return m, on
|
||||
}
|
||||
|
||||
// store übernimmt entry und meldet nur Verstöße, die vorher nicht bestanden.
|
||||
func (r *Registry) store(nodeID string, entry *nodeEntry) {
|
||||
if r.nodes == nil {
|
||||
|
||||
@@ -398,3 +398,46 @@ func TestRegistry_Concurrent(t *testing.T) {
|
||||
t.Errorf("%d Router, erwartet 20", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Container/Dienst in Wartung per Label: nur dessen Router gehen zum Lander.
|
||||
func TestRegistry_ServiceMaintenanceLabel(t *testing.T) {
|
||||
r, _, _ := newRegistry()
|
||||
shop := wp("shop", "100.64.0.1", "shop.kunde.de")
|
||||
shop.Labels["traefik.http.routers.wp.rule"] = "Host(`shop.kunde.de`)"
|
||||
shop.Labels = map[string]string{
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.shop.rule": "Host(`shop.kunde.de`)",
|
||||
"traefik.http.services.shop.loadbalancer.server.port": "8080",
|
||||
"sultan.maintenance": "true",
|
||||
"sultan.maintenance.public_reason": "Inventur",
|
||||
}
|
||||
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de"), shop}})
|
||||
|
||||
cfg := r.Config().HTTP
|
||||
if cfg.Routers["kunde__n1__shop"].Service != policy.InterceptorService {
|
||||
t.Error("Dienst mit sultan.maintenance=true nicht umgeleitet")
|
||||
}
|
||||
if cfg.Routers["kunde__n1__wp"].Service == policy.InterceptorService {
|
||||
t.Error("anderer Dienst wurde umgeleitet")
|
||||
}
|
||||
hdr := cfg.Middlewares["sultan__kunde__n1__shop"]["headers"].(map[string]any)["customRequestHeaders"].(map[string]any)
|
||||
if hdr[policy.HeaderPublicReason] != "Inventur" {
|
||||
t.Errorf("öffentlicher Grund: %v", hdr)
|
||||
}
|
||||
|
||||
shop.Labels["sultan.maintenance"] = "false"
|
||||
r.Update(ctx, "n1", NodeState{Workloads: []Workload{shop}})
|
||||
if r.Config().HTTP.Routers["kunde__n1__shop"].Service == policy.InterceptorService {
|
||||
t.Error("sultan.maintenance=false leitet um")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_NodeMaintenanceFromDirectory(t *testing.T) {
|
||||
r, dir, _ := newRegistry()
|
||||
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")}})
|
||||
dir.set("n1", func(nc *NodeContext) { nc.Maintenance = &policy.Maintenance{Until: time.Now().Add(time.Hour)} })
|
||||
r.Refresh(ctx)
|
||||
if r.Config().HTTP.Routers["kunde__n1__wp"].Service != policy.InterceptorService {
|
||||
t.Error("Knotenwartung aus dem Directory nicht angewendet")
|
||||
}
|
||||
}
|
||||
+31
-8
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/license"
|
||||
"sultan/pkg/policy"
|
||||
@@ -14,8 +15,8 @@ import (
|
||||
// directory liefert der Registry Mandant, Lizenz und Drain-Status eines
|
||||
// Knotens aus dem Store.
|
||||
//
|
||||
// Solange Migration v2 fehlt (Spec Abschnitt 9), wird die Lizenz aus dem
|
||||
// Mandanten-Datensatz abgeleitet: ohne Features und ohne Mahnstufe.
|
||||
// Die Lizenz wird aus dem Mandanten-Datensatz abgeleitet (inkl. Features);
|
||||
// die Mahnstufe ergibt sich aus der offenen Zahlung (DunningSchedule).
|
||||
type directory struct {
|
||||
store *store.Store
|
||||
}
|
||||
@@ -36,15 +37,37 @@ func (d *directory) NodeContext(ctx context.Context, nodeID string) (registry.No
|
||||
return registry.NodeContext{}, err
|
||||
}
|
||||
return registry.NodeContext{
|
||||
Tenant: policy.Tenant{ID: t.ID, Status: t.Status},
|
||||
License: license.License{
|
||||
Tenant: policy.Tenant{
|
||||
ID: t.ID,
|
||||
Status: t.Status,
|
||||
DunningLevel: policy.DunningSchedule{}.Level(t.PaymentOverdueSince, time.Now()),
|
||||
},
|
||||
License: licenseOf(t),
|
||||
Drained: node.Status == store.NodeDrained,
|
||||
Maintenance: maintenanceOf(node),
|
||||
Disconnected: node.Status == store.NodeOffline,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// licenseOf leitet die (unsignierte) Lizenz aus dem Mandanten-Datensatz ab.
|
||||
func licenseOf(t store.Tenant) license.License {
|
||||
features := make([]license.Feature, 0, len(t.Features))
|
||||
for _, f := range t.Features {
|
||||
features = append(features, license.Feature(f))
|
||||
}
|
||||
return license.License{
|
||||
TenantID: t.ID,
|
||||
Tier: license.Tier(t.Tier),
|
||||
MaxRoutes: t.MaxRoutes,
|
||||
AllowedDomains: t.AllowedDomains,
|
||||
Features: []license.Feature{},
|
||||
Features: features,
|
||||
ValidUntil: t.ValidUntil,
|
||||
},
|
||||
Drained: node.Status == store.NodeDrained,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func maintenanceOf(n store.Node) *policy.Maintenance {
|
||||
if n.Maintenance == nil {
|
||||
return nil
|
||||
}
|
||||
return &policy.Maintenance{Until: n.Maintenance.Until, PublicReason: n.Maintenance.PublicReason}
|
||||
}
|
||||
@@ -3,9 +3,12 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/auth"
|
||||
"sultan/pkg/policy"
|
||||
@@ -28,17 +31,40 @@ type EnrollResponse struct {
|
||||
MasterPublicKey string `json:"master_public_key"` // zum Prüfen von Lizenzen/Leases
|
||||
}
|
||||
|
||||
// MaintenanceRequest ist der Body von POST /api/v1/maintenance: Der Knoten
|
||||
// meldet Wartung an oder ab (z. B. aus einem Patch-/Reboot-Skript).
|
||||
type MaintenanceRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Duration string `json:"duration,omitempty"` // Go-Dauer; "" = 4h, "0" = unbefristet
|
||||
Reason string `json:"reason,omitempty"` // intern
|
||||
PublicReason string `json:"public_reason,omitempty"`
|
||||
}
|
||||
|
||||
// RotateKeyRequest ist der Body von POST /api/v1/rotate-key. Der Request ist
|
||||
// mit dem bisherigen Schlüssel signiert; Proof (auth.RotationProof) beweist
|
||||
// den Besitz des neuen.
|
||||
type RotateKeyRequest struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
Proof string `json:"proof"`
|
||||
}
|
||||
|
||||
// DefaultMaintenance ist die Wartungsdauer ohne Angabe.
|
||||
const DefaultMaintenance = 4 * time.Hour
|
||||
|
||||
func (s *Server) agentRoutes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/v1/enroll", s.handleEnroll)
|
||||
mux.HandleFunc("GET /api/v1/stream", s.handleStream)
|
||||
mux.HandleFunc("POST /api/v1/state", s.handleState)
|
||||
mux.HandleFunc("POST /api/v1/maintenance", s.handleMaintenance)
|
||||
mux.HandleFunc("POST /api/v1/rotate-key", s.handleRotateKey)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) providerRoutes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /v1/traefik-config", s.registry.Handler())
|
||||
mux.HandleFunc("GET /metrics/checkmk", s.handleCheckmk)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Write([]byte("ok\n"))
|
||||
})
|
||||
@@ -46,6 +72,13 @@ func (s *Server) providerRoutes() http.Handler {
|
||||
}
|
||||
|
||||
func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
// Vor der Signaturprüfung: Fluten kosten so kaum etwas.
|
||||
if ok, retry := s.enrollRL.allow(remoteIP(r)); !ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(retry.Seconds()))))
|
||||
s.log.Warn("Enrollment gebremst (zu viele Versuche)", "ip", remoteIP(r))
|
||||
http.Error(w, "zu viele Versuche", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
pub, err := s.verifier.VerifyUnregistered(r)
|
||||
if err != nil {
|
||||
authError(w, err)
|
||||
@@ -144,6 +177,91 @@ func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleMaintenance(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := s.verifier.Verify(r)
|
||||
if err != nil {
|
||||
authError(w, err)
|
||||
return
|
||||
}
|
||||
var req MaintenanceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "ungültiger Body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !req.Enabled {
|
||||
err = s.store.ClearNodeMaintenance(r.Context(), id.Node.ID)
|
||||
} else {
|
||||
d := DefaultMaintenance
|
||||
if req.Duration != "" {
|
||||
if d, err = time.ParseDuration(req.Duration); err != nil || d < 0 {
|
||||
http.Error(w, "duration ungültig (z. B. 30m, 4h; 0 = unbefristet)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
m := store.Maintenance{Reason: req.Reason, PublicReason: req.PublicReason}
|
||||
if d > 0 {
|
||||
m.Until = time.Now().Add(d)
|
||||
}
|
||||
err = s.store.SetNodeMaintenance(r.Context(), id.Node.ID, m)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, store.ErrInvalid):
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
case err != nil:
|
||||
s.log.Error("Wartung setzen", "node", id.Node.ID, "err", err)
|
||||
http.Error(w, "interner Fehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if req.Enabled {
|
||||
s.log.Info("Wartung angemeldet", "node", id.Node.ID, "duration", req.Duration, "reason", req.Reason,
|
||||
"public_reason", req.PublicReason)
|
||||
} else {
|
||||
s.log.Info("Wartung beendet", "node", id.Node.ID)
|
||||
}
|
||||
// Sofort wirksam, nicht erst beim nächsten Refresh.
|
||||
if err := s.registry.Refresh(r.Context()); err != nil {
|
||||
s.log.Error("Neubewertung nach Wartungsänderung", "err", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleRotateKey tauscht den Schlüssel eines Knotens. Idempotent: Ist der
|
||||
// neue Schlüssel schon aktiv (Wiederholung nach einem Abbruch, signiert mit
|
||||
// dem neuen Schlüssel), antwortet er ebenfalls mit 204.
|
||||
func (s *Server) handleRotateKey(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := s.verifier.Verify(r)
|
||||
if err != nil {
|
||||
authError(w, err)
|
||||
return
|
||||
}
|
||||
var req RotateKeyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "ungültiger Body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pub, err := auth.DecodePublicKey(req.PublicKey)
|
||||
if err != nil || !auth.VerifyRotationProof(id.Node.ID, pub, req.Proof) {
|
||||
http.Error(w, "Besitznachweis für den neuen Schlüssel ungültig", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
encoded := auth.EncodePublicKey(pub)
|
||||
if encoded != id.Node.PublicKey {
|
||||
err := s.store.UpdateNodePublicKey(r.Context(), id.Node.ID, encoded)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrExists):
|
||||
http.Error(w, "Schlüssel bereits vergeben", http.StatusConflict)
|
||||
return
|
||||
case err != nil:
|
||||
s.log.Error("Schlüssel tauschen", "node", id.Node.ID, "err", err)
|
||||
http.Error(w, "interner Fehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.log.Info("Knotenschlüssel gewechselt", "node", id.Node.ID)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func authError(w http.ResponseWriter, err error) {
|
||||
code := auth.StatusCode(err)
|
||||
msg := http.StatusText(code)
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/checkmk"
|
||||
"sultan/pkg/notify"
|
||||
"sultan/pkg/policy"
|
||||
"sultan/pkg/store"
|
||||
)
|
||||
|
||||
// retention: Aufbewahrung von Einladungen, abgelaufenen Leases und
|
||||
// zugestellten Befehlen (entspricht der Community-Lease-Laufzeit).
|
||||
const retention = 10 * 24 * time.Hour
|
||||
|
||||
func commercial(t store.Tenant) bool { return t.Tier != store.TierCommunity }
|
||||
|
||||
// runJobs läuft bei jedem Refresh: Vertragsablauf, Benachrichtigungen,
|
||||
// Aufräumen.
|
||||
func (s *Server) runJobs(ctx context.Context) {
|
||||
now := time.Now()
|
||||
tenants, err := s.store.ListTenants(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("Job: Mandanten lesen", "err", err)
|
||||
return
|
||||
}
|
||||
for _, t := range tenants {
|
||||
s.expireContract(ctx, t, now)
|
||||
if s.notifier != nil && commercial(t) {
|
||||
s.notifyContract(ctx, t, now)
|
||||
s.notifyDunning(ctx, t, now)
|
||||
}
|
||||
}
|
||||
if s.notifier != nil {
|
||||
s.notifyOffline(ctx, now)
|
||||
}
|
||||
s.housekeeping(ctx)
|
||||
}
|
||||
|
||||
// expireContract setzt kommerzielle Verträge ab dem Ablaufzeitpunkt dauerhaft
|
||||
// auf expired (Community wird automatisch neu lizenziert).
|
||||
func (s *Server) expireContract(ctx context.Context, t store.Tenant, now time.Time) {
|
||||
if !commercial(t) || t.Status == store.TenantExpired || t.Status == store.TenantQuarantined || now.Before(t.ValidUntil) {
|
||||
return
|
||||
}
|
||||
if err := s.store.SetTenantStatus(ctx, t.ID, store.TenantExpired); err != nil {
|
||||
s.log.Error("Job: Vertrag auf expired setzen", "tenant", t.ID, "err", err)
|
||||
return
|
||||
}
|
||||
s.log.Warn("Vertrag abgelaufen – Mandant auf expired gesetzt", "tenant", t.ID, "valid_until", t.ValidUntil)
|
||||
}
|
||||
|
||||
func (s *Server) notifyContract(ctx context.Context, t store.Tenant, now time.Time) {
|
||||
day := t.ValidUntil.UTC().Format(time.DateOnly)
|
||||
days := int(t.ValidUntil.Sub(now).Hours() / 24)
|
||||
switch {
|
||||
case t.Status == store.TenantExpired || !now.Before(t.ValidUntil):
|
||||
s.sendOnce(ctx, "contract:"+t.ID+":"+day+":0", notify.Message{
|
||||
Severity: notify.Critical,
|
||||
Title: "Vertrag " + t.ID + " abgelaufen",
|
||||
Body: fmt.Sprintf("Der Vertrag von *%s* ist am %s abgelaufen. Die Seiten zeigen die Landingpage.", t.ID, day),
|
||||
})
|
||||
case days <= checkmk.ContractCritDays:
|
||||
s.sendOnce(ctx, "contract:"+t.ID+":"+day+":14", notify.Message{
|
||||
Severity: notify.Critical,
|
||||
Title: fmt.Sprintf("Vertrag %s läuft in %d Tagen ab", t.ID, days),
|
||||
Body: fmt.Sprintf("Vertragsende für *%s*: %s. Verlängerung erforderlich.", t.ID, day),
|
||||
})
|
||||
case days <= checkmk.ContractWarnDays:
|
||||
s.sendOnce(ctx, "contract:"+t.ID+":"+day+":30", notify.Message{
|
||||
Severity: notify.Warning,
|
||||
Title: fmt.Sprintf("Vertrag %s läuft in %d Tagen ab", t.ID, days),
|
||||
Body: fmt.Sprintf("Vertragsende für *%s*: %s.", t.ID, day),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) notifyDunning(ctx context.Context, t store.Tenant, now time.Time) {
|
||||
level := policy.DunningSchedule{}.Level(t.PaymentOverdueSince, now)
|
||||
if level == 0 {
|
||||
return
|
||||
}
|
||||
sev := notify.Warning
|
||||
if level >= 2 {
|
||||
sev = notify.Critical
|
||||
}
|
||||
text := map[int]string{1: "Hinweisseite mit 5 s Weiterleitung", 2: "Hinweisseite mit 10 s Weiterleitung", 3: "Kunde offline (Einladungsseite)"}[min(level, 3)]
|
||||
s.sendOnce(ctx, fmt.Sprintf("dunning:%s:%s:%d", t.ID, t.PaymentOverdueSince.UTC().Format(time.DateOnly), level), notify.Message{
|
||||
Severity: sev,
|
||||
Title: fmt.Sprintf("Mahnstufe %d für %s", level, t.ID),
|
||||
Body: fmt.Sprintf("Zahlung von *%s* seit %s offen. Wirkung: %s.", t.ID, t.PaymentOverdueSince.UTC().Format(time.DateOnly), text),
|
||||
})
|
||||
}
|
||||
|
||||
// notifyOffline alarmiert einmal pro Ausfall, wenn ein Knoten länger als
|
||||
// OfflineAlertAfter getrennt ist – nicht bei angemeldeter Wartung oder drain.
|
||||
func (s *Server) notifyOffline(ctx context.Context, now time.Time) {
|
||||
nodes, err := s.store.ListNodes(ctx, "")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, n := range nodes {
|
||||
if n.Status != store.NodeOffline || n.Maintenance.Active(now) || now.Sub(n.LastSeen) < s.cfg.OfflineAlertAfter {
|
||||
continue
|
||||
}
|
||||
s.sendOnce(ctx, "offline:"+n.ID+":"+n.LastSeen.UTC().Format(time.RFC3339Nano), notify.Message{
|
||||
Severity: notify.Critical,
|
||||
Title: "Knoten " + n.ID + " offline",
|
||||
Body: fmt.Sprintf("Knoten *%s* (Mandant %s, %s) ist seit %s getrennt. Besucher sehen die Wartungsseite, solange die Lease gilt.",
|
||||
n.ID, n.TenantID, n.Hostname, n.LastSeen.Local().Format("2006-01-02 15:04")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// notifyBackOnline meldet „wieder online“ – nur für Ausfälle, zu denen ein
|
||||
// Offline-Alarm verschickt wurde, und je Ausfall genau einmal.
|
||||
func (s *Server) notifyBackOnline(nodeID string) {
|
||||
ctx := context.Background()
|
||||
prefix := "offline:" + nodeID + ":"
|
||||
keys, err := s.store.NotificationKeys(ctx, prefix)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, k := range keys {
|
||||
msg := notify.Message{Severity: notify.Info, Title: "Knoten " + nodeID + " wieder online"}
|
||||
if since, err := time.Parse(time.RFC3339Nano, k[len(prefix):]); err == nil {
|
||||
msg.Body = fmt.Sprintf("Knoten *%s* ist nach %s wieder verbunden.", nodeID, outage(time.Since(since)))
|
||||
} else {
|
||||
msg.Body = fmt.Sprintf("Knoten *%s* ist wieder verbunden.", nodeID)
|
||||
}
|
||||
s.sendOnce(ctx, "recovered:"+k, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func outage(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%d s", int(d/time.Second))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%d min", int(d/time.Minute))
|
||||
default:
|
||||
return fmt.Sprintf("%d h %d min", int(d/time.Hour), int(d%time.Hour/time.Minute))
|
||||
}
|
||||
}
|
||||
|
||||
// sendOnce verschickt eine Nachricht genau einmal je Schlüssel; scheitert
|
||||
// der Versand, wird es beim nächsten Durchlauf erneut versucht.
|
||||
func (s *Server) sendOnce(ctx context.Context, key string, m notify.Message) {
|
||||
if s.store.Notified(ctx, key) {
|
||||
return
|
||||
}
|
||||
if err := s.notifier.Send(ctx, m); err != nil {
|
||||
s.log.Warn("Benachrichtigung nicht zugestellt – nächster Versuch beim nächsten Durchlauf", "key", key, "err", err)
|
||||
return
|
||||
}
|
||||
if err := s.store.RecordNotification(ctx, key); err != nil {
|
||||
s.log.Error("Benachrichtigung vermerken", "key", key, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) housekeeping(ctx context.Context) {
|
||||
for name, purge := range map[string]func(context.Context, time.Duration) (int64, error){
|
||||
"Einladungen": s.store.PurgeEnrollmentTokens,
|
||||
"Leases": s.store.PurgeLeases,
|
||||
"Befehle": s.store.PurgeNodeCommands,
|
||||
} {
|
||||
if n, err := purge(ctx, retention); err != nil {
|
||||
s.log.Error("Aufräumen", "was", name, "err", err)
|
||||
} else if n > 0 {
|
||||
s.log.Info("Aufgeräumt", "was", name, "anzahl", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recordViolation zählt Verstöße für Checkmk (letzte 24 h, im Speicher).
|
||||
func (s *Server) recordViolation(at time.Time) {
|
||||
s.vmu.Lock()
|
||||
defer s.vmu.Unlock()
|
||||
s.violations = append(s.violations, at)
|
||||
}
|
||||
|
||||
func (s *Server) violations24h(now time.Time) int {
|
||||
s.vmu.Lock()
|
||||
defer s.vmu.Unlock()
|
||||
cutoff := now.Add(-24 * time.Hour)
|
||||
i := 0
|
||||
for i < len(s.violations) && s.violations[i].Before(cutoff) {
|
||||
i++
|
||||
}
|
||||
s.violations = s.violations[i:]
|
||||
return len(s.violations)
|
||||
}
|
||||
|
||||
// handleCheckmk liefert den Zustand im Checkmk-Local-Check-Format.
|
||||
func (s *Server) handleCheckmk(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, now := r.Context(), time.Now()
|
||||
state := checkmk.State{Violations24h: s.violations24h(now), OfflineCrit: s.cfg.OfflineAlertAfter, Location: time.Local}
|
||||
tenants, err := s.store.ListTenants(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, "interner Fehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, t := range tenants {
|
||||
state.Tenants = append(state.Tenants, checkmk.Tenant{
|
||||
ID: t.ID, Commercial: commercial(t), Status: string(t.Status), ValidUntil: t.ValidUntil,
|
||||
DunningLevel: policy.DunningSchedule{}.Level(t.PaymentOverdueSince, now), OverdueSince: t.PaymentOverdueSince,
|
||||
})
|
||||
}
|
||||
nodes, err := s.store.ListNodes(ctx, "")
|
||||
if err != nil {
|
||||
http.Error(w, "interner Fehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, n := range nodes {
|
||||
cn := checkmk.Node{ID: n.ID, Status: string(n.Status), OfflineSince: n.LastSeen}
|
||||
if n.Maintenance.Active(now) {
|
||||
cn.MaintenanceActive, cn.MaintenanceUntil, cn.MaintenanceReason = true, n.Maintenance.Until, n.Maintenance.Reason
|
||||
}
|
||||
if l, err := s.store.GetLease(ctx, n.ID); err == nil {
|
||||
cn.LeaseUntil = l.ValidUntil
|
||||
}
|
||||
state.Nodes = append(state.Nodes, cn)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Write([]byte(checkmk.Render(state, now)))
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"sultan/pkg/license"
|
||||
"sultan/pkg/store"
|
||||
"sultan/pkg/stream"
|
||||
)
|
||||
|
||||
// ensureLease stellt die Lease eines verbundenen Knotens aus bzw. erneuert
|
||||
// sie ab renew_after und schickt sie per lease_renew. Mit always wird auch
|
||||
// eine noch gültige Lease erneut zugestellt (beim Verbinden).
|
||||
func (s *Server) ensureLease(nodeID string, always bool) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
current, err := s.store.GetLease(ctx, nodeID)
|
||||
if err == nil && now.Before(current.RenewAfter) {
|
||||
if always {
|
||||
s.sendLease(nodeID, []byte(current.Document))
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
s.log.Error("Lease lesen", "node", nodeID, "err", err)
|
||||
return
|
||||
}
|
||||
node, err := s.store.GetNode(ctx, nodeID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tenant, err := s.store.GetTenant(ctx, node.TenantID)
|
||||
if err != nil || tenant.Status == store.TenantQuarantined {
|
||||
return
|
||||
}
|
||||
lic := licenseOf(tenant)
|
||||
terms, ok := license.TermsFor(lic.Tier, node.Maintenance.Active(now), lic.ValidUntil, now, s.cfg.LeaseDuration, nil)
|
||||
if !ok {
|
||||
s.log.Warn("Keine Lease: Vertrag abgelaufen", "node", nodeID, "tenant", tenant.ID)
|
||||
return
|
||||
}
|
||||
lease, err := (&license.Issuer{Key: s.master}).IssueLease(lic, nodeID, terms, now)
|
||||
if err != nil {
|
||||
s.log.Error("Lease ausstellen", "node", nodeID, "err", err)
|
||||
return
|
||||
}
|
||||
doc, _ := json.Marshal(lease)
|
||||
if err := s.store.PutLease(ctx, store.Lease{
|
||||
NodeID: nodeID, IssuedAt: lease.IssuedAt, RenewAfter: lease.RenewAfter, ValidUntil: lease.ValidUntil, Document: string(doc),
|
||||
}); err != nil {
|
||||
s.log.Error("Lease speichern", "node", nodeID, "err", err)
|
||||
return
|
||||
}
|
||||
s.log.Info("Lease ausgestellt", "node", nodeID, "valid_until", terms.ValidUntil, "renew_after", terms.RenewAfter)
|
||||
s.sendLease(nodeID, doc)
|
||||
}
|
||||
|
||||
func (s *Server) sendLease(nodeID string, doc []byte) {
|
||||
if err := s.hub.Send(nodeID, stream.Event{Type: stream.EventLeaseRenew, Payload: doc}); err != nil {
|
||||
s.log.Debug("Lease nicht zugestellt", "node", nodeID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// leaseValid meldet, ob der Knoten eine noch gültige Lease hat.
|
||||
func (s *Server) leaseValid(nodeID string) bool {
|
||||
l, err := s.store.GetLease(context.Background(), nodeID)
|
||||
return err == nil && time.Now().Before(l.ValidUntil)
|
||||
}
|
||||
|
||||
// dispatchCommands stellt Befehle aus der Warteschlange zu.
|
||||
func (s *Server) dispatchCommands(nodeID string) {
|
||||
ctx := context.Background()
|
||||
cmds, err := s.store.PendingNodeCommands(ctx, nodeID)
|
||||
if err != nil {
|
||||
s.log.Error("Befehle lesen", "node", nodeID, "err", err)
|
||||
return
|
||||
}
|
||||
for _, c := range cmds {
|
||||
payload, _ := json.Marshal(stream.NodeCommand{Command: c.Command, Reason: c.Reason})
|
||||
if err := s.hub.Send(nodeID, stream.Event{Type: stream.EventNodeCommand, Payload: payload}); err != nil {
|
||||
return // später erneut (nächster Refresh oder nächste Verbindung)
|
||||
}
|
||||
if err := s.store.MarkNodeCommandSent(ctx, c.ID); err != nil {
|
||||
s.log.Error("Befehl als gesendet markieren", "node", nodeID, "err", err)
|
||||
return
|
||||
}
|
||||
s.log.Info("Befehl zugestellt", "node", nodeID, "command", c.Command, "reason", c.Reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rateLimiter begrenzt Versuche je Schlüssel (Client-IP) auf limit pro
|
||||
// window (gleitendes Fenster). limit < 0 = unbegrenzt.
|
||||
type rateLimiter struct {
|
||||
limit int
|
||||
window time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
lastPurge time.Time
|
||||
}
|
||||
|
||||
// allow verbucht einen Versuch; bei Ablehnung liefert es die Wartezeit.
|
||||
func (l *rateLimiter) allow(key string) (bool, time.Duration) {
|
||||
if l.limit < 0 {
|
||||
return true, 0
|
||||
}
|
||||
now := time.Now()
|
||||
if l.now != nil {
|
||||
now = l.now()
|
||||
}
|
||||
cutoff := now.Add(-l.window)
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.hits == nil {
|
||||
l.hits = map[string][]time.Time{}
|
||||
}
|
||||
if now.Sub(l.lastPurge) >= l.window { // vergessen, was nicht mehr zählt
|
||||
for k, ts := range l.hits {
|
||||
if !ts[len(ts)-1].After(cutoff) {
|
||||
delete(l.hits, k)
|
||||
}
|
||||
}
|
||||
l.lastPurge = now
|
||||
}
|
||||
ts := l.hits[key]
|
||||
i := 0
|
||||
for i < len(ts) && !ts[i].After(cutoff) {
|
||||
i++
|
||||
}
|
||||
ts = ts[i:]
|
||||
if len(ts) >= l.limit {
|
||||
l.hits[key] = ts
|
||||
return false, ts[0].Add(l.window).Sub(now)
|
||||
}
|
||||
l.hits[key] = append(ts, now)
|
||||
return true, 0
|
||||
}
|
||||
|
||||
func (l *rateLimiter) size() int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.hits)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRateLimiter(t *testing.T) {
|
||||
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
l := &rateLimiter{limit: 3, window: time.Minute, now: func() time.Time { return now }}
|
||||
|
||||
for i := range 3 {
|
||||
if ok, _ := l.allow("1.2.3.4"); !ok {
|
||||
t.Fatalf("Versuch %d abgelehnt", i+1)
|
||||
}
|
||||
}
|
||||
ok, retry := l.allow("1.2.3.4")
|
||||
if ok || retry != time.Minute {
|
||||
t.Errorf("4. Versuch: ok=%v retry=%v, erwartet abgelehnt mit 1 min", ok, retry)
|
||||
}
|
||||
if ok, _ := l.allow("5.6.7.8"); !ok {
|
||||
t.Error("andere IP mitbetroffen")
|
||||
}
|
||||
|
||||
now = now.Add(40 * time.Second)
|
||||
if _, retry := l.allow("1.2.3.4"); retry != 20*time.Second {
|
||||
t.Errorf("Retry-After nach 40 s: %v, erwartet 20 s", retry)
|
||||
}
|
||||
now = now.Add(20 * time.Second)
|
||||
if ok, _ := l.allow("1.2.3.4"); !ok {
|
||||
t.Error("nach Ablauf des Fensters weiter gesperrt")
|
||||
}
|
||||
}
|
||||
|
||||
// Die Zählerliste darf bei vielen IPs nicht unbegrenzt wachsen.
|
||||
func TestRateLimiter_ForgetsOldEntries(t *testing.T) {
|
||||
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
l := &rateLimiter{limit: 1, window: time.Minute, now: func() time.Time { return now }}
|
||||
for i := range 1000 {
|
||||
l.allow(string(rune('a'+i%26)) + time.Duration(i).String())
|
||||
}
|
||||
now = now.Add(2 * time.Minute)
|
||||
l.allow("neu")
|
||||
if n := l.size(); n != 1 {
|
||||
t.Errorf("%d Einträge nach Ablauf, erwartet 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_Disabled(t *testing.T) {
|
||||
l := &rateLimiter{limit: -1, window: time.Minute}
|
||||
for range 100 {
|
||||
if ok, _ := l.allow("x"); !ok {
|
||||
t.Fatal("Limit -1 muss unbegrenzt sein")
|
||||
}
|
||||
}
|
||||
}
|
||||
+224
-10
@@ -5,6 +5,8 @@
|
||||
// Zustandsmeldungen der Agents, jeder Request Ed25519-signiert.
|
||||
// - Provider-Port (Default 127.0.0.1:8080, Klartext, nur lokal): die
|
||||
// Traefik-Konfiguration für Traefiks HTTP-Provider und /healthz.
|
||||
// - Lander-Port (Default 127.0.0.1:8081, nur lokal): Hinweis-,
|
||||
// Wartungs- und Einladungsseiten, zu denen die Policy umleitet.
|
||||
//
|
||||
// Persistenter Zustand liegt im Datenverzeichnis (Default ./suldata):
|
||||
// sultan.db, server.key/server.crt (TLS) und master.key (Lizenzen).
|
||||
@@ -13,6 +15,7 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -25,6 +28,8 @@ import (
|
||||
|
||||
"sultan/pkg/auth"
|
||||
"sultan/pkg/crypto"
|
||||
"sultan/pkg/lander"
|
||||
"sultan/pkg/notify"
|
||||
"sultan/pkg/policy"
|
||||
"sultan/pkg/registry"
|
||||
"sultan/pkg/store"
|
||||
@@ -36,9 +41,32 @@ type Config struct {
|
||||
DataDir string // Default ./suldata
|
||||
AgentAddr string // Default :8443
|
||||
ProviderAddr string // Default 127.0.0.1:8080 – Klartext, nicht öffentlich binden
|
||||
InterceptorURL string // Default http://127.0.0.1:8081
|
||||
RefreshInterval time.Duration // Default 1m: Neubewertung aller Knoten
|
||||
// PublicURLs: Adressen, unter denen Agents den Server erreichen (z. B.
|
||||
// öffentlich und NetBird). Werden gemerkt; die erste steckt in
|
||||
// Einladungen, die ganze Liste bekommen Agents per config-Befehl.
|
||||
PublicURLs []string
|
||||
LanderAddr string // Default 127.0.0.1:8081; "none" = kein eingebauter Lander
|
||||
InterceptorURL string // Default: Adresse des eingebauten Landers
|
||||
HosterName string // Lander: Name des Hosting-Anbieters
|
||||
HosterURL string // Lander: Angebotsseite
|
||||
ProURL string // Lander: Pro-Hinweis auf Community-Wartungsseiten
|
||||
RefreshInterval time.Duration // Default 1m: Neubewertung aller Knoten, Lease-Erneuerung, Befehle
|
||||
LeaseDuration time.Duration // 0 = Laufzeiten je Tier (Spec 4.1a); > 0 feste Laufzeit (Tests, Betrieb)
|
||||
Logger *slog.Logger // Default: Text auf stderr
|
||||
|
||||
// Benachrichtigungen: Slack-kompatible Webhooks und ntfy-Topics (je
|
||||
// mehrfach); Notify ersetzt beides (Tests, eigene Ziele).
|
||||
SlackWebhooks []string
|
||||
NtfyURLs []string
|
||||
NtfyToken string
|
||||
Notify notify.Target
|
||||
OfflineAlertAfter time.Duration // Default 15m: Alarm für getrennte Knoten
|
||||
|
||||
// EnrollLimit: Enrollment-Versuche pro Minute und Client-IP (Default 10,
|
||||
// -1 = unbegrenzt) – gegen das Durchprobieren von Einladungen.
|
||||
EnrollLimit int
|
||||
|
||||
interceptorDerived bool // InterceptorURL folgt der gebundenen Lander-Adresse
|
||||
}
|
||||
|
||||
func (c Config) withDefaults() Config {
|
||||
@@ -51,18 +79,55 @@ func (c Config) withDefaults() Config {
|
||||
if c.ProviderAddr == "" {
|
||||
c.ProviderAddr = "127.0.0.1:8080"
|
||||
}
|
||||
if c.LanderAddr == "" {
|
||||
c.LanderAddr = "127.0.0.1:8081"
|
||||
}
|
||||
if c.InterceptorURL == "" {
|
||||
c.InterceptorURL = "http://127.0.0.1:8081"
|
||||
if c.LanderAddr != "none" {
|
||||
c.InterceptorURL = "http://" + loopbackAddr(c.LanderAddr)
|
||||
c.interceptorDerived = true
|
||||
}
|
||||
}
|
||||
if c.RefreshInterval <= 0 {
|
||||
c.RefreshInterval = time.Minute
|
||||
}
|
||||
if c.OfflineAlertAfter <= 0 {
|
||||
c.OfflineAlertAfter = 15 * time.Minute
|
||||
}
|
||||
if c.EnrollLimit == 0 {
|
||||
c.EnrollLimit = 10
|
||||
}
|
||||
if c.Logger == nil {
|
||||
c.Logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// SettingPublicURLs ist der Store-Schlüssel der gemerkten Adressen (JSON-Liste).
|
||||
const SettingPublicURLs = "public_urls"
|
||||
|
||||
// LoadPublicURLs liest die gemerkten Adressen (leer, wenn keine gesetzt sind).
|
||||
func LoadPublicURLs(ctx context.Context, st *store.Store) ([]string, error) {
|
||||
raw, err := st.Setting(ctx, SettingPublicURLs)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var urls []string
|
||||
if err := json.Unmarshal([]byte(raw), &urls); err != nil {
|
||||
return nil, fmt.Errorf("server: %s beschädigt: %w", SettingPublicURLs, err)
|
||||
}
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
// PublicURLs liefert die gemerkten Adressen.
|
||||
func (s *Server) PublicURLs(ctx context.Context) ([]string, error) {
|
||||
return LoadPublicURLs(ctx, s.store)
|
||||
}
|
||||
|
||||
// Server ist ein laufender bzw. startbereiter Sultan-Server.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
@@ -73,12 +138,20 @@ type Server struct {
|
||||
hub *stream.Hub
|
||||
verifier *auth.Verifier
|
||||
registry *registry.Registry
|
||||
lander *lander.Lander
|
||||
notifier notify.Target // nil = keine Benachrichtigungen
|
||||
enrollRL *rateLimiter
|
||||
|
||||
vmu sync.Mutex
|
||||
violations []time.Time // Zeitpunkte der Policy-Verstöße (für Checkmk)
|
||||
|
||||
mu sync.Mutex
|
||||
agentAddr string
|
||||
providerAddr string
|
||||
landerAddr string
|
||||
agentSrv *http.Server
|
||||
providerSrv *http.Server
|
||||
landerSrv *http.Server
|
||||
serveErr chan error
|
||||
stopRefresh chan struct{}
|
||||
shutdownOnce sync.Once
|
||||
@@ -89,6 +162,13 @@ type Server struct {
|
||||
// TLS-Zertifikat und Master-Key. Lauschen beginnt erst mit Start.
|
||||
func New(ctx context.Context, cfg Config) (*Server, error) {
|
||||
cfg = cfg.withDefaults()
|
||||
for i, raw := range cfg.PublicURLs {
|
||||
u, err := crypto.ValidateServerURL(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("server: --public-url: %w", err)
|
||||
}
|
||||
cfg.PublicURLs[i] = u
|
||||
}
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("server: Datenverzeichnis: %w", err)
|
||||
}
|
||||
@@ -96,6 +176,13 @@ func New(ctx context.Context, cfg Config) (*Server, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cfg.PublicURLs) > 0 {
|
||||
raw, _ := json.Marshal(cfg.PublicURLs)
|
||||
if err := st.SetSetting(ctx, SettingPublicURLs, string(raw)); err != nil {
|
||||
st.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
cert, err := crypto.LoadOrCreateServerCert(cfg.DataDir)
|
||||
if err != nil {
|
||||
st.Close()
|
||||
@@ -114,10 +201,31 @@ func New(ctx context.Context, cfg Config) (*Server, error) {
|
||||
cert: cert,
|
||||
master: master,
|
||||
verifier: &auth.Verifier{Directory: st},
|
||||
serveErr: make(chan error, 2),
|
||||
serveErr: make(chan error, 3),
|
||||
stopRefresh: make(chan struct{}),
|
||||
}
|
||||
s.hub = &stream.Hub{OnConnect: s.nodeOnline, OnDisconnect: s.nodeOffline}
|
||||
s.hub = &stream.Hub{OnConnect: s.nodeOnline, OnDisconnect: s.nodeOffline, SigningKey: master}
|
||||
s.enrollRL = &rateLimiter{limit: cfg.EnrollLimit, window: time.Minute}
|
||||
s.notifier = cfg.Notify
|
||||
if s.notifier == nil {
|
||||
var targets notify.Multi
|
||||
for _, u := range cfg.SlackWebhooks {
|
||||
targets = append(targets, notify.Slack{URL: u})
|
||||
}
|
||||
for _, u := range cfg.NtfyURLs {
|
||||
targets = append(targets, notify.Ntfy{URL: u, Token: cfg.NtfyToken})
|
||||
}
|
||||
if len(targets) > 0 {
|
||||
s.notifier = targets
|
||||
}
|
||||
}
|
||||
s.lander = &lander.Lander{
|
||||
Dir: filepath.Join(cfg.DataDir, "landing"),
|
||||
HosterName: cfg.HosterName,
|
||||
HosterURL: cfg.HosterURL,
|
||||
ProURL: cfg.ProURL,
|
||||
Logger: cfg.Logger,
|
||||
}
|
||||
s.registry = ®istry.Registry{
|
||||
Directory: &directory{store: st},
|
||||
Policy: &policy.Engine{InterceptorURL: cfg.InterceptorURL},
|
||||
@@ -157,6 +265,22 @@ func (s *Server) AgentAddr() string { s.mu.Lock(); defer s.mu.Unlock(); return s
|
||||
// ProviderAddr ist die tatsächlich gebundene Adresse des Provider-Ports.
|
||||
func (s *Server) ProviderAddr() string { s.mu.Lock(); defer s.mu.Unlock(); return s.providerAddr }
|
||||
|
||||
// LanderAddr ist die gebundene Adresse des Landers ("" wenn abgeschaltet).
|
||||
func (s *Server) LanderAddr() string { s.mu.Lock(); defer s.mu.Unlock(); return s.landerAddr }
|
||||
|
||||
// loopbackAddr macht aus ":8081" bzw. "0.0.0.0:8081" eine lokal erreichbare
|
||||
// Adresse (127.0.0.1:8081) für die Interceptor-URL.
|
||||
func loopbackAddr(addr string) string {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return addr
|
||||
}
|
||||
if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
// Start bindet beide Ports und beginnt zu bedienen; er kehrt sofort zurück.
|
||||
func (s *Server) Start() error {
|
||||
agentLn, err := net.Listen("tcp", s.cfg.AgentAddr)
|
||||
@@ -168,6 +292,18 @@ func (s *Server) Start() error {
|
||||
agentLn.Close()
|
||||
return fmt.Errorf("server: Provider-Port %s: %w", s.cfg.ProviderAddr, err)
|
||||
}
|
||||
var landerLn net.Listener
|
||||
if s.cfg.LanderAddr != "none" {
|
||||
if landerLn, err = net.Listen("tcp", s.cfg.LanderAddr); err != nil {
|
||||
agentLn.Close()
|
||||
providerLn.Close()
|
||||
return fmt.Errorf("server: Lander-Port %s: %w", s.cfg.LanderAddr, err)
|
||||
}
|
||||
if s.cfg.interceptorDerived {
|
||||
// Noch läuft nichts nebenläufig: die Policy darf angepasst werden.
|
||||
s.registry.Policy.InterceptorURL = "http://" + loopbackAddr(landerLn.Addr().String())
|
||||
}
|
||||
}
|
||||
|
||||
agentSrv := &http.Server{
|
||||
Handler: s.agentRoutes(),
|
||||
@@ -183,14 +319,22 @@ func (s *Server) Start() error {
|
||||
s.mu.Lock()
|
||||
s.agentAddr, s.providerAddr = agentLn.Addr().String(), providerLn.Addr().String()
|
||||
s.agentSrv, s.providerSrv = agentSrv, providerSrv
|
||||
if landerLn != nil {
|
||||
s.landerAddr = landerLn.Addr().String()
|
||||
s.landerSrv = &http.Server{Handler: s.lander, ReadHeaderTimeout: 10 * time.Second}
|
||||
}
|
||||
landerSrv := s.landerSrv
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.serve(func() error { return agentSrv.ServeTLS(agentLn, "", "") })
|
||||
go s.serve(func() error { return providerSrv.Serve(providerLn) })
|
||||
if landerSrv != nil {
|
||||
go s.serve(func() error { return landerSrv.Serve(landerLn) })
|
||||
}
|
||||
go s.refreshLoop()
|
||||
|
||||
s.log.Info("Sultan-Server gestartet",
|
||||
"agent", s.AgentAddr(), "provider", s.ProviderAddr(),
|
||||
"agent", s.AgentAddr(), "provider", s.ProviderAddr(), "lander", s.LanderAddr(),
|
||||
"Fingerprint", string(s.cert.Fingerprint), "data_dir", s.cfg.DataDir)
|
||||
return nil
|
||||
}
|
||||
@@ -209,9 +353,15 @@ func (s *Server) refreshLoop() {
|
||||
case <-s.stopRefresh:
|
||||
return
|
||||
case <-t.C:
|
||||
s.pruneDisconnected()
|
||||
for _, id := range s.hub.Nodes() {
|
||||
s.ensureLease(id, false)
|
||||
s.dispatchCommands(id)
|
||||
}
|
||||
if err := s.registry.Refresh(context.Background()); err != nil {
|
||||
s.log.Error("Neubewertung fehlgeschlagen", "err", err)
|
||||
}
|
||||
s.runJobs(context.Background())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,10 +391,10 @@ func (s *Server) Shutdown(ctx context.Context) error {
|
||||
close(s.stopRefresh)
|
||||
s.hub.Close()
|
||||
s.mu.Lock()
|
||||
agentSrv, providerSrv := s.agentSrv, s.providerSrv
|
||||
agentSrv, providerSrv, landerSrv := s.agentSrv, s.providerSrv, s.landerSrv
|
||||
s.mu.Unlock()
|
||||
var errs []error
|
||||
for _, srv := range []*http.Server{agentSrv, providerSrv} {
|
||||
for _, srv := range []*http.Server{agentSrv, providerSrv, landerSrv} {
|
||||
if srv != nil {
|
||||
errs = append(errs, srv.Shutdown(ctx))
|
||||
}
|
||||
@@ -257,16 +407,62 @@ func (s *Server) Shutdown(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// nodeOnline/nodeOffline halten nodes.status aktuell; ein gedrainter
|
||||
// Knoten bleibt gedraint. Beim Trennen verschwinden seine Routen.
|
||||
// Knoten bleibt gedraint. Beim Trennen bleiben die Routen als Wartungsseite
|
||||
// stehen, solange eine Wartung angemeldet ist oder die Lease gilt; erst
|
||||
// danach (pruneDisconnected) verschwinden sie. drain entfernt sie sofort.
|
||||
func (s *Server) nodeOnline(nodeID string) {
|
||||
s.setNodeStatus(nodeID, store.NodeOnline)
|
||||
s.log.Info("Knoten verbunden", "node", nodeID)
|
||||
s.ensureLease(nodeID, true)
|
||||
s.dispatchCommands(nodeID)
|
||||
s.sendServerURLs(nodeID)
|
||||
if s.notifier != nil {
|
||||
go s.notifyBackOnline(nodeID) // nicht den Stream-Aufbau aufhalten
|
||||
}
|
||||
}
|
||||
|
||||
// sendServerURLs schickt dem Agenten die Adressliste (Ausweichweg).
|
||||
func (s *Server) sendServerURLs(nodeID string) {
|
||||
urls, err := s.PublicURLs(context.Background())
|
||||
if err != nil || len(urls) == 0 {
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(stream.NodeCommand{Command: stream.CmdConfig, ServerURLs: urls})
|
||||
if err := s.hub.Send(nodeID, stream.Event{Type: stream.EventNodeCommand, Payload: payload}); err != nil {
|
||||
s.log.Debug("Adressliste nicht zugestellt", "node", nodeID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) nodeOffline(nodeID string) {
|
||||
s.registry.Remove(nodeID)
|
||||
s.setNodeStatus(nodeID, store.NodeOffline)
|
||||
s.log.Info("Knoten getrennt", "node", nodeID)
|
||||
if err := s.store.MarkNodeSeen(context.Background(), nodeID); err != nil {
|
||||
s.log.Debug("offline-seit setzen", "node", nodeID, "err", err)
|
||||
}
|
||||
if s.inMaintenance(nodeID) || s.leaseValid(nodeID) {
|
||||
s.log.Info("Knoten getrennt – Routen bleiben als Wartungsseite (Wartung bzw. Lease)", "node", nodeID)
|
||||
if err := s.registry.Refresh(context.Background()); err != nil {
|
||||
s.log.Error("Neubewertung nach Trennung", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
s.registry.Remove(nodeID)
|
||||
s.log.Info("Knoten getrennt – Routen entfernt", "node", nodeID)
|
||||
}
|
||||
|
||||
func (s *Server) inMaintenance(nodeID string) bool {
|
||||
n, err := s.store.GetNode(context.Background(), nodeID)
|
||||
return err == nil && n.Maintenance.Active(time.Now())
|
||||
}
|
||||
|
||||
// pruneDisconnected entfernt Routen getrennter Knoten, deren Wartung und
|
||||
// Lease inzwischen abgelaufen sind.
|
||||
func (s *Server) pruneDisconnected() {
|
||||
for _, id := range s.registry.Nodes() {
|
||||
if !s.hub.Connected(id) && !s.inMaintenance(id) && !s.leaseValid(id) {
|
||||
s.registry.Remove(id)
|
||||
s.log.Info("Getrennter Knoten: Wartung und Lease abgelaufen – Routen entfernt", "node", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) setNodeStatus(nodeID string, status store.NodeStatus) {
|
||||
@@ -284,4 +480,22 @@ func (s *Server) logViolation(v registry.Violation) {
|
||||
s.log.Warn("Policy-Verstoß",
|
||||
"node", v.NodeID, "kind", string(v.Kind), "router", v.Router,
|
||||
"workload", v.Workload, "detail", v.Detail, "observed", v.Observed)
|
||||
s.recordViolation(time.Now())
|
||||
if s.notifier == nil {
|
||||
return
|
||||
}
|
||||
// Asynchron: OnViolation läuft unter der Sperre der Registry. Die
|
||||
// Registry meldet jeden Verstoß nur beim ersten Auftreten.
|
||||
msg := notify.Message{
|
||||
Severity: notify.Warning,
|
||||
Title: "Policy-Verstoß auf Knoten " + v.NodeID,
|
||||
Body: fmt.Sprintf("Knoten *%s*, Router `%s`, Art `%s`: %s", v.NodeID, v.Router, v.Kind, v.Detail),
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := s.notifier.Send(ctx, msg); err != nil {
|
||||
s.log.Warn("Benachrichtigung über Policy-Verstoß nicht zugestellt", "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
+725
-9
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -17,6 +19,8 @@ import (
|
||||
|
||||
"sultan/pkg/auth"
|
||||
"sultan/pkg/crypto"
|
||||
"sultan/pkg/license"
|
||||
"sultan/pkg/notify"
|
||||
"sultan/pkg/policy"
|
||||
"sultan/pkg/registry"
|
||||
"sultan/pkg/store"
|
||||
@@ -58,6 +62,7 @@ func startServer(t *testing.T, mutate ...func(*Config)) (*Server, *syncBuffer) {
|
||||
DataDir: filepath.Join(t.TempDir(), "suldata"),
|
||||
AgentAddr: "127.0.0.1:0",
|
||||
ProviderAddr: "127.0.0.1:0",
|
||||
LanderAddr: "127.0.0.1:0",
|
||||
RefreshInterval: time.Hour,
|
||||
Logger: slog.New(slog.NewTextHandler(logs, nil)),
|
||||
}
|
||||
@@ -92,7 +97,7 @@ func createTenant(t *testing.T, s *Server, id string, domains ...string) {
|
||||
// issueToken erzeugt einen Enrollment-Token wie `sultan token` es tun wird.
|
||||
func issueToken(t *testing.T, s *Server, tenant string) string {
|
||||
t.Helper()
|
||||
tok, err := crypto.NewEnrollmentToken(s.Fingerprint())
|
||||
tok, err := crypto.NewEnrollmentToken(s.Fingerprint(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -116,11 +121,10 @@ func newAgent(t *testing.T, s *Server, token string) *testAgent {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ks := &crypto.KnownServers{Path: filepath.Join(t.TempDir(), "known_servers")}
|
||||
priv, _ := auth.GenerateKey()
|
||||
return &testAgent{
|
||||
base: "https://" + s.AgentAddr(),
|
||||
hc: &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.ClientTLSConfig(s.AgentAddr(), ks, tok.Fingerprint)}},
|
||||
hc: &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(tok.Fingerprint)}},
|
||||
signer: &auth.Signer{Key: priv},
|
||||
secret: tok.Secret,
|
||||
}
|
||||
@@ -151,13 +155,46 @@ func (a *testAgent) enroll(t *testing.T, nodeID string) *http.Response {
|
||||
|
||||
// connect hält den Stream im Hintergrund offen, bis cancel aufgerufen wird.
|
||||
func (a *testAgent) connect(t *testing.T) (cancel func()) {
|
||||
t.Helper()
|
||||
cancel, _ = a.connectEvents(t)
|
||||
return cancel
|
||||
}
|
||||
|
||||
// connectEvents hält den Stream offen und liefert die empfangenen Events.
|
||||
func (a *testAgent) connectEvents(t *testing.T) (cancel func(), events <-chan stream.Event) {
|
||||
t.Helper()
|
||||
sctx, cancel := context.WithCancel(ctx)
|
||||
ch := make(chan stream.Event, 64)
|
||||
c := &stream.Client{URL: a.base + "/api/v1/stream", HTTPClient: a.hc, Prepare: a.signer.Sign, MinBackoff: 10 * time.Millisecond}
|
||||
done := make(chan struct{})
|
||||
go func() { c.Run(sctx, func(stream.Event) error { return nil }); close(done) }()
|
||||
go func() {
|
||||
c.Run(sctx, func(ev stream.Event) error {
|
||||
select {
|
||||
case ch <- ev:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
t.Cleanup(func() { cancel(); <-done })
|
||||
return cancel
|
||||
return cancel, ch
|
||||
}
|
||||
|
||||
// nextEvent wartet auf das nächste Event des Typs typ.
|
||||
func nextEvent(t *testing.T, events <-chan stream.Event, typ stream.EventType) stream.Event {
|
||||
t.Helper()
|
||||
deadline := time.After(3 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case ev := <-events:
|
||||
if ev.Type == typ {
|
||||
return ev
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatalf("kein Event %s empfangen", typ)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func wpState(domain string) registry.NodeState {
|
||||
@@ -187,7 +224,7 @@ func TestConfigDefaults(t *testing.T) {
|
||||
if c.DataDir != "./suldata" || c.AgentAddr != ":8443" || c.ProviderAddr != "127.0.0.1:8080" {
|
||||
t.Errorf("Defaults: %+v", c)
|
||||
}
|
||||
if c.InterceptorURL != "http://127.0.0.1:8081" || c.RefreshInterval != time.Minute || c.Logger == nil {
|
||||
if c.InterceptorURL != "http://127.0.0.1:8081" || c.LanderAddr != "127.0.0.1:8081" || c.RefreshInterval != time.Minute || c.Logger == nil {
|
||||
t.Errorf("Defaults: %+v", c)
|
||||
}
|
||||
}
|
||||
@@ -299,8 +336,11 @@ func TestEndToEnd(t *testing.T) {
|
||||
t.Errorf("Traefik-Konfiguration: %s", cfg)
|
||||
}
|
||||
|
||||
// Ungeplant getrennt: Routen bleiben als Wartungsseite, solange die Lease gilt.
|
||||
stop()
|
||||
eventually(t, "Route nach Trennung entfernt", func() bool { return providerConfig(t, s) == "{}" })
|
||||
eventually(t, "Wartungsseite nach Trennung", func() bool {
|
||||
return strings.Contains(providerConfig(t, s), policy.InterceptorService)
|
||||
})
|
||||
eventually(t, "Knoten offline", func() bool {
|
||||
n, _ := s.store.GetNode(ctx, "node-1")
|
||||
return n.Status == store.NodeOffline
|
||||
@@ -442,7 +482,7 @@ func TestDirectory(t *testing.T) {
|
||||
func TestRun_ShutdownWithOpenStream(t *testing.T) {
|
||||
cfg := Config{
|
||||
DataDir: filepath.Join(t.TempDir(), "suldata"), AgentAddr: "127.0.0.1:0", ProviderAddr: "127.0.0.1:0",
|
||||
Logger: slog.New(slog.DiscardHandler),
|
||||
LanderAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler),
|
||||
}
|
||||
s, err := New(ctx, cfg)
|
||||
if err != nil {
|
||||
@@ -474,7 +514,7 @@ func TestStart_PortInUse(t *testing.T) {
|
||||
a, _ := startServer(t)
|
||||
b, err := New(ctx, Config{
|
||||
DataDir: filepath.Join(t.TempDir(), "suldata"), AgentAddr: a.AgentAddr(), ProviderAddr: "127.0.0.1:0",
|
||||
Logger: slog.New(slog.DiscardHandler),
|
||||
LanderAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -484,3 +524,679 @@ func TestStart_PortInUse(t *testing.T) {
|
||||
t.Error("belegter Port: erwartet Fehler")
|
||||
}
|
||||
}
|
||||
|
||||
// Wie Traefik: Konfiguration lesen, Router zum Interceptor finden, dessen
|
||||
// Header-Middleware anwenden und den Lander aufrufen.
|
||||
func TestLander_EndToEndMaintenance(t *testing.T) {
|
||||
s, _ := startServer(t, func(c *Config) { c.ProURL = "https://hoster.example/pro" })
|
||||
if s.LanderAddr() == "" || strings.HasSuffix(s.LanderAddr(), ":0") {
|
||||
t.Fatalf("Lander-Adresse: %q", s.LanderAddr())
|
||||
}
|
||||
createTenant(t, s, "kunde", "kunde.de")
|
||||
agent := newAgent(t, s, issueToken(t, s, "kunde"))
|
||||
agent.enroll(t, "n1")
|
||||
agent.connect(t)
|
||||
eventually(t, "Stream", func() bool { return s.hub.Connected("n1") })
|
||||
st := wpState("kunde.de")
|
||||
st.DownServices = []string{"wp"}
|
||||
if r := agent.do(t, http.MethodPost, "/api/v1/state", st); r.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("State: %d", r.StatusCode)
|
||||
}
|
||||
|
||||
var cfg struct {
|
||||
HTTP struct {
|
||||
Routers map[string]struct {
|
||||
Service string
|
||||
Middlewares []string
|
||||
}
|
||||
Services map[string]struct {
|
||||
LoadBalancer struct{ Servers []struct{ URL string } }
|
||||
}
|
||||
Middlewares map[string]struct {
|
||||
Headers struct{ CustomRequestHeaders map[string]string }
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal([]byte(providerConfig(t, s)), &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := cfg.HTTP.Routers["kunde__n1__wp"]
|
||||
interceptor := cfg.HTTP.Services[router.Service].LoadBalancer.Servers[0].URL
|
||||
if interceptor != "http://"+s.LanderAddr() {
|
||||
t.Fatalf("Interceptor-URL %q, erwartet den gebundenen Lander %q", interceptor, s.LanderAddr())
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, interceptor+"/shop", nil)
|
||||
for _, mw := range router.Middlewares {
|
||||
for k, v := range cfg.HTTP.Middlewares[mw].Headers.CustomRequestHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusServiceUnavailable || resp.Header.Get("Retry-After") != "60" ||
|
||||
!strings.Contains(string(body), "Gleich wieder da") {
|
||||
t.Errorf("Lander: %d %q\n%s", resp.StatusCode, resp.Header.Get("Retry-After"), body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLander_CustomTemplatesFromDataDir(t *testing.T) {
|
||||
s, _ := startServer(t)
|
||||
dir := filepath.Join(s.cfg.DataDir, "landing")
|
||||
os.MkdirAll(dir, 0o755)
|
||||
os.WriteFile(filepath.Join(dir, "maintenance.html"), []byte("Eigene Seite {{.Tenant}}"), 0o644)
|
||||
req, _ := http.NewRequest(http.MethodGet, "http://"+s.LanderAddr()+"/", nil)
|
||||
req.Header.Set("X-Sultan-Stage", "maintenance")
|
||||
req.Header.Set("X-Sultan-Tenant", "kunde")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if body, _ := io.ReadAll(resp.Body); string(body) != "Eigene Seite kunde" {
|
||||
t.Errorf("body = %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLander_Disabled(t *testing.T) {
|
||||
s, _ := startServer(t, func(c *Config) { c.LanderAddr = "none"; c.InterceptorURL = "http://lander.intern:8081" })
|
||||
if s.LanderAddr() != "" {
|
||||
t.Errorf("Lander läuft trotz none: %q", s.LanderAddr())
|
||||
}
|
||||
if s.registry.Policy.InterceptorURL != "http://lander.intern:8081" {
|
||||
t.Errorf("explizite Interceptor-URL überschrieben: %q", s.registry.Policy.InterceptorURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Die öffentliche Adresse wird im Store gemerkt, damit `sultan token invite`
|
||||
// (eigener Prozess) sie in Einladungen einbetten kann.
|
||||
func TestPublicURLs(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "suldata")
|
||||
discard := slog.New(slog.DiscardHandler)
|
||||
if _, err := New(ctx, Config{DataDir: dir, PublicURLs: []string{"https://ok.example:8443", "http://ingress:8443"}, Logger: discard}); err == nil {
|
||||
t.Error("http-URL akzeptiert")
|
||||
}
|
||||
s, err := New(ctx, Config{DataDir: dir, PublicURLs: []string{"https://Ingress.example:8443/", "https://100.64.0.1:8443"}, Logger: discard})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"https://ingress.example:8443", "https://100.64.0.1:8443"}
|
||||
if got, err := s.PublicURLs(ctx); err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("gespeichert: %v %v", got, err)
|
||||
}
|
||||
s.Shutdown(ctx)
|
||||
|
||||
// Neustart ohne --public-url: gemerkte Adressen bleiben.
|
||||
s, err = New(ctx, Config{DataDir: dir, Logger: discard})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Shutdown(ctx)
|
||||
if got, _ := s.PublicURLs(ctx); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("nach Neustart: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Beim Verbinden bekommt der Agent die vollständige Adressliste (signiert).
|
||||
func TestConfigCommandOnConnect(t *testing.T) {
|
||||
urls := []string{"https://ingress.example:8443", "https://100.64.0.1:8443"}
|
||||
s, _ := startServer(t, func(c *Config) { c.PublicURLs = urls })
|
||||
createTenant(t, s, "kunde", "kunde.de")
|
||||
agent := newAgent(t, s, issueToken(t, s, "kunde"))
|
||||
agent.enroll(t, "n1")
|
||||
_, events := agent.connectEvents(t)
|
||||
|
||||
ev := nextEvent(t, events, stream.EventNodeCommand)
|
||||
if err := stream.VerifyEvent(ev, s.MasterPublicKey()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd, err := stream.ParseNodeCommand(ev)
|
||||
if err != nil || cmd.Command != stream.CmdConfig || !reflect.DeepEqual(cmd.ServerURLs, urls) {
|
||||
t.Errorf("config-Befehl: %+v %v", cmd, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoConfigCommandWithoutPublicURLs(t *testing.T) {
|
||||
s, _ := startServer(t)
|
||||
createTenant(t, s, "kunde", "kunde.de")
|
||||
agent := newAgent(t, s, issueToken(t, s, "kunde"))
|
||||
agent.enroll(t, "n1")
|
||||
_, events := agent.connectEvents(t)
|
||||
nextEvent(t, events, stream.EventLeaseRenew)
|
||||
select {
|
||||
case ev := <-events:
|
||||
if ev.Type == stream.EventNodeCommand {
|
||||
t.Errorf("config ohne Adressen gesendet: %s", ev.Payload)
|
||||
}
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// connectedAgent: enrollt, verbindet und meldet wpState(kunde.de).
|
||||
func connectedAgent(t *testing.T, s *Server, node string) (*testAgent, func()) {
|
||||
t.Helper()
|
||||
if _, err := s.store.GetTenant(ctx, "kunde"); err != nil {
|
||||
createTenant(t, s, "kunde", "kunde.de")
|
||||
}
|
||||
agent := newAgent(t, s, issueToken(t, s, "kunde"))
|
||||
if r := agent.enroll(t, node); r.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("Enrollment: %d", r.StatusCode)
|
||||
}
|
||||
stop := agent.connect(t)
|
||||
eventually(t, "Stream", func() bool { return s.hub.Connected(node) })
|
||||
if r := agent.do(t, http.MethodPost, "/api/v1/state", wpState("kunde.de")); r.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("State: %d", r.StatusCode)
|
||||
}
|
||||
return agent, stop
|
||||
}
|
||||
|
||||
func TestMaintenance_AgentEndpoint(t *testing.T) {
|
||||
s, logs := startServer(t)
|
||||
agent, _ := connectedAgent(t, s, "n1")
|
||||
|
||||
resp := agent.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{
|
||||
Enabled: true, Duration: "2h", Reason: "Kernel 6.12", PublicReason: "Sicherheitsupdate",
|
||||
})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("Wartung an: %d %s", resp.StatusCode, b)
|
||||
}
|
||||
n, _ := s.store.GetNode(ctx, "n1")
|
||||
if m := n.Maintenance; m == nil || m.Reason != "Kernel 6.12" || m.PublicReason != "Sicherheitsupdate" ||
|
||||
time.Until(m.Until) < 119*time.Minute || time.Until(m.Until) > 121*time.Minute {
|
||||
t.Errorf("gespeichert: %+v", m)
|
||||
}
|
||||
// Sofort wirksam, nicht erst beim nächsten Refresh.
|
||||
if cfg := providerConfig(t, s); !strings.Contains(cfg, policy.InterceptorService) {
|
||||
t.Errorf("Wartung nicht sofort wirksam: %s", cfg)
|
||||
}
|
||||
if !strings.Contains(logs.String(), "Kernel 6.12") {
|
||||
t.Error("interner Grund nicht protokolliert")
|
||||
}
|
||||
|
||||
if r := agent.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{Enabled: false}); r.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("Wartung aus: %d", r.StatusCode)
|
||||
}
|
||||
if n, _ := s.store.GetNode(ctx, "n1"); n.Maintenance != nil {
|
||||
t.Error("Wartung nicht beendet")
|
||||
}
|
||||
if cfg := providerConfig(t, s); strings.Contains(cfg, policy.InterceptorService) {
|
||||
t.Errorf("nach Wartungsende noch umgeleitet: %s", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenance_DefaultAndUnlimited(t *testing.T) {
|
||||
s, _ := startServer(t)
|
||||
agent, _ := connectedAgent(t, s, "n1")
|
||||
|
||||
agent.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{Enabled: true})
|
||||
n, _ := s.store.GetNode(ctx, "n1")
|
||||
if d := time.Until(n.Maintenance.Until); d < 239*time.Minute || d > 241*time.Minute {
|
||||
t.Errorf("Default-Dauer %v, erwartet 4h", d)
|
||||
}
|
||||
agent.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{Enabled: true, Duration: "0"})
|
||||
if n, _ := s.store.GetNode(ctx, "n1"); n.Maintenance == nil || !n.Maintenance.Until.IsZero() {
|
||||
t.Errorf("Dauer 0 = unbefristet: %+v", n.Maintenance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenance_InvalidRequests(t *testing.T) {
|
||||
s, _ := startServer(t)
|
||||
agent, _ := connectedAgent(t, s, "n1")
|
||||
for name, req := range map[string]any{
|
||||
"Dauer unlesbar": MaintenanceRequest{Enabled: true, Duration: "bald"},
|
||||
"Dauer negativ": MaintenanceRequest{Enabled: true, Duration: "-1h"},
|
||||
"Grund zu lang": MaintenanceRequest{Enabled: true, PublicReason: strings.Repeat("x", 301)},
|
||||
"Grund mehrzeilig": MaintenanceRequest{Enabled: true, PublicReason: "Zeile 1\nZeile 2"},
|
||||
"kein JSON-Objekt": "an",
|
||||
} {
|
||||
if r := agent.do(t, http.MethodPost, "/api/v1/maintenance", req); r.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("%s: Status %d", name, r.StatusCode)
|
||||
}
|
||||
}
|
||||
plain, _ := http.NewRequest(http.MethodPost, agent.base+"/api/v1/maintenance", strings.NewReader(`{"enabled":true}`))
|
||||
if resp, err := agent.hc.Do(plain); err != nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("unsigniert: %v %v", resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Reboot während der Wartung: Routen bleiben als Wartungsseite stehen, bis
|
||||
// die Wartung endet – erst dann verschwinden sie.
|
||||
func TestMaintenance_DisconnectKeepsRoutesUntilEnd(t *testing.T) {
|
||||
// Kurze Lease, damit nach Wartungsende nicht die Lease die Routen hält.
|
||||
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond; c.LeaseDuration = 150 * time.Millisecond })
|
||||
agent, stop := connectedAgent(t, s, "n1")
|
||||
agent.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{Enabled: true, Duration: "400ms"})
|
||||
|
||||
stop()
|
||||
eventually(t, "Knoten getrennt", func() bool { return !s.hub.Connected("n1") })
|
||||
time.Sleep(100 * time.Millisecond) // einige Refresh-Runden
|
||||
if cfg := providerConfig(t, s); !strings.Contains(cfg, policy.InterceptorService) {
|
||||
t.Fatalf("Routen während der Wartung entfernt: %s", cfg)
|
||||
}
|
||||
eventually(t, "Routen nach Wartungsende entfernt", func() bool { return providerConfig(t, s) == "{}" })
|
||||
}
|
||||
|
||||
// Per CLI (anderer Prozess, direkt im Store) gesetzte Wartung wirkt beim nächsten Refresh.
|
||||
func TestMaintenance_FromStoreAppliedOnRefresh(t *testing.T) {
|
||||
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond })
|
||||
connectedAgent(t, s, "n1")
|
||||
if err := s.store.SetNodeMaintenance(ctx, "n1", store.Maintenance{Until: time.Now().Add(time.Hour)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventually(t, "Umleitung", func() bool { return strings.Contains(providerConfig(t, s), policy.InterceptorService) })
|
||||
}
|
||||
|
||||
func TestDirectory_Maintenance(t *testing.T) {
|
||||
s, _ := startServer(t)
|
||||
createTenant(t, s, "kunde", "kunde.de")
|
||||
s.store.CreateNode(ctx, store.Node{ID: "n1", TenantID: "kunde", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"})
|
||||
until := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
|
||||
s.store.SetNodeMaintenance(ctx, "n1", store.Maintenance{Until: until, Reason: "intern", PublicReason: "öffentlich"})
|
||||
nc, err := (&directory{store: s.store}).NodeContext(ctx, "n1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nc.Maintenance == nil || !nc.Maintenance.Until.Equal(until) || nc.Maintenance.PublicReason != "öffentlich" {
|
||||
t.Errorf("Maintenance: %+v", nc.Maintenance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLease_IssuedOnConnectAndSigned(t *testing.T) {
|
||||
s, _ := startServer(t)
|
||||
createTenant(t, s, "kunde", "kunde.de")
|
||||
agent := newAgent(t, s, issueToken(t, s, "kunde"))
|
||||
agent.enroll(t, "n1")
|
||||
_, events := agent.connectEvents(t)
|
||||
|
||||
ev := nextEvent(t, events, stream.EventLeaseRenew)
|
||||
if err := stream.VerifyEvent(ev, s.MasterPublicKey()); err != nil {
|
||||
t.Fatalf("Event nicht vom Server signiert: %v", err)
|
||||
}
|
||||
l, err := license.ParseLease(ev.Payload, s.MasterPublicKey())
|
||||
if err != nil {
|
||||
t.Fatalf("Lease ungültig: %v", err)
|
||||
}
|
||||
if l.NodeID != "n1" || l.TenantID != "kunde" {
|
||||
t.Errorf("Lease: %+v", l)
|
||||
}
|
||||
if d := time.Until(l.ValidUntil); d < 7*24*time.Hour-time.Minute || d > 7*24*time.Hour {
|
||||
t.Errorf("Pro-Lease läuft %v, erwartet 7 Tage", d)
|
||||
}
|
||||
stored, err := s.store.GetLease(ctx, "n1")
|
||||
if err != nil || !stored.ValidUntil.Equal(l.ValidUntil) {
|
||||
t.Errorf("gespeicherte Lease: %+v %v", stored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLease_Renewal(t *testing.T) {
|
||||
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond; c.LeaseDuration = 300 * time.Millisecond })
|
||||
createTenant(t, s, "kunde", "kunde.de")
|
||||
agent := newAgent(t, s, issueToken(t, s, "kunde"))
|
||||
agent.enroll(t, "n1")
|
||||
_, events := agent.connectEvents(t)
|
||||
|
||||
first, _ := license.ParseLease(nextEvent(t, events, stream.EventLeaseRenew).Payload, s.MasterPublicKey())
|
||||
second, _ := license.ParseLease(nextEvent(t, events, stream.EventLeaseRenew).Payload, s.MasterPublicKey())
|
||||
if !second.ValidUntil.After(first.ValidUntil) {
|
||||
t.Errorf("nicht verlängert: %v → %v", first.ValidUntil, second.ValidUntil)
|
||||
}
|
||||
}
|
||||
|
||||
// Ungeplantes Trennen: Wartungsseite bis zum Ablauf der Lease, danach weg.
|
||||
func TestDisconnect_RoutesUntilLeaseExpiry(t *testing.T) {
|
||||
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond; c.LeaseDuration = 600 * time.Millisecond })
|
||||
_, stop := connectedAgent(t, s, "n1")
|
||||
stop()
|
||||
eventually(t, "Wartungsseite", func() bool { return strings.Contains(providerConfig(t, s), policy.InterceptorService) })
|
||||
eventually(t, "Routen nach Lease-Ablauf entfernt", func() bool { return providerConfig(t, s) == "{}" })
|
||||
}
|
||||
|
||||
// drain entfernt die Routen eines getrennten Knotens sofort – trotz gültiger Lease.
|
||||
func TestDisconnect_DrainRemovesImmediately(t *testing.T) {
|
||||
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond })
|
||||
_, stop := connectedAgent(t, s, "n1")
|
||||
stop()
|
||||
eventually(t, "Wartungsseite", func() bool { return strings.Contains(providerConfig(t, s), policy.InterceptorService) })
|
||||
if err := s.store.SetNodeStatus(ctx, "n1", store.NodeDrained); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventually(t, "drain entfernt Routen", func() bool { return providerConfig(t, s) == "{}" })
|
||||
}
|
||||
|
||||
// Befehle aus der Warteschlange (die CLI ist ein eigener Prozess) werden
|
||||
// zugestellt – auch solche, die vor dem Verbinden eingereiht wurden.
|
||||
func TestNodeCommands_Delivered(t *testing.T) {
|
||||
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond })
|
||||
createTenant(t, s, "kunde", "kunde.de")
|
||||
agent := newAgent(t, s, issueToken(t, s, "kunde"))
|
||||
agent.enroll(t, "n1")
|
||||
s.store.EnqueueNodeCommand(ctx, "n1", stream.CmdResync, "")
|
||||
|
||||
_, events := agent.connectEvents(t)
|
||||
ev := nextEvent(t, events, stream.EventNodeCommand)
|
||||
if err := stream.VerifyEvent(ev, s.MasterPublicKey()); err != nil {
|
||||
t.Fatalf("Befehl nicht signiert: %v", err)
|
||||
}
|
||||
if cmd, err := stream.ParseNodeCommand(ev); err != nil || cmd.Command != stream.CmdResync {
|
||||
t.Errorf("Befehl: %+v %v", cmd, err)
|
||||
}
|
||||
|
||||
s.store.EnqueueNodeCommand(ctx, "n1", stream.CmdDrain, "Umzug")
|
||||
ev = nextEvent(t, events, stream.EventNodeCommand)
|
||||
if cmd, _ := stream.ParseNodeCommand(ev); cmd.Command != stream.CmdDrain || cmd.Reason != "Umzug" {
|
||||
t.Errorf("zweiter Befehl: %+v", cmd)
|
||||
}
|
||||
eventually(t, "als gesendet markiert", func() bool {
|
||||
cmds, _ := s.store.PendingNodeCommands(ctx, "n1")
|
||||
return len(cmds) == 0
|
||||
})
|
||||
}
|
||||
|
||||
func TestRotateKey(t *testing.T) {
|
||||
s, _ := startServer(t)
|
||||
agent, _ := connectedAgent(t, s, "n1")
|
||||
other, _ := connectedAgent(t, s, "n2")
|
||||
newKey, _ := auth.GenerateKey()
|
||||
newPub := auth.EncodePublicKey(newKey.Public().(ed25519.PublicKey))
|
||||
req := RotateKeyRequest{PublicKey: newPub, Proof: auth.RotationProof("n1", newKey)}
|
||||
|
||||
if r := agent.do(t, http.MethodPost, "/api/v1/rotate-key", RotateKeyRequest{PublicKey: newPub, Proof: "AAAA"}); r.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("falscher Besitznachweis: %d", r.StatusCode)
|
||||
}
|
||||
if r := agent.do(t, http.MethodPost, "/api/v1/rotate-key", req); r.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("Rotation: %d", r.StatusCode)
|
||||
}
|
||||
if n, _ := s.store.GetNode(ctx, "n1"); n.PublicKey != newPub {
|
||||
t.Error("Schlüssel nicht getauscht")
|
||||
}
|
||||
if r := agent.do(t, http.MethodPost, "/api/v1/rotate-key", req); r.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("alter Schlüssel noch gültig: %d", r.StatusCode)
|
||||
}
|
||||
oldSigner := agent.signer
|
||||
agent.signer = &auth.Signer{Key: newKey}
|
||||
if r := agent.do(t, http.MethodPost, "/api/v1/rotate-key", req); r.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("Wiederholung mit neuem Schlüssel (Wiederaufnahme nach Absturz): %d", r.StatusCode)
|
||||
}
|
||||
agent.signer = oldSigner
|
||||
|
||||
// Der Besitznachweis ist an die Knoten-ID gebunden: Ein Nachweis für n2
|
||||
// gilt nicht für n1.
|
||||
_ = other
|
||||
another, _ := auth.GenerateKey()
|
||||
anotherPub := auth.EncodePublicKey(another.Public().(ed25519.PublicKey))
|
||||
agent.signer = &auth.Signer{Key: newKey}
|
||||
foreign := RotateKeyRequest{PublicKey: anotherPub, Proof: auth.RotationProof("n2", another)}
|
||||
if r := agent.do(t, http.MethodPost, "/api/v1/rotate-key", foreign); r.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("Nachweis für anderen Knoten: %d", r.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectory_FeaturesPaymentDisconnected(t *testing.T) {
|
||||
s, _ := startServer(t)
|
||||
if _, err := s.store.CreateTenant(ctx, store.Tenant{
|
||||
ID: "kunde", Name: "k", Email: "k@x.de", Tier: store.TierPro, MaxRoutes: 5,
|
||||
AllowedDomains: []string{"kunde.de"}, ValidUntil: time.Now().AddDate(1, 0, 0),
|
||||
Features: []string{"custom_middleware"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.store.CreateNode(ctx, store.Node{ID: "n1", TenantID: "kunde", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4", Status: store.NodeOffline})
|
||||
s.store.SetTenantPayment(ctx, "kunde", time.Now().Add(-10*24*time.Hour))
|
||||
|
||||
nc, err := (&directory{store: s.store}).NodeContext(ctx, "n1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !nc.License.HasFeature(license.FeatureCustomMiddleware) {
|
||||
t.Error("Features nicht in der Lizenz")
|
||||
}
|
||||
if nc.Tenant.DunningLevel != 1 {
|
||||
t.Errorf("Mahnstufe %d, erwartet 1 (10 Tage überfällig)", nc.Tenant.DunningLevel)
|
||||
}
|
||||
if !nc.Disconnected {
|
||||
t.Error("getrennter Knoten nicht erkannt")
|
||||
}
|
||||
}
|
||||
|
||||
// recorder ist ein Benachrichtigungsziel für Tests; failFirst lässt die
|
||||
// ersten n Zustellungen scheitern.
|
||||
type recorder struct {
|
||||
mu sync.Mutex
|
||||
msgs []notify.Message
|
||||
failFirst int
|
||||
}
|
||||
|
||||
func (r *recorder) Send(_ context.Context, m notify.Message) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.failFirst > 0 {
|
||||
r.failFirst--
|
||||
return errors.New("Ziel nicht erreichbar")
|
||||
}
|
||||
r.msgs = append(r.msgs, m)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recorder) matching(substr string) []notify.Message {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var out []notify.Message
|
||||
for _, m := range r.msgs {
|
||||
if strings.Contains(m.Title+" "+m.Body, substr) {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fastJobs(rec *recorder) func(*Config) {
|
||||
return func(c *Config) {
|
||||
c.RefreshInterval = 20 * time.Millisecond
|
||||
c.Notify = rec
|
||||
}
|
||||
}
|
||||
|
||||
func commercialTenant(t *testing.T, s *Server, id string, validFor time.Duration) {
|
||||
t.Helper()
|
||||
if _, err := s.store.CreateTenant(ctx, store.Tenant{
|
||||
ID: id, Name: id, Email: id + "@example.com", Tier: store.TierPro, MaxRoutes: 10,
|
||||
AllowedDomains: []string{"kunde.de"}, ValidUntil: time.Now().Add(validFor),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckmkEndpoint(t *testing.T) {
|
||||
s, _ := startServer(t)
|
||||
commercialTenant(t, s, "kunde", 20*24*time.Hour)
|
||||
connectedAgent(t, s, "n1")
|
||||
|
||||
resp, err := http.Get("http://" + s.ProviderAddr() + "/metrics/checkmk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") {
|
||||
t.Fatalf("Status %d, Content-Type %q", resp.StatusCode, resp.Header.Get("Content-Type"))
|
||||
}
|
||||
out := string(body)
|
||||
for _, want := range []string{`1 "Sultan_Tenant_kunde" days_left=19;30;14`, `0 "Sultan_Payment_kunde"`, `0 "Sultan_Node_n1"`, `0 "Sultan_Policy_Violations"`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("fehlt %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJob_ExpiresCommercialTenantOnly(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
s, _ := startServer(t, fastJobs(rec))
|
||||
commercialTenant(t, s, "kunde", 150*time.Millisecond)
|
||||
if _, err := s.store.CreateTenant(ctx, store.Tenant{
|
||||
ID: "homelab", Name: "h", Email: "h@x.de", Tier: store.TierCommunity, MaxRoutes: 5,
|
||||
AllowedDomains: []string{"x.de"}, ValidUntil: time.Now().Add(150 * time.Millisecond),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventually(t, "kommerzieller Vertrag auf expired", func() bool {
|
||||
tn, _ := s.store.GetTenant(ctx, "kunde")
|
||||
return tn.Status == store.TenantExpired
|
||||
})
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if tn, _ := s.store.GetTenant(ctx, "homelab"); tn.Status != store.TenantActive {
|
||||
t.Errorf("Community abgelaufen: %s", tn.Status)
|
||||
}
|
||||
eventually(t, "Benachrichtigung Vertragsende", func() bool { return len(rec.matching("abgelaufen")) == 1 })
|
||||
if m := rec.matching("abgelaufen")[0]; m.Severity != notify.Critical || !strings.Contains(m.Title, "kunde") {
|
||||
t.Errorf("Nachricht: %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications_ContractOnce(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
s, _ := startServer(t, fastJobs(rec))
|
||||
commercialTenant(t, s, "kunde", 10*24*time.Hour) // innerhalb der 14-Tage-Schwelle
|
||||
eventually(t, "Benachrichtigung", func() bool { return len(rec.matching("kunde")) > 0 })
|
||||
time.Sleep(150 * time.Millisecond) // mehrere Job-Runden
|
||||
if got := rec.matching("kunde"); len(got) != 1 || got[0].Severity != notify.Critical {
|
||||
t.Errorf("erwartet genau eine kritische Nachricht, bekommen %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications_Dunning(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
s, _ := startServer(t, fastJobs(rec))
|
||||
commercialTenant(t, s, "kunde", 365*24*time.Hour)
|
||||
s.store.SetTenantPayment(ctx, "kunde", time.Now().Add(-9*24*time.Hour))
|
||||
eventually(t, "Mahnstufe 1", func() bool { return len(rec.matching("Mahnstufe 1")) == 1 })
|
||||
if m := rec.matching("Mahnstufe 1")[0]; m.Severity != notify.Warning {
|
||||
t.Errorf("Schweregrad: %v", m.Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications_NodeOffline(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
s, _ := startServer(t, fastJobs(rec), func(c *Config) { c.OfflineAlertAfter = 100 * time.Millisecond })
|
||||
_, stop := connectedAgent(t, s, "n1")
|
||||
_, stop2 := connectedAgent(t, s, "n2")
|
||||
agent2, _ := connectedAgent(t, s, "n3")
|
||||
agent2.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{Enabled: true})
|
||||
_ = stop2
|
||||
stop()
|
||||
eventually(t, "Offline-Alarm", func() bool { return len(rec.matching("n1")) == 1 })
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if got := rec.matching("n1"); len(got) != 1 || got[0].Severity != notify.Critical {
|
||||
t.Errorf("erwartet genau einen Alarm: %+v", got)
|
||||
}
|
||||
if len(rec.matching("n2")) != 0 || len(rec.matching("n3")) != 0 {
|
||||
t.Error("Alarm für verbundenen Knoten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications_NoAlarmDuringMaintenance(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
s, _ := startServer(t, fastJobs(rec), func(c *Config) { c.OfflineAlertAfter = 50 * time.Millisecond })
|
||||
agent, stop := connectedAgent(t, s, "n1")
|
||||
agent.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{Enabled: true, Duration: "1h"})
|
||||
stop()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if got := rec.matching("n1"); len(got) != 0 {
|
||||
t.Errorf("Alarm trotz angemeldeter Wartung: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications_Violation(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
s, _ := startServer(t, fastJobs(rec))
|
||||
agent, _ := connectedAgent(t, s, "n1")
|
||||
agent.do(t, http.MethodPost, "/api/v1/state", wpState("bank.de"))
|
||||
eventually(t, "Verstoß gemeldet", func() bool { return len(rec.matching("bank.de")) == 1 })
|
||||
if m := rec.matching("bank.de")[0]; m.Severity != notify.Warning || !strings.Contains(m.Body, "n1") {
|
||||
t.Errorf("Nachricht: %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// Scheitert die Zustellung, wird es beim nächsten Durchlauf erneut versucht.
|
||||
func TestNotifications_RetryOnFailure(t *testing.T) {
|
||||
rec := &recorder{failFirst: 2}
|
||||
s, _ := startServer(t, fastJobs(rec))
|
||||
commercialTenant(t, s, "kunde", 10*24*time.Hour)
|
||||
eventually(t, "Zustellung nach Wiederholung", func() bool { return len(rec.matching("kunde")) == 1 })
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if n := len(rec.matching("kunde")); n != 1 {
|
||||
t.Errorf("%d Nachrichten, erwartet 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Massenhaftes Durchprobieren von Einladungen wird gebremst – vor der
|
||||
// Signaturprüfung, pro Client-IP.
|
||||
func TestEnroll_RateLimited(t *testing.T) {
|
||||
s, _ := startServer(t, func(c *Config) { c.EnrollLimit = 3 })
|
||||
createTenant(t, s, "kunde", "kunde.de")
|
||||
agent := newAgent(t, s, issueToken(t, s, "kunde"))
|
||||
agent.secret = crypto.TokenPrefix + strings.Repeat("A", 43) // falsch geraten
|
||||
for i := range 3 {
|
||||
if r := agent.enroll(t, "n1"); r.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("Versuch %d: %d", i+1, r.StatusCode)
|
||||
}
|
||||
}
|
||||
r := agent.enroll(t, "n1")
|
||||
if r.StatusCode != http.StatusTooManyRequests || r.Header.Get("Retry-After") == "" {
|
||||
t.Errorf("4. Versuch: %d, Retry-After %q", r.StatusCode, r.Header.Get("Retry-After"))
|
||||
}
|
||||
// Auch unsignierte Fluten zählen.
|
||||
req, _ := http.NewRequest(http.MethodPost, agent.base+"/api/v1/enroll", strings.NewReader(`{}`))
|
||||
if resp, err := agent.hc.Do(req); err != nil || resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Errorf("unsigniert: %v %v", resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDefaults_EnrollLimit(t *testing.T) {
|
||||
if c := (Config{}).withDefaults(); c.EnrollLimit != 10 {
|
||||
t.Errorf("EnrollLimit-Default %d, erwartet 10 pro Minute", c.EnrollLimit)
|
||||
}
|
||||
}
|
||||
|
||||
// „Wieder online“ nur nach einem Offline-Alarm und genau einmal pro Ausfall.
|
||||
func TestNotifications_BackOnline(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
s, _ := startServer(t, fastJobs(rec), func(c *Config) { c.OfflineAlertAfter = 100 * time.Millisecond })
|
||||
agent, stop := connectedAgent(t, s, "n1")
|
||||
stop()
|
||||
eventually(t, "Offline-Alarm", func() bool { return len(rec.matching("offline")) == 1 })
|
||||
|
||||
agent.connect(t)
|
||||
eventually(t, "wieder online", func() bool { return len(rec.matching("wieder online")) == 1 })
|
||||
m := rec.matching("wieder online")[0]
|
||||
if m.Severity != notify.Info || !strings.Contains(m.Body, "n1") {
|
||||
t.Errorf("Nachricht: %+v", m)
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if n := len(rec.matching("wieder online")); n != 1 {
|
||||
t.Errorf("%d Wieder-online-Nachrichten, erwartet 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications_NoBackOnlineWithoutAlarm(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
s, _ := startServer(t, fastJobs(rec), func(c *Config) { c.OfflineAlertAfter = time.Hour })
|
||||
agent, stop := connectedAgent(t, s, "n1")
|
||||
stop()
|
||||
eventually(t, "offline", func() bool { n, _ := s.store.GetNode(ctx, "n1"); return n.Status == store.NodeOffline })
|
||||
agent.connect(t)
|
||||
eventually(t, "online", func() bool { return s.hub.Connected("n1") })
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if got := rec.matching("wieder online"); len(got) != 0 {
|
||||
t.Errorf("Meldung ohne vorherigen Alarm: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDefaults_EnrollLimitDisabled(t *testing.T) {
|
||||
if c := (Config{EnrollLimit: -1}).withDefaults(); c.EnrollLimit != -1 {
|
||||
t.Errorf("-1 (unbegrenzt) überschrieben: %d", c.EnrollLimit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PurgeLeases löscht Leases, die vor mehr als olderThan abgelaufen sind.
|
||||
func (s *Store) PurgeLeases(ctx context.Context, olderThan time.Duration) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM leases WHERE valid_until <= ?`, formatTime(s.now().Add(-olderThan)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// PurgeNodeCommands löscht zugestellte Befehle, die älter als olderThan sind.
|
||||
func (s *Store) PurgeNodeCommands(ctx context.Context, olderThan time.Duration) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM node_commands WHERE sent_at IS NOT NULL AND sent_at <= ?`,
|
||||
formatTime(s.now().Add(-olderThan)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// Notified meldet, ob zum Ereignis key schon benachrichtigt wurde.
|
||||
func (s *Store) Notified(ctx context.Context, key string) bool {
|
||||
var one int
|
||||
return s.db.QueryRowContext(ctx, `SELECT 1 FROM notifications WHERE key = ?`, key).Scan(&one) == nil
|
||||
}
|
||||
|
||||
// RecordNotification vermerkt den Versand zu key (mehrfach unschädlich).
|
||||
func (s *Store) RecordNotification(ctx context.Context, key string) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO notifications (key, sent_at) VALUES (?, ?)`,
|
||||
key, formatTime(s.now()))
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkNodeSeen setzt last_seen auf jetzt (z. B. beim Trennen: "offline seit").
|
||||
func (s *Store) MarkNodeSeen(ctx context.Context, nodeID string) error {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET last_seen = ? WHERE id = ?`, formatTime(s.now()), nodeID)
|
||||
return requireAffected(res, err, "Knoten "+nodeID)
|
||||
}
|
||||
|
||||
// NotificationKeys liefert die vermerkten Ereignisse mit dem Präfix prefix.
|
||||
func (s *Store) NotificationKeys(ctx context.Context, prefix string) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT key FROM notifications WHERE substr(key, 1, length(?)) = ? ORDER BY key`,
|
||||
prefix, prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var keys []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Lease ist die aktuelle Lease eines Knotens. Document ist das signierte
|
||||
// JSON (license.Lease); die Zeiten liegen zusätzlich als Spalten vor, damit
|
||||
// der Server ohne Parsen entscheiden kann.
|
||||
type Lease struct {
|
||||
NodeID string
|
||||
IssuedAt time.Time
|
||||
RenewAfter time.Time
|
||||
ValidUntil time.Time
|
||||
Document string
|
||||
}
|
||||
|
||||
// PutLease speichert bzw. ersetzt die Lease eines Knotens.
|
||||
func (s *Store) PutLease(ctx context.Context, l Lease) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO leases (node_id, issued_at, renew_after, valid_until, document)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(node_id) DO UPDATE SET issued_at = excluded.issued_at, renew_after = excluded.renew_after,
|
||||
valid_until = excluded.valid_until, document = excluded.document`,
|
||||
l.NodeID, formatTime(l.IssuedAt), formatTime(l.RenewAfter), formatTime(l.ValidUntil), l.Document)
|
||||
if sqliteCode(err) != 0 {
|
||||
return fmt.Errorf("Lease für %s: Knoten: %w", l.NodeID, ErrNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// GetLease liest die Lease eines Knotens.
|
||||
func (s *Store) GetLease(ctx context.Context, nodeID string) (Lease, error) {
|
||||
l := Lease{NodeID: nodeID}
|
||||
err := s.db.QueryRowContext(ctx, `SELECT issued_at, renew_after, valid_until, document FROM leases WHERE node_id = ?`, nodeID).
|
||||
Scan(dbTime{&l.IssuedAt}, dbTime{&l.RenewAfter}, dbTime{&l.ValidUntil}, &l.Document)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Lease{}, fmt.Errorf("Lease für %s: %w", nodeID, ErrNotFound)
|
||||
}
|
||||
return l, err
|
||||
}
|
||||
|
||||
// NodeCommand ist ein Befehl in der Warteschlange für einen Knoten. Die CLI
|
||||
// (eigener Prozess) reiht ein, der Server stellt zu.
|
||||
type NodeCommand struct {
|
||||
ID int64
|
||||
NodeID string
|
||||
Command string
|
||||
Reason string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// EnqueueNodeCommand reiht einen Befehl für nodeID ein.
|
||||
func (s *Store) EnqueueNodeCommand(ctx context.Context, nodeID, command, reason string) error {
|
||||
if blank(command) {
|
||||
return invalid("Befehl fehlt")
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO node_commands (node_id, command, reason, created_at) VALUES (?, ?, ?, ?)`,
|
||||
nodeID, command, reason, formatTime(s.now()))
|
||||
return mapConstraint(err, "Befehl für "+nodeID)
|
||||
}
|
||||
|
||||
// PendingNodeCommands liefert die noch nicht zugestellten Befehle in Reihenfolge.
|
||||
func (s *Store) PendingNodeCommands(ctx context.Context, nodeID string) ([]NodeCommand, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, command, reason, created_at FROM node_commands
|
||||
WHERE node_id = ? AND sent_at IS NULL ORDER BY id`, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []NodeCommand
|
||||
for rows.Next() {
|
||||
c := NodeCommand{NodeID: nodeID}
|
||||
if err := rows.Scan(&c.ID, &c.Command, &c.Reason, dbTime{&c.CreatedAt}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MarkNodeCommandSent vermerkt die Zustellung.
|
||||
func (s *Store) MarkNodeCommandSent(ctx context.Context, id int64) error {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE node_commands SET sent_at = ? WHERE id = ?`, formatTime(s.now()), id)
|
||||
return requireAffected(res, err, fmt.Sprintf("Befehl %d", id))
|
||||
}
|
||||
|
||||
// UpdateNodePublicKey tauscht den Schlüssel eines Knotens (rotate_key).
|
||||
func (s *Store) UpdateNodePublicKey(ctx context.Context, nodeID, publicKey string) error {
|
||||
if blank(publicKey) {
|
||||
return invalid("Public Key fehlt")
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET public_key = ? WHERE id = ?`, publicKey, nodeID)
|
||||
if err != nil {
|
||||
return mapConstraint(err, "Knoten "+nodeID)
|
||||
}
|
||||
return requireAffected(res, nil, "Knoten "+nodeID)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Maintenance ist eine angemeldete Wartung eines Knotens.
|
||||
type Maintenance struct {
|
||||
Since time.Time
|
||||
Until time.Time // Nullwert = unbefristet
|
||||
Reason string // intern (Log, Monitoring) – nie öffentlich
|
||||
PublicReason string // optional, ersetzt den Standardtext der Wartungsseite
|
||||
}
|
||||
|
||||
// Active meldet, ob die Wartung zum Zeitpunkt now gilt (nil = keine).
|
||||
func (m *Maintenance) Active(now time.Time) bool {
|
||||
return m != nil && (m.Until.IsZero() || now.Before(m.Until))
|
||||
}
|
||||
|
||||
const (
|
||||
maxPublicReason = 300
|
||||
maxReason = 500
|
||||
)
|
||||
|
||||
// validText: einzeilig, ohne Steuerzeichen, höchstens max Zeichen.
|
||||
func validText(s string, max int) bool {
|
||||
return utf8.RuneCountInString(s) <= max && !strings.ContainsFunc(s, unicode.IsControl)
|
||||
}
|
||||
|
||||
// SetNodeMaintenance meldet eine Wartung an (Since = jetzt). m.Until muss
|
||||
// in der Zukunft liegen oder Nullwert (unbefristet) sein.
|
||||
func (s *Store) SetNodeMaintenance(ctx context.Context, nodeID string, m Maintenance) error {
|
||||
now := s.now()
|
||||
switch {
|
||||
case !m.Until.IsZero() && !m.Until.After(now):
|
||||
return invalid("Wartungsende liegt nicht in der Zukunft")
|
||||
case !validText(m.PublicReason, maxPublicReason):
|
||||
return invalid("öffentlicher Grund: einzeilig, höchstens %d Zeichen", maxPublicReason)
|
||||
case !validText(m.Reason, maxReason):
|
||||
return invalid("Grund: einzeilig, höchstens %d Zeichen", maxReason)
|
||||
}
|
||||
var until any
|
||||
if !m.Until.IsZero() {
|
||||
until = formatTime(m.Until)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET maintenance_since = ?, maintenance_until = ?,
|
||||
maintenance_reason = ?, maintenance_public_reason = ? WHERE id = ?`,
|
||||
formatTime(now), until, m.Reason, m.PublicReason, nodeID)
|
||||
return requireAffected(res, err, "Knoten "+nodeID)
|
||||
}
|
||||
|
||||
// ClearNodeMaintenance beendet die Wartung eines Knotens.
|
||||
func (s *Store) ClearNodeMaintenance(ctx context.Context, nodeID string) error {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET maintenance_since = NULL, maintenance_until = NULL,
|
||||
maintenance_reason = '', maintenance_public_reason = '' WHERE id = ?`, nodeID)
|
||||
return requireAffected(res, err, "Knoten "+nodeID)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNodeMaintenance(t *testing.T) {
|
||||
s, clock := openTest(t)
|
||||
mustTenant(t, s, "t1")
|
||||
mustNode(t, s, "n1", "t1", "k1")
|
||||
|
||||
if n, _ := s.GetNode(ctx, "n1"); n.Maintenance != nil {
|
||||
t.Fatalf("neuer Knoten mit Wartung: %+v", n.Maintenance)
|
||||
}
|
||||
until := clock.now().Add(4 * time.Hour)
|
||||
err := s.SetNodeMaintenance(ctx, "n1", Maintenance{
|
||||
Until: until, Reason: "Kernel-Update", PublicReason: "Wir spielen ein Sicherheitsupdate ein.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetNodeMaintenance: %v", err)
|
||||
}
|
||||
n, _ := s.GetNode(ctx, "n1")
|
||||
m := n.Maintenance
|
||||
if m == nil || !m.Since.Equal(clock.now()) || !m.Until.Equal(until) ||
|
||||
m.Reason != "Kernel-Update" || m.PublicReason != "Wir spielen ein Sicherheitsupdate ein." {
|
||||
t.Errorf("Wartung: %+v", m)
|
||||
}
|
||||
if n.Status != NodeOnline {
|
||||
t.Errorf("Wartung darf den Verbindungsstatus nicht ändern: %s", n.Status)
|
||||
}
|
||||
if nodes, _ := s.ListNodes(ctx, ""); nodes[0].Maintenance == nil {
|
||||
t.Error("ListNodes ohne Wartungsdaten")
|
||||
}
|
||||
|
||||
// Unbefristet: Until bleibt Nullwert.
|
||||
if err := s.SetNodeMaintenance(ctx, "n1", Maintenance{Reason: "Umbau"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := s.GetNode(ctx, "n1"); n.Maintenance == nil || !n.Maintenance.Until.IsZero() {
|
||||
t.Errorf("unbefristet: %+v", n.Maintenance)
|
||||
}
|
||||
|
||||
if err := s.ClearNodeMaintenance(ctx, "n1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := s.GetNode(ctx, "n1"); n.Maintenance != nil {
|
||||
t.Errorf("nach Clear: %+v", n.Maintenance)
|
||||
}
|
||||
if err := s.SetNodeMaintenance(ctx, "fehlt", Maintenance{}); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("unbekannter Knoten: %v", err)
|
||||
}
|
||||
if err := s.ClearNodeMaintenance(ctx, "fehlt"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("unbekannter Knoten: %v", err)
|
||||
}
|
||||
if err := s.SetNodeMaintenance(ctx, "n1", Maintenance{Until: clock.now().Add(-time.Minute)}); !errors.Is(err, ErrInvalid) {
|
||||
t.Errorf("Ende in der Vergangenheit: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceActive(t *testing.T) {
|
||||
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
name string
|
||||
m *Maintenance
|
||||
want bool
|
||||
}{
|
||||
{"keine", nil, false},
|
||||
{"unbefristet", &Maintenance{Since: now.Add(-time.Hour)}, true},
|
||||
{"läuft", &Maintenance{Since: now.Add(-time.Hour), Until: now.Add(time.Minute)}, true},
|
||||
{"abgelaufen", &Maintenance{Since: now.Add(-time.Hour), Until: now}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.m.Active(now); got != tt.want {
|
||||
t.Errorf("%s: Active = %v", tt.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -30,6 +30,9 @@ type Node struct {
|
||||
LastSeen time.Time
|
||||
IPAddress string
|
||||
Status NodeStatus
|
||||
// Maintenance ist die angemeldete Wartung (nil = keine). Sie ist eine
|
||||
// eigene Dimension: Ein Knoten in Wartung kann verbunden sein oder nicht.
|
||||
Maintenance *Maintenance
|
||||
}
|
||||
|
||||
func (n *Node) normalize(now time.Time) error {
|
||||
@@ -79,11 +82,17 @@ func (s *Store) insertNode(ctx context.Context, db execer, n Node) (Node, error)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
const nodeColumns = `id, tenant_id, hostname, public_key, last_seen, ip_address, status`
|
||||
const nodeColumns = `id, tenant_id, hostname, public_key, last_seen, ip_address, status,
|
||||
maintenance_since, maintenance_until, maintenance_reason, maintenance_public_reason`
|
||||
|
||||
func scanNode(row interface{ Scan(...any) error }) (Node, error) {
|
||||
var n Node
|
||||
err := row.Scan(&n.ID, &n.TenantID, &n.Hostname, &n.PublicKey, dbTime{&n.LastSeen}, &n.IPAddress, &n.Status)
|
||||
var m Maintenance
|
||||
err := row.Scan(&n.ID, &n.TenantID, &n.Hostname, &n.PublicKey, dbTime{&n.LastSeen}, &n.IPAddress, &n.Status,
|
||||
dbTime{&m.Since}, dbTime{&m.Until}, &m.Reason, &m.PublicReason)
|
||||
if err == nil && !m.Since.IsZero() {
|
||||
n.Maintenance = &m
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Setting liest eine Server-Einstellung (ErrNotFound, wenn nicht gesetzt).
|
||||
func (s *Store) Setting(ctx context.Context, key string) (string, error) {
|
||||
var v string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fmt.Errorf("Einstellung %s: %w", key, ErrNotFound)
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
// SetSetting setzt bzw. überschreibt eine Server-Einstellung.
|
||||
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
|
||||
if blank(key) {
|
||||
return invalid("Schlüssel fehlt")
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
key, value)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSettings(t *testing.T) {
|
||||
s, _ := openTest(t)
|
||||
if _, err := s.Setting(ctx, "public_url"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("fehlende Einstellung: erwartet ErrNotFound, bekommen %v", err)
|
||||
}
|
||||
if err := s.SetSetting(ctx, "public_url", "https://a.example:8443"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetSetting(ctx, "public_url", "https://b.example:8443"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, err := s.Setting(ctx, "public_url"); err != nil || v != "https://b.example:8443" {
|
||||
t.Errorf("Setting = %q, %v", v, err)
|
||||
}
|
||||
if err := s.SetSetting(ctx, "", "x"); !errors.Is(err, ErrInvalid) {
|
||||
t.Errorf("leerer Schlüssel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings_SurviveReopen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sultan.db")
|
||||
s, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetSetting(ctx, "public_url", "https://a.example:8443")
|
||||
s.Close()
|
||||
s, _ = Open(ctx, path)
|
||||
defer s.Close()
|
||||
if v, _ := s.Setting(ctx, "public_url"); v != "https://a.example:8443" {
|
||||
t.Errorf("nach Neustart: %q", v)
|
||||
}
|
||||
if v, _ := s.SchemaVersion(ctx); v < 2 {
|
||||
t.Errorf("Schema-Version %d, erwartet mindestens 2", v)
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,41 @@ var migrations = []string{
|
||||
used INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX enrollment_tokens_tenant_id ON enrollment_tokens(tenant_id);`,
|
||||
// v2: Server-Einstellungen, die auch `sultan token` (eigener Prozess) braucht.
|
||||
`CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);`,
|
||||
// v3: Wartung pro Knoten (eigene Dimension neben online/offline/drained).
|
||||
`ALTER TABLE nodes ADD COLUMN maintenance_since DATETIME;
|
||||
ALTER TABLE nodes ADD COLUMN maintenance_until DATETIME;
|
||||
ALTER TABLE nodes ADD COLUMN maintenance_reason TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE nodes ADD COLUMN maintenance_public_reason TEXT NOT NULL DEFAULT '';`,
|
||||
// v4: Leases, Befehlswarteschlange, Features und Zahlungsstatus, Einlösezeitpunkt.
|
||||
`CREATE TABLE leases (
|
||||
node_id TEXT PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
issued_at DATETIME NOT NULL,
|
||||
renew_after DATETIME NOT NULL,
|
||||
valid_until DATETIME NOT NULL,
|
||||
document TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE node_commands (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
command TEXT NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
sent_at DATETIME
|
||||
);
|
||||
CREATE INDEX node_commands_pending ON node_commands(node_id, sent_at);
|
||||
ALTER TABLE tenants ADD COLUMN features TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE tenants ADD COLUMN payment_overdue_since DATETIME;
|
||||
ALTER TABLE enrollment_tokens ADD COLUMN used_at DATETIME;`,
|
||||
// v5: versandte Benachrichtigungen (jedes Ereignis genau einmal).
|
||||
`CREATE TABLE notifications (
|
||||
key TEXT PRIMARY KEY,
|
||||
sent_at DATETIME NOT NULL
|
||||
);`,
|
||||
}
|
||||
|
||||
// Store ist der Zugriff auf die SQLite-Datenbank. Er ist für parallele
|
||||
@@ -147,6 +182,9 @@ type dbTime struct{ t *time.Time }
|
||||
|
||||
func (d dbTime) Scan(src any) error {
|
||||
switch v := src.(type) {
|
||||
case nil: // NULL = nicht gesetzt
|
||||
*d.t = time.Time{}
|
||||
return nil
|
||||
case time.Time:
|
||||
*d.t = normTime(v)
|
||||
return nil
|
||||
|
||||
+47
-17
@@ -50,6 +50,10 @@ type Tenant struct {
|
||||
AllowedDomains []string
|
||||
ValidUntil time.Time
|
||||
Status TenantStatus
|
||||
Features []string // freigeschaltete Lizenz-Features (z. B. custom_middleware)
|
||||
// PaymentOverdueSince: seit wann eine Zahlung offen ist (Nullwert = nichts
|
||||
// offen); daraus berechnet die Policy die Mahnstufe.
|
||||
PaymentOverdueSince time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -66,6 +70,9 @@ func (t *Tenant) normalize() error {
|
||||
if t.AllowedDomains == nil {
|
||||
t.AllowedDomains = []string{}
|
||||
}
|
||||
if t.Features == nil {
|
||||
t.Features = []string{}
|
||||
}
|
||||
t.ValidUntil = normTime(t.ValidUntil)
|
||||
|
||||
switch {
|
||||
@@ -89,6 +96,11 @@ func (t *Tenant) normalize() error {
|
||||
return invalid("leerer Eintrag in allowed_domains")
|
||||
}
|
||||
}
|
||||
for _, f := range t.Features {
|
||||
if blank(f) {
|
||||
return invalid("leerer Eintrag in features")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -98,40 +110,47 @@ func (s *Store) CreateTenant(ctx context.Context, t Tenant) (Tenant, error) {
|
||||
if err := t.normalize(); err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
domains, err := json.Marshal(t.AllowedDomains)
|
||||
if err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
domains, _ := json.Marshal(t.AllowedDomains)
|
||||
features, _ := json.Marshal(t.Features)
|
||||
now := normTime(s.now())
|
||||
t.CreatedAt, t.UpdatedAt = now, now
|
||||
t.PaymentOverdueSince = time.Time{}
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO tenants
|
||||
(id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO tenants
|
||||
(id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at, features)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
t.ID, t.Name, t.Email, t.Tier, t.MaxRoutes, string(domains),
|
||||
formatTime(t.ValidUntil), t.Status, formatTime(now), formatTime(now))
|
||||
formatTime(t.ValidUntil), t.Status, formatTime(now), formatTime(now), string(features))
|
||||
if err != nil {
|
||||
return Tenant{}, mapConstraint(err, "Mandant "+t.ID)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
const tenantColumns = `id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at`
|
||||
const tenantColumns = `id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at,
|
||||
features, payment_overdue_since`
|
||||
|
||||
func scanTenant(row interface{ Scan(...any) error }) (Tenant, error) {
|
||||
var t Tenant
|
||||
var domains string
|
||||
var domains, features string
|
||||
err := row.Scan(&t.ID, &t.Name, &t.Email, &t.Tier, &t.MaxRoutes, &domains,
|
||||
dbTime{&t.ValidUntil}, &t.Status, dbTime{&t.CreatedAt}, dbTime{&t.UpdatedAt})
|
||||
dbTime{&t.ValidUntil}, &t.Status, dbTime{&t.CreatedAt}, dbTime{&t.UpdatedAt},
|
||||
&features, dbTime{&t.PaymentOverdueSince})
|
||||
if err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(domains), &t.AllowedDomains); err != nil {
|
||||
return Tenant{}, fmt.Errorf("store: allowed_domains von %s: %w", t.ID, err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(features), &t.Features); err != nil {
|
||||
return Tenant{}, fmt.Errorf("store: features von %s: %w", t.ID, err)
|
||||
}
|
||||
if t.AllowedDomains == nil {
|
||||
t.AllowedDomains = []string{}
|
||||
}
|
||||
if t.Features == nil {
|
||||
t.Features = []string{}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -168,15 +187,14 @@ func (s *Store) UpdateTenant(ctx context.Context, t Tenant) (Tenant, error) {
|
||||
if err := t.normalize(); err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
domains, err := json.Marshal(t.AllowedDomains)
|
||||
if err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
domains, _ := json.Marshal(t.AllowedDomains)
|
||||
features, _ := json.Marshal(t.Features)
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE tenants SET
|
||||
name = ?, email = ?, tier = ?, max_routes = ?, allowed_domains = ?, valid_until = ?, status = ?, updated_at = ?
|
||||
name = ?, email = ?, tier = ?, max_routes = ?, allowed_domains = ?, valid_until = ?, status = ?, updated_at = ?,
|
||||
features = ?
|
||||
WHERE id = ?`,
|
||||
t.Name, t.Email, t.Tier, t.MaxRoutes, string(domains), formatTime(t.ValidUntil), t.Status,
|
||||
formatTime(s.now()), t.ID)
|
||||
formatTime(s.now()), string(features), t.ID)
|
||||
if err := requireAffected(res, err, "Mandant "+t.ID); err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
@@ -193,6 +211,18 @@ func (s *Store) SetTenantStatus(ctx context.Context, id string, status TenantSta
|
||||
return requireAffected(res, err, "Mandant "+id)
|
||||
}
|
||||
|
||||
// SetTenantPayment setzt, seit wann eine Zahlung offen ist (Nullwert =
|
||||
// bezahlt). Grundlage der Mahnstufen.
|
||||
func (s *Store) SetTenantPayment(ctx context.Context, id string, overdueSince time.Time) error {
|
||||
var v any
|
||||
if !overdueSince.IsZero() {
|
||||
v = formatTime(overdueSince)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE tenants SET payment_overdue_since = ?, updated_at = ? WHERE id = ?`,
|
||||
v, formatTime(s.now()), id)
|
||||
return requireAffected(res, err, "Mandant "+id)
|
||||
}
|
||||
|
||||
// DeleteTenant löscht den Mandanten samt seiner Knoten und Tokens.
|
||||
func (s *Store) DeleteTenant(ctx context.Context, id string) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM tenants WHERE id = ?`, id)
|
||||
|
||||
+8
-6
@@ -62,7 +62,8 @@ func (s *Store) Enroll(ctx context.Context, token string, n Node) (Node, error)
|
||||
return Node{}, ErrTokenExpired
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE enrollment_tokens SET used = 1 WHERE token_hash = ?`, hash); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE enrollment_tokens SET used = 1, used_at = ? WHERE token_hash = ?`,
|
||||
formatTime(s.now()), hash); err != nil {
|
||||
return Node{}, err
|
||||
}
|
||||
n.TenantID = tenantID
|
||||
@@ -76,11 +77,12 @@ func (s *Store) Enroll(ctx context.Context, token string, n Node) (Node, error)
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// PurgeEnrollmentTokens löscht verbrauchte und abgelaufene Tokens und
|
||||
// liefert ihre Anzahl.
|
||||
func (s *Store) PurgeEnrollmentTokens(ctx context.Context) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM enrollment_tokens WHERE used = 1 OR expires_at <= ?`, formatTime(s.now()))
|
||||
// PurgeEnrollmentTokens löscht Einladungen, die vor mehr als olderThan
|
||||
// eingelöst wurden oder abgelaufen sind (0 = sofort), und liefert ihre Anzahl.
|
||||
func (s *Store) PurgeEnrollmentTokens(ctx context.Context, olderThan time.Duration) (int64, error) {
|
||||
cutoff := formatTime(s.now().Add(-olderThan))
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM enrollment_tokens
|
||||
WHERE (used = 1 AND (used_at IS NULL OR used_at <= ?)) OR expires_at <= ?`, cutoff, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ func TestPurgeEnrollmentTokens(t *testing.T) {
|
||||
}
|
||||
clock.advance(time.Minute)
|
||||
|
||||
n, err := s.PurgeEnrollmentTokens(ctx)
|
||||
n, err := s.PurgeEnrollmentTokens(ctx, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLeases(t *testing.T) {
|
||||
s, clock := openTest(t)
|
||||
mustTenant(t, s, "t1")
|
||||
mustNode(t, s, "n1", "t1", "k1")
|
||||
|
||||
if _, err := s.GetLease(ctx, "n1"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("ohne Lease: %v", err)
|
||||
}
|
||||
l := Lease{
|
||||
NodeID: "n1",
|
||||
IssuedAt: clock.now(),
|
||||
RenewAfter: clock.now().Add(7 * 24 * time.Hour),
|
||||
ValidUntil: clock.now().Add(14 * 24 * time.Hour),
|
||||
Document: `{"node_id":"n1"}`,
|
||||
}
|
||||
if err := s.PutLease(ctx, l); err != nil {
|
||||
t.Fatalf("PutLease: %v", err)
|
||||
}
|
||||
got, err := s.GetLease(ctx, "n1")
|
||||
if err != nil || !reflect.DeepEqual(got, l) {
|
||||
t.Errorf("GetLease = %+v, %v", got, err)
|
||||
}
|
||||
l.ValidUntil = l.ValidUntil.Add(time.Hour)
|
||||
if err := s.PutLease(ctx, l); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.GetLease(ctx, "n1"); !got.ValidUntil.Equal(l.ValidUntil) {
|
||||
t.Error("PutLease ersetzt nicht")
|
||||
}
|
||||
if err := s.PutLease(ctx, Lease{NodeID: "fehlt", Document: "{}"}); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("unbekannter Knoten: %v", err)
|
||||
}
|
||||
s.DeleteNode(ctx, "n1")
|
||||
if _, err := s.GetLease(ctx, "n1"); !errors.Is(err, ErrNotFound) {
|
||||
t.Error("Lease nach Löschen des Knotens noch da")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantFeaturesAndPayment(t *testing.T) {
|
||||
s, clock := openTest(t)
|
||||
in := sampleTenant("t1")
|
||||
in.Features = []string{"custom_middleware", "websockets"}
|
||||
created, err := s.CreateTenant(ctx, in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := s.GetTenant(ctx, "t1")
|
||||
if !reflect.DeepEqual(got.Features, []string{"custom_middleware", "websockets"}) || !reflect.DeepEqual(got, created) {
|
||||
t.Errorf("Features: %+v", got.Features)
|
||||
}
|
||||
if !got.PaymentOverdueSince.IsZero() {
|
||||
t.Error("neuer Mandant mit offener Zahlung")
|
||||
}
|
||||
if def, _ := s.CreateTenant(ctx, sampleTenant("t2")); def.Features == nil || len(def.Features) != 0 {
|
||||
t.Errorf("Default-Features: %#v", def.Features)
|
||||
}
|
||||
|
||||
due := clock.now().Add(-10 * 24 * time.Hour)
|
||||
if err := s.SetTenantPayment(ctx, "t1", due); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.GetTenant(ctx, "t1"); !got.PaymentOverdueSince.Equal(due) {
|
||||
t.Errorf("offen seit: %v", got.PaymentOverdueSince)
|
||||
}
|
||||
if err := s.SetTenantPayment(ctx, "t1", time.Time{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.GetTenant(ctx, "t1"); !got.PaymentOverdueSince.IsZero() {
|
||||
t.Error("bezahlt: Fälligkeit nicht gelöscht")
|
||||
}
|
||||
if err := s.SetTenantPayment(ctx, "fehlt", due); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("unbekannter Mandant: %v", err)
|
||||
}
|
||||
bad := sampleTenant("t3")
|
||||
bad.Features = []string{"ok", " "}
|
||||
if _, err := s.CreateTenant(ctx, bad); !errors.Is(err, ErrInvalid) {
|
||||
t.Errorf("leeres Feature: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnroll_RecordsUsedAt(t *testing.T) {
|
||||
s, clock := openTest(t)
|
||||
mustTenant(t, s, "t1")
|
||||
s.AddEnrollmentToken(ctx, "tok", "t1", clock.now().Add(time.Hour))
|
||||
clock.advance(time.Minute)
|
||||
if _, err := s.Enroll(ctx, "tok", sampleNode("n1", "k1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var usedAt time.Time
|
||||
if err := s.db.QueryRowContext(ctx, "SELECT used_at FROM enrollment_tokens").Scan(dbTime{&usedAt}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !usedAt.Equal(clock.now()) {
|
||||
t.Errorf("used_at = %v, erwartet %v", usedAt, clock.now())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeCommands(t *testing.T) {
|
||||
s, clock := openTest(t)
|
||||
mustTenant(t, s, "t1")
|
||||
mustNode(t, s, "n1", "t1", "k1")
|
||||
|
||||
if err := s.EnqueueNodeCommand(ctx, "n1", "resync", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock.advance(time.Second)
|
||||
s.EnqueueNodeCommand(ctx, "n1", "drain", "Umzug")
|
||||
cmds, err := s.PendingNodeCommands(ctx, "n1")
|
||||
if err != nil || len(cmds) != 2 || cmds[0].Command != "resync" || cmds[1].Command != "drain" || cmds[1].Reason != "Umzug" {
|
||||
t.Fatalf("ausstehend: %+v %v", cmds, err)
|
||||
}
|
||||
if err := s.MarkNodeCommandSent(ctx, cmds[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmds, _ := s.PendingNodeCommands(ctx, "n1"); len(cmds) != 1 || cmds[0].Command != "drain" {
|
||||
t.Errorf("nach Versand: %+v", cmds)
|
||||
}
|
||||
if err := s.EnqueueNodeCommand(ctx, "fehlt", "resync", ""); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("unbekannter Knoten: %v", err)
|
||||
}
|
||||
if err := s.EnqueueNodeCommand(ctx, "n1", "", ""); !errors.Is(err, ErrInvalid) {
|
||||
t.Errorf("leerer Befehl: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNodePublicKey(t *testing.T) {
|
||||
s, _ := openTest(t)
|
||||
mustTenant(t, s, "t1")
|
||||
mustNode(t, s, "n1", "t1", "k1")
|
||||
mustNode(t, s, "n2", "t1", "k2")
|
||||
if err := s.UpdateNodePublicKey(ctx, "n1", "k1-neu"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := s.GetNodeByPublicKey(ctx, "k1-neu"); n.ID != "n1" {
|
||||
t.Errorf("neuer Schlüssel nicht gefunden: %+v", n)
|
||||
}
|
||||
if _, err := s.GetNodeByPublicKey(ctx, "k1"); !errors.Is(err, ErrNotFound) {
|
||||
t.Error("alter Schlüssel noch gültig")
|
||||
}
|
||||
if err := s.UpdateNodePublicKey(ctx, "n1", "k2"); !errors.Is(err, ErrExists) {
|
||||
t.Errorf("fremder Schlüssel: %v", err)
|
||||
}
|
||||
if err := s.UpdateNodePublicKey(ctx, "fehlt", "x"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("unbekannter Knoten: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Aufräumen erst nach einer Frist (Job: 10 Tage), CLI-purge sofort (0).
|
||||
func TestPurgeEnrollmentTokens_Retention(t *testing.T) {
|
||||
s, clock := openTest(t)
|
||||
mustTenant(t, s, "t1")
|
||||
start := clock.now()
|
||||
s.AddEnrollmentToken(ctx, "alt-benutzt", "t1", start.Add(time.Hour))
|
||||
s.AddEnrollmentToken(ctx, "jung-benutzt", "t1", start.Add(30*24*time.Hour))
|
||||
s.AddEnrollmentToken(ctx, "alt-abgelaufen", "t1", start.Add(time.Hour))
|
||||
s.AddEnrollmentToken(ctx, "gueltig", "t1", start.Add(30*24*time.Hour))
|
||||
s.Enroll(ctx, "alt-benutzt", sampleNode("n1", "k1"))
|
||||
clock.advance(9 * 24 * time.Hour)
|
||||
s.Enroll(ctx, "jung-benutzt", sampleNode("n2", "k2"))
|
||||
clock.advance(2 * 24 * time.Hour) // jetzt: alt-* vor > 10 Tagen benutzt/abgelaufen
|
||||
|
||||
n, err := s.PurgeEnrollmentTokens(ctx, 10*24*time.Hour)
|
||||
if err != nil || n != 2 {
|
||||
t.Errorf("gelöscht %d (%v), erwartet 2 (alt-benutzt, alt-abgelaufen)", n, err)
|
||||
}
|
||||
if n, _ := s.PurgeEnrollmentTokens(ctx, 0); n != 1 {
|
||||
t.Errorf("sofort: %d gelöscht, erwartet 1 (jung-benutzt)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeLeasesAndCommands(t *testing.T) {
|
||||
s, clock := openTest(t)
|
||||
mustTenant(t, s, "t1")
|
||||
mustNode(t, s, "n1", "t1", "k1")
|
||||
mustNode(t, s, "n2", "t1", "k2")
|
||||
now := clock.now()
|
||||
s.PutLease(ctx, Lease{NodeID: "n1", IssuedAt: now, RenewAfter: now, ValidUntil: now.Add(time.Hour), Document: "{}"})
|
||||
s.PutLease(ctx, Lease{NodeID: "n2", IssuedAt: now, RenewAfter: now, ValidUntil: now.Add(20 * 24 * time.Hour), Document: "{}"})
|
||||
s.EnqueueNodeCommand(ctx, "n1", "resync", "")
|
||||
s.EnqueueNodeCommand(ctx, "n1", "drain", "")
|
||||
cmds, _ := s.PendingNodeCommands(ctx, "n1")
|
||||
s.MarkNodeCommandSent(ctx, cmds[0].ID)
|
||||
clock.advance(11 * 24 * time.Hour)
|
||||
|
||||
if n, err := s.PurgeLeases(ctx, 10*24*time.Hour); err != nil || n != 1 {
|
||||
t.Errorf("Leases: %d %v", n, err)
|
||||
}
|
||||
if _, err := s.GetLease(ctx, "n2"); err != nil {
|
||||
t.Error("gültige Lease gelöscht")
|
||||
}
|
||||
if n, err := s.PurgeNodeCommands(ctx, 10*24*time.Hour); err != nil || n != 1 {
|
||||
t.Errorf("Befehle: %d %v", n, err)
|
||||
}
|
||||
if cmds, _ := s.PendingNodeCommands(ctx, "n1"); len(cmds) != 1 {
|
||||
t.Error("nicht zugestellter Befehl gelöscht")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsOnce(t *testing.T) {
|
||||
s, _ := openTest(t)
|
||||
if s.Notified(ctx, "contract:t1:2026-12-15:14") {
|
||||
t.Error("neu: schon benachrichtigt")
|
||||
}
|
||||
if err := s.RecordNotification(ctx, "contract:t1:2026-12-15:14"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.Notified(ctx, "contract:t1:2026-12-15:14") {
|
||||
t.Error("nicht vermerkt")
|
||||
}
|
||||
if err := s.RecordNotification(ctx, "contract:t1:2026-12-15:14"); err != nil {
|
||||
t.Errorf("doppelt vermerken: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkNodeSeen(t *testing.T) {
|
||||
s, clock := openTest(t)
|
||||
mustTenant(t, s, "t1")
|
||||
mustNode(t, s, "n1", "t1", "k1")
|
||||
clock.advance(time.Hour)
|
||||
if err := s.MarkNodeSeen(ctx, "n1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := s.GetNode(ctx, "n1"); !n.LastSeen.Equal(clock.now()) {
|
||||
t.Errorf("last_seen = %v", n.LastSeen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationKeysWithPrefix(t *testing.T) {
|
||||
s, _ := openTest(t)
|
||||
for _, k := range []string{"offline:n1:a", "offline:n1:b", "offline:n10:c", "contract:x"} {
|
||||
s.RecordNotification(ctx, k)
|
||||
}
|
||||
got, err := s.NotificationKeys(ctx, "offline:n1:")
|
||||
if err != nil || len(got) != 2 || got[0] != "offline:n1:a" || got[1] != "offline:n1:b" {
|
||||
t.Errorf("Schlüssel: %v %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,9 @@ type Client struct {
|
||||
MinBackoff, MaxBackoff time.Duration
|
||||
// OnError wird in Run vor jeder Wartezeit mit dem Grund aufgerufen.
|
||||
OnError func(err error, retryIn time.Duration)
|
||||
// OnConnect wird nach jedem erfolgreichen Verbindungsaufbau aufgerufen,
|
||||
// bevor Events gelesen werden – z. B. um den eigenen Zustand neu zu senden.
|
||||
OnConnect func()
|
||||
|
||||
sleep func(ctx context.Context, d time.Duration) error // für Tests ersetzbar
|
||||
}
|
||||
@@ -102,6 +105,9 @@ func (c *Client) connect(ctx context.Context, handle func(Event) error) (connect
|
||||
return false, fmt.Errorf("%w: Content-Type %q", ErrUnexpectedResponse, resp.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
if c.OnConnect != nil {
|
||||
c.OnConnect()
|
||||
}
|
||||
idle := c.idleTimeout()
|
||||
timer := time.AfterFunc(idle, func() { cancel(ErrIdleTimeout) })
|
||||
defer timer.Stop()
|
||||
|
||||
@@ -331,3 +331,36 @@ func TestSleepCtx(t *testing.T) {
|
||||
t.Error("sleepCtx hat trotz Abbruch gewartet")
|
||||
}
|
||||
}
|
||||
|
||||
// OnConnect wird nach jedem erfolgreichen Verbindungsaufbau aufgerufen –
|
||||
// nicht bei Fehlantworten.
|
||||
func TestClient_OnConnect(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if requests.Add(1)%2 == 1 {
|
||||
http.Error(w, "später", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var connects, sleeps atomic.Int32
|
||||
c := &Client{
|
||||
URL: srv.URL,
|
||||
OnConnect: func() { connects.Add(1) },
|
||||
sleep: func(ctx context.Context, d time.Duration) error {
|
||||
if sleeps.Add(1) == 4 {
|
||||
cancel()
|
||||
return ctx.Err()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Run(ctx, func(Event) error { return nil })
|
||||
if n := connects.Load(); n != 2 {
|
||||
t.Errorf("OnConnect %d-mal, erwartet 2 (bei 4 Versuchen, davon 2 erfolgreich)", n)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,9 @@ type Event struct {
|
||||
Type EventType `json:"type"`
|
||||
NodeID string `json:"node_id"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
// Signature: Ed25519-Signatur des Servers über alle übrigen Felder
|
||||
// (SignEvent/VerifyEvent); der Agent verwirft Events ohne gültige Signatur.
|
||||
Signature string `json:"signature,omitempty"`
|
||||
}
|
||||
|
||||
// NewEvent erzeugt ein Event mit neuer ID, aktuellem Zeitstempel und der als
|
||||
|
||||
+16
-6
@@ -1,6 +1,7 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -41,6 +42,9 @@ type Hub struct {
|
||||
// nur ein weiteres OnConnect, kein OnDisconnect.
|
||||
OnConnect func(nodeID string)
|
||||
OnDisconnect func(nodeID string)
|
||||
// SigningKey: Ist er gesetzt, signiert der Hub jedes Event, nachdem er ID,
|
||||
// Zeitstempel und Knoten-ID ergänzt hat.
|
||||
SigningKey ed25519.PrivateKey
|
||||
|
||||
mu sync.Mutex
|
||||
conns map[string]*conn
|
||||
@@ -86,6 +90,15 @@ func (h *Hub) Serve(w http.ResponseWriter, r *http.Request, nodeID string) error
|
||||
return ErrHubClosed
|
||||
}
|
||||
|
||||
// Erst registrieren, dann die Header senden: Sobald der Client die
|
||||
// Antwort sieht, muss der Knoten als verbunden gelten (der Agent schickt
|
||||
// direkt danach seinen Zustand).
|
||||
c := &conn{frames: make(chan []byte, h.bufferSize()), done: make(chan struct{})}
|
||||
if err := h.register(nodeID, c); err != nil {
|
||||
return err
|
||||
}
|
||||
defer h.unregister(nodeID, c)
|
||||
|
||||
rc := http.NewResponseController(w)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
@@ -98,12 +111,6 @@ func (h *Hub) Serve(w http.ResponseWriter, r *http.Request, nodeID string) error
|
||||
return err
|
||||
}
|
||||
|
||||
c := &conn{frames: make(chan []byte, h.bufferSize()), done: make(chan struct{})}
|
||||
if err := h.register(nodeID, c); err != nil {
|
||||
return err
|
||||
}
|
||||
defer h.unregister(nodeID, c)
|
||||
|
||||
ticker := time.NewTicker(h.keepAlive())
|
||||
defer ticker.Stop()
|
||||
write := func(p []byte) error {
|
||||
@@ -185,6 +192,9 @@ func (h *Hub) Send(nodeID string, ev Event) error {
|
||||
ev.Timestamp = time.Now().Unix()
|
||||
}
|
||||
ev.NodeID = nodeID
|
||||
if h.SigningKey != nil {
|
||||
ev = SignEvent(ev, h.SigningKey)
|
||||
}
|
||||
frame, err := encode(ev)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -404,3 +404,16 @@ func TestHub_Disconnect(t *testing.T) {
|
||||
t.Errorf("zweites Disconnect: erwartet ErrNodeOffline, bekommen %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sobald der Client die Antwort-Header hat, muss der Knoten registriert sein –
|
||||
// sonst scheitert ein direkt folgender Request ("Stream nicht verbunden").
|
||||
func TestHub_ConnectedWhenHeadersArrive(t *testing.T) {
|
||||
for range 50 {
|
||||
h := &Hub{}
|
||||
srv, _ := hubServer(t, h)
|
||||
openStream(t, context.Background(), srv, "n")
|
||||
if !h.Connected("n") {
|
||||
t.Fatal("Header empfangen, Knoten aber noch nicht registriert")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrUnsigned: Das Event trägt keine Signatur.
|
||||
ErrUnsigned = errors.New("stream: Event nicht signiert")
|
||||
// ErrBadSignature: Die Signatur passt nicht (fremder Schlüssel oder verändertes Event).
|
||||
ErrBadSignature = errors.New("stream: Event-Signatur ungültig")
|
||||
)
|
||||
|
||||
const eventSignatureVersion = "SULTAN-EVENT-V1\n"
|
||||
|
||||
func eventMessage(ev Event) []byte {
|
||||
ev.Signature = ""
|
||||
b, _ := json.Marshal(ev)
|
||||
return append([]byte(eventSignatureVersion), b...)
|
||||
}
|
||||
|
||||
// SignEvent signiert ev (alle Felder außer der Signatur) mit dem Master-Key.
|
||||
func SignEvent(ev Event, key ed25519.PrivateKey) Event {
|
||||
ev.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(key, eventMessage(ev)))
|
||||
return ev
|
||||
}
|
||||
|
||||
// VerifyEvent prüft die Signatur mit dem Public Key des Master-Keys.
|
||||
func VerifyEvent(ev Event, pub ed25519.PublicKey) error {
|
||||
if ev.Signature == "" {
|
||||
return ErrUnsigned
|
||||
}
|
||||
sig, err := base64.StdEncoding.DecodeString(ev.Signature)
|
||||
if err != nil || len(sig) != ed25519.SignatureSize || len(pub) != ed25519.PublicKeySize {
|
||||
return ErrBadSignature
|
||||
}
|
||||
if !ed25519.Verify(pub, eventMessage(ev), sig) {
|
||||
return ErrBadSignature
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Befehle in node_command – eine feste Liste, keine beliebigen Kommandos.
|
||||
const (
|
||||
CmdResync = "resync" // Agent sendet sofort seinen Zustand
|
||||
CmdRotateKey = "rotate_key" // Agent erzeugt einen neuen node.key
|
||||
CmdDrain = "drain" // Hinweis: Routen entfernt
|
||||
CmdMaintenance = "maintenance" // Hinweis: Wartung angemeldet
|
||||
CmdResume = "resume" // Hinweis: Wartung/drain beendet
|
||||
CmdConfig = "config" // Server-Adressen (ServerURLs) für den Ausweichweg
|
||||
)
|
||||
|
||||
// Commands sind alle bekannten Befehle.
|
||||
var Commands = []string{CmdResync, CmdRotateKey, CmdDrain, CmdMaintenance, CmdResume, CmdConfig}
|
||||
|
||||
// NodeCommand ist die Payload eines node_command-Events.
|
||||
type NodeCommand struct {
|
||||
Command string `json:"command"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// ServerURLs: bei config die Adressen, unter denen der Server erreichbar ist.
|
||||
ServerURLs []string `json:"server_urls,omitempty"`
|
||||
}
|
||||
|
||||
// ParseNodeCommand liest die Payload eines node_command-Events.
|
||||
func ParseNodeCommand(ev Event) (NodeCommand, error) {
|
||||
if ev.Type != EventNodeCommand {
|
||||
return NodeCommand{}, fmt.Errorf("%w: kein node_command", ErrInvalidEvent)
|
||||
}
|
||||
var c NodeCommand
|
||||
if err := json.Unmarshal(ev.Payload, &c); err != nil || c.Command == "" {
|
||||
return NodeCommand{}, fmt.Errorf("%w: Befehl fehlt", ErrInvalidEvent)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSignAndVerifyEvent(t *testing.T) {
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
ev := Event{ID: "evt_1", Timestamp: 42, Type: EventNodeCommand, NodeID: "n1", Payload: json.RawMessage(`{"command":"resync"}`)}
|
||||
signed := SignEvent(ev, priv)
|
||||
if signed.Signature == "" {
|
||||
t.Fatal("keine Signatur")
|
||||
}
|
||||
if err := VerifyEvent(signed, pub); err != nil {
|
||||
t.Errorf("Verify: %v", err)
|
||||
}
|
||||
|
||||
otherPub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
for name, tc := range map[string]struct {
|
||||
ev Event
|
||||
pub ed25519.PublicKey
|
||||
err error
|
||||
}{
|
||||
"unsigniert": {ev, pub, ErrUnsigned},
|
||||
"fremder Schlüssel": {signed, otherPub, ErrBadSignature},
|
||||
"Payload geändert": {func() Event { e := signed; e.Payload = json.RawMessage(`{"command":"rotate_key"}`); return e }(), pub, ErrBadSignature},
|
||||
"Knoten geändert": {func() Event { e := signed; e.NodeID = "n2"; return e }(), pub, ErrBadSignature},
|
||||
"Signatur Müll": {func() Event { e := signed; e.Signature = "!!!"; return e }(), pub, ErrBadSignature},
|
||||
} {
|
||||
if err := VerifyEvent(tc.ev, tc.pub); !errors.Is(err, tc.err) {
|
||||
t.Errorf("%s: erwartet %v, bekommen %v", name, tc.err, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mit SigningKey signiert der Hub jedes Event – nachdem er ID, Zeitstempel
|
||||
// und Knoten-ID ergänzt hat, damit auch diese geschützt sind.
|
||||
func TestHub_SignsEvents(t *testing.T) {
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
h := &Hub{SigningKey: priv}
|
||||
srv, _ := hubServer(t, h)
|
||||
resp := openStream(t, context.Background(), srv, "n1")
|
||||
if err := h.Send("n1", Event{Type: EventNodeCommand, Payload: json.RawMessage(`{"command":"resync"}`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev, err := NewDecoder(resp.Body).Next()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ev.NodeID != "n1" || ev.ID == "" {
|
||||
t.Errorf("Event: %+v", ev)
|
||||
}
|
||||
if err := VerifyEvent(ev, pub); err != nil {
|
||||
t.Errorf("Signatur nach Übertragung ungültig: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeCommandPayload(t *testing.T) {
|
||||
ev, err := NewEvent(EventNodeCommand, NodeCommand{Command: CmdRotateKey, Reason: "Routine"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd, err := ParseNodeCommand(ev)
|
||||
if err != nil || cmd.Command != CmdRotateKey || cmd.Reason != "Routine" {
|
||||
t.Errorf("ParseNodeCommand: %+v %v", cmd, err)
|
||||
}
|
||||
if _, err := ParseNodeCommand(Event{Type: EventLeaseRenew}); err == nil {
|
||||
t.Error("falscher Event-Typ akzeptiert")
|
||||
}
|
||||
if _, err := ParseNodeCommand(Event{Type: EventNodeCommand, Payload: json.RawMessage(`{}`)}); err == nil {
|
||||
t.Error("Befehl ohne Namen akzeptiert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeCommandServerURLs(t *testing.T) {
|
||||
ev, _ := NewEvent(EventNodeCommand, NodeCommand{Command: CmdConfig, ServerURLs: []string{"https://a:8443", "https://b:8443"}})
|
||||
cmd, err := ParseNodeCommand(ev)
|
||||
if err != nil || cmd.Command != CmdConfig || len(cmd.ServerURLs) != 2 || cmd.ServerURLs[1] != "https://b:8443" {
|
||||
t.Errorf("config: %+v %v", cmd, err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user