883 lines
76 KiB
Markdown
883 lines
76 KiB
Markdown
# Sultan: High-Performance Distributed Ingress & ZeroTrust Service Discovery
|
||
|
||
Dieses Dokument beschreibt das vollständige Architektur-, Daten-, Netzwerk- und Anwendungsmodell des Projekts **Sultan**. Es dient als Kontext und Referenz für Software-Architekten und AI-gestützte Coding-Agenten (z. B. via Continue in VSCodium).
|
||
|
||
---
|
||
|
||
## 1. Executive Summary & Problemstellung
|
||
|
||
Klassische Ingress-Lösungen wie Nginx Proxy Manager (NPM) erfordern eine manuelle, GUI-basierte Konfiguration des Routings. In verteilten Umgebungen (Mehrere Proxmox-Cluster, Docker-Hosts, Bare-Metal-LAMP/Apache-Server, Kunden-Setups hinter DS-Lite/CGNAT) führt dies zu Dokumentationsbrüchen, toten Routen und hohem Wartungsaufwand.
|
||
|
||
**Sultan** löst dies durch eine entkoppelte, zweigeteilte Architektur:
|
||
1. **In-Service-Deklaration:** Backends deklarieren ihr Routing direkt am Dienst (native Traefik-Labels in Docker oder Drop-in-Konfigurationen unter `/etc/sultan.d/`).
|
||
2. **Dynamische Registrierung:** Ein leichtgewichtiger Agent überträgt diese Konfiguration kryptografisch gesichert über einen langlebigen, ressourcenschonenden Keepalive-Stream an den zentralen Ingress.
|
||
3. **ZeroTrust & Public Reachability:** Optionale Anbindung über ein WireGuard-Mesh (z. B. NetBird), um auch Hosts ohne statische IPv4/IPv6 oder hinter NAT/Firewalls öffentlich mit TLS bereitzustellen.
|
||
4. **Integrierte Autorisierung & Monetarisierung:** Mandantensichere Capability-Lizenzen via Ed25519-Kryptografie, Lease-System und automatisierte Überwachung von Lizenzfristen.
|
||
|
||
---
|
||
|
||
## 2. Netzwerkmodell (Network Model)
|
||
|
||
### 2.1 Topologie
|
||
|
||
```text
|
||
[ Öffentliches Internet ]
|
||
│
|
||
│ HTTP (80) / HTTPS (443) / TCP-Streams
|
||
▼
|
||
┌────────────────────────────────────────────────────────┐
|
||
│ Ingress-Knoten (Zentrales Rechenzentrum / Public IP) │
|
||
│ │
|
||
│ ┌────────────────────────────────────────────────┐ │
|
||
│ │ Traefik v3 (Ingress / ACME DNS-01 Validierung) │ │
|
||
│ └───────────────────────▲────────────────────────┘ │
|
||
│ │ HTTP Poll (:8080) │
|
||
│ │ (/v1/traefik-config) │
|
||
│ ┌───────────────────────┴────────────────────────┐ │
|
||
│ │ Sultan-Server (Registry / Policy Engine) │ │
|
||
│ └───────────────────────▲────────────────────────┘ │
|
||
│ │ │
|
||
│ │ Control Plane: │
|
||
│ │ TLS-Stream (:8443) │
|
||
│ │ (45s Heartbeat, │
|
||
│ │ Instant Event Push) │
|
||
│ │ │
|
||
│ ┌───────────────────────┴────────────────────────┐ │
|
||
│ │ NetBird / WireGuard Gateway (100.64.0.1) │ │
|
||
│ └───────────────────────▲────────────────────────┘ │
|
||
└───────────────────────────┼────────────────────────────┘
|
||
│
|
||
ZeroTrust WireGuard Tunnel (Traffic)
|
||
│
|
||
┌──────────────────┴──────────────────┐
|
||
│ │
|
||
┌────────┴──────────────┐ ┌──────────┴─────────────┐
|
||
│ Client-Host A │ │ Client-Host B │
|
||
│ (Bare-Metal / LAMP) │ │ (Docker-Compose Stack) │
|
||
│ │ │ │
|
||
│ ┌───────────────────┐ │ │ ┌────────────────────┐ │
|
||
│ │ sultan-agent │ │ │ │ sultan-agent │ │
|
||
│ │ (File-Watcher) │ │ │ │ (Docker-Socket) │ │
|
||
│ └───────────────────┘ │ │ └────────────────────┘ │
|
||
│ ┌───────────────────┐ │ │ ┌────────────────────┐ │
|
||
│ │ Apache / VHost │ │ │ │ Web-Container │ │
|
||
│ └───────────────────┘ │ │ └────────────────────┘ │
|
||
└───────────────────────┘ └────────────────────────┘
|
||
|
||
```
|
||
|
||
### 2.2 Datenfluss & Verbindungstypen
|
||
|
||
1. **Control Plane (Agent -> Server):**
|
||
* **Transport:** Ausgehende Verbindung vom Agenten zum Server über TCP `:8443` (TLS).
|
||
* **Firewall-Freundlichkeit:** Reine Outbound-Verbindung; funktioniert hinter DS-Lite, Mobilfunk, Carrier-Grade-NAT und Unternehmens-Firewalls ohne Portweiterleitung.
|
||
* **Keepalive-Mechanik:** Persistenter **SSE-Stream** (Server-Sent Events, `text/event-stream`) vom Server zum Agenten. Alle 45 Sekunden ohne Event wird die SSE-Kommentarzeile `:\n\n` gesendet. Verhindert das Schließen von State-Tables in NAT-Routern (Conntrack Timeout). Der Agent gilt als getrennt, wenn 90 s lang nichts ankommt (auch kein Keepalive), und verbindet sich mit exponentiellem Backoff (1 s … 60 s) neu.
|
||
* **Rückkanal (Agent -> Server):** Nicht über den Stream, sondern als eigene, signierte HTTPS-Requests (z. B. `service_state`).
|
||
* **Event-Driven Push:** Änderungen an Containern (Start/Stop) oder Notfall-Sperren werden verzögerungsfrei (Latenz < 5 ms) über den bestehenden Stream übertragen.
|
||
|
||
|
||
2. **Data Plane (Traffic: Besucher -> Backend):**
|
||
* **Direkt:** Liegen Ingress und Backend im selben VLAN/LAN, routet Traefik direkt auf die interne LAN-IP.
|
||
* **ZeroTrust Overlay (NetBird):** Sitzt das Backend im Fremdnetz/Homelab, routet Traefik auf die WireGuard-Overlay-IP (`100.64.x.y`). End-to-End verschlüsselt.
|
||
|
||
|
||
3. **Ingress zu Traefik:**
|
||
* Traefik pollt den lokalen Sultan-Server via HTTP-Provider: `GET http://127.0.0.1:8080/v1/traefik-config`.
|
||
* Intervall: 2–5 Sekunden. Traefik verarbeitet die In-Memory-Konfiguration ohne Neustart und ohne Verbindungsabbrüche.
|
||
* **Pull, nicht Push:** Traefik holt die Konfiguration ab; Sultan schreibt nie in Traefik und emuliert keinen Docker-Socket. Freigeschaltet wird das einmalig in Traefiks statischer Konfiguration (`--providers.http.endpoint`). Router aus Sultan heißen in Traefik `<name>@http`; Mischbetrieb mit dem Docker-Provider für lokale Container ist möglich (Querverweise mit Provider-Suffix, z. B. `auth@docker`).
|
||
* Port 8080 ist Klartext und deshalb nur an Loopback bzw. ein internes Docker-Netz zu binden. Achtung: Traefiks eigenes API/Dashboard nutzt per Default ebenfalls 8080.
|
||
|
||
|
||
|
||
---
|
||
|
||
## 3. Sicherheits- und Vertrauensmodell
|
||
|
||
### 3.1 Transport-Verschlüsselung (TLS ohne CA-Zirkus)
|
||
|
||
* **Grundsatz: Kein Agent-Verkehr im Klartext.** Durchgesetzt auf mehreren Ebenen:
|
||
* Der Agent-Port `:8443` wird ausschließlich per TLS bedient, **nur TLS 1.3**.
|
||
* Der Agent signiert nur Requests an `https`-URLs; an eine `http`-URL geht weder Signatur noch Body.
|
||
* Der Server authentifiziert nur Requests, die über TLS angekommen sind (sonst `426 Upgrade Required`).
|
||
* **Server-Zertifikat:** Wird beim ersten Start selbst erzeugt (selbstsigniert, ECDSA P-256, **keine CA**) und liegt dauerhaft unter `<data-dir>/server.key` (0600) und `server.crt`. Gültigkeit 20 Jahre, weil Agents den Fingerprint pinnen. Fehlt nur eine der beiden Dateien, startet der Server nicht, statt still ein neues Zertifikat zu erzeugen (das würde alle Pins brechen).
|
||
* **Fingerprint:** SHA-256 über das DER-Zertifikat, Base64url ohne Padding (43 Zeichen).
|
||
* **Vertrauensmodell: Pinning 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>[.<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.
|
||
3. Agent generiert lokalen Key (`node.key`) und sendet `sltn_enroll_<secret>` an den Server (`POST /api/v1/enroll`). Der Request ist bereits mit dem neuen Schlüssel signiert (Besitznachweis); der Public Key steht im Header `X-Sultan-Key`.
|
||
4. Server prüft die Signatur, löst den Token ein und legt den Knoten **in einer Transaktion** an. Der Mandant ergibt sich ausschließlich aus dem Token. Schlägt das Anlegen fehl, bleibt der Token gültig. Paralleles Einlösen desselben Tokens gelingt genau einmal.
|
||
5. *(Geplant)* Server signiert das initiale Berechtigungszertifikat (Capability Lease).
|
||
6. Alle künftigen Requests werden vom Agenten mit `node.key` signiert.
|
||
|
||
* **Request-Signatur** (jeder Agent-Request inkl. Stream-Aufbau):
|
||
* Header: `X-Sultan-Key` (Public Key, Base64), `X-Sultan-Timestamp` (Unix-Sekunden), `X-Sultan-Nonce` (16–64 Zeichen, pro Request neu), `X-Sultan-Signature` (Base64).
|
||
* Signiert wird (zeilengetrennt): `SULTAN-ED25519-V1`, Methode, Pfad+Query, Timestamp, Nonce, SHA-256-Hex des Bodys.
|
||
* **Server-Prüfreihenfolge:**
|
||
1. TLS
|
||
2. Signatur (ohne Datenbankzugriff)
|
||
3. Zeitfenster ±5 Minuten
|
||
4. Nonce (Replay-Schutz)
|
||
5. erst dann Nachschlagen des Knotens per Public Key
|
||
|
||
Gefälschte Requests kosten so keinen DB-Zugriff und können keine Nonces verbrauchen.
|
||
* Die **Knoten-ID** wird ausschließlich aus dem geprüften Schlüssel abgeleitet, nie aus einer Angabe des Clients.
|
||
* Body-Limit 1 MiB.
|
||
* Fehler-Mapping: 401 (nicht authentifiziert, Replay, Zeitabweichung), 403 (gesperrt), 413 (Body zu groß), 426 (kein TLS); Infrastrukturfehler 500.
|
||
* **Sperren:** Die Authentifizierung sperrt nur Mandanten im Status `quarantined`. Zahlungsverzug, Ablauf und gedrainte Knoten regelt die Policy-Engine über das Routing (5.2). Der Agent behält den Kanal, z. B. für `lease_renew` oder eine Reaktivierung. Für Entzug zur Laufzeit trennt der Server bestehende Streams aktiv (`Hub.Disconnect`).
|
||
|
||
|
||
|
||
### 3.3 Autorisierung & Domain-Ownership-Schutz
|
||
|
||
Jeder Payload vom Agenten an den Server muss vom Server gegen die zugewiesene Policy geprüft werden:
|
||
|
||
* **Domain Spoofing Protection:** Beansprucht ein Agent ein Label `Host('kunde.de')`, prüft der Server, ob `kunde.de` im Feld `allowed_domains` der Mandanten-Lizenz hinterlegt ist. Falls nicht: Ablehnung mit Sicherheits-Audit-Log.
|
||
* **Route Limits:** Übersteigt die Anzahl der deklarierten Routen das Limit (`max_routes`), werden überzählige Routen verworfen (deterministisch: nach Router-Namen sortiert, die hinteren fallen weg).
|
||
|
||
Umsetzung in `pkg/policy`. Jeder Verstoß entfernt nur den betroffenen Router und wird als Violation (Audit-Log) gemeldet:
|
||
|
||
* **Regel-Analyse statt Textsuche:** Traefik-Regeln werden geparst (Matcher, `&&`, `||`, `!`, Klammern).
|
||
* Jeder `||`-Zweig muss durch `Host(...)` eingeschränkt sein. ``Host(`kunde.de`) || PathPrefix(`/`)`` würde sonst den Verkehr **aller** Mandanten abgreifen.
|
||
* Negierte Hosts schränken nicht ein.
|
||
* `HostRegexp` ist nicht erlaubt, weil sich ein Regex nicht gegen `allowed_domains` prüfen lässt.
|
||
* **Namensraum:** Router, Services und Middlewares heißen in der ausgelieferten Konfiguration `<tenant>__<node>__<name>`.
|
||
* Mandanten können sich damit weder überschreiben noch gegenseitig referenzieren.
|
||
* IDs: alphanumerisch mit `-`/`_`, nicht am Rand, kein `__`.
|
||
* Das Präfix `sultan__` und die Mandanten-ID `sultan` sind reserviert.
|
||
* **Fremde Provider:** Referenzen mit `@` (z. B. `api@internal` = Traefik-Dashboard) sind verboten, außer sie stehen in einer Freigabeliste des Admins (z. B. `auth@file`).
|
||
* **Middleware-Ziele begrenzen (geplant):** Middlewares mit eigenem Netzwerkziel (z. B. `forwardAuth`, Redirects auf andere Hosts) sollen auf das eigene Backend des Dienstes beschränkt werden können.
|
||
* Grenze: das Tupel `ip:port`, `[ipv6]:port` bzw. `fqdn:port`, eventuell plus höchstens ein Pfadsegment.
|
||
* Redirect/Auth auf einen Host derselben Zone (z. B. `acronym.meine.home.lab` → `acronym-service.meine.home.lab`) soll möglich bleiben, um Zertifikate zu erleichtern.
|
||
* Aktivierung **per Label**; zunächst im **Beobachtungsmodus** (nur Audit-Log, keine Sperre).
|
||
* Zunächst werden nur die mitgelieferten Redirects (to-www/to-non-www) angeboten; alles andere wird **geduldet**.
|
||
* **Umgesetzt ist die Andockstelle** `policy.Engine.MiddlewareTargetCheck(mwName, def, backend)`:
|
||
* nil (Default): alles dulden
|
||
* mit Prüffunktion und `EnforceMiddlewareTargets = false`: Verstoß nur melden (`Observed`)
|
||
* mit `true`: Router entfernen
|
||
* Regeln und Label-Name folgen später.
|
||
* **Middleware-Gates:**
|
||
* Ohne Feature `custom_middleware` sind nur Basistypen erlaubt: redirectScheme, redirectRegex, stripPrefix(Regex), addPrefix, replacePath(Regex), compress, headers.
|
||
* `chain` und `errors` sind **immer** verboten, weil sie per Namen auf andere Middlewares/Services verweisen und den Namensraum umgehen könnten.
|
||
* Nicht referenzierte Services/Middlewares werden nicht ausgeliefert.
|
||
|
||
### 3.4 Bedrohungsmodell: Erstkontakt (TOFU) und Server-Events
|
||
|
||
* **Schwäche von reinem TOFU:** Beim ersten Kontakt wird ungeprüft vertraut. Ein Angreifer im Netzwerkweg (ARP-Spoofing im LAN, DNS-Manipulation, kompromittierter Router, offenes WLAN) würde dauerhaft gepinnt. Er könnte damit:
|
||
* den Enrollment-Token abgreifen und mit eigenem Schlüssel zuerst einlösen,
|
||
* sich dauerhaft dazwischenschalten (der echte Server gilt danach als „Fälschung“),
|
||
* unsignierte Server-Events (`node_command`, `lease_renew`) einschleusen.
|
||
* **Gegenmaßnahme (umgesetzt):** Der Fingerprint ist im Enrollment-Token eingebettet (3.1/3.2). Damit gibt es keinen ungeprüften Erstkontakt mehr. Ein Angreifer scheitert im TLS-Handshake und sieht weder Token noch Signatur; der Token bleibt beim echten Server einlösbar.
|
||
* **Betriebsrisiko:** Ein Verlust von `<data-dir>/server.key` erzeugt ein neues Zertifikat und sperrt alle Agents aus. Deshalb muss `server.key` gesichert werden. *(Geplant:)* geordneter Zertifikatswechsel, bei dem der Server das neue Zertifikat vorab über den gepinnten Kanal ankündigt.
|
||
* **Zweite Schicht (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.
|
||
|
||
---
|
||
|
||
## 4. Datenmodell (Data Specifications)
|
||
|
||
### 4.1 Lizenz & Capabilities (`license.json`)
|
||
|
||
```json
|
||
{
|
||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||
"title": "SultanCapabilities",
|
||
"type": "object",
|
||
"properties": {
|
||
"tenant_id": { "type": "string" },
|
||
"tier": { "type": "string", "enum": ["community", "pro", "enterprise"] },
|
||
"max_routes": { "type": "integer", "description": "-1 für unbegrenzt" },
|
||
"allowed_domains": {
|
||
"type": "array",
|
||
"items": { "type": "string" }
|
||
},
|
||
"features": {
|
||
"type": "array",
|
||
"items": { "type": "string", "enum": ["websockets", "tcp_stream", "custom_middleware"] }
|
||
},
|
||
"valid_until": { "type": "string", "format": "date-time" },
|
||
"signature": { "type": "string", "description": "Ed25519 Signatur des Server-Master-Keys über den Payload" }
|
||
},
|
||
"required": ["tenant_id", "tier", "max_routes", "allowed_domains", "valid_until", "signature"]
|
||
}
|
||
|
||
```
|
||
|
||
* **Signatur:** Ed25519 mit dem Server-Master-Key.
|
||
* Signiert wird `SULTAN-LICENSE-V1\n` + JSON der Felder in fester Reihenfolge (`tenant_id`, `tier`, `max_routes`, `allowed_domains`, `features`, `valid_until`).
|
||
* `valid_until` geht als RFC 3339 in UTC mit ganzen Sekunden ein.
|
||
* Jede Änderung eines Felds, auch die Reihenfolge der Domains, macht die Signatur ungültig.
|
||
* Das Präfix trennt Lizenz-Signaturen von künftigen anderen Nutzungen desselben Master-Keys (z. B. signierte Server-Events, 3.4).
|
||
* **Einlesen:** Unbekannte Felder werden abgelehnt; `features` fehlt → leere Liste.
|
||
* **`allowed_domains`:** Kleinbuchstaben, ohne Punkt am Ende, IDN als Punycode (`xn--…`).
|
||
* `shop.de` deckt **genau** `shop.de`.
|
||
* `*.firma.de` deckt **jede Subdomain beliebiger Tiefe**, aber **nicht** `firma.de` selbst.
|
||
* Wildcards nur ganz vorne; `*.de` (Wildcard auf TLD) und `*` sind unzulässig.
|
||
* **Ablauf:** Der Zeitpunkt `valid_until` selbst gilt als abgelaufen. „Verbleibende Tage“ (Checkmk) = abgerundete volle Tage, nach Ablauf negativ.
|
||
* **Routen:** `max_routes = -1` bedeutet unbegrenzt.
|
||
|
||
### 4.1a Leases (Entscheidung 2026-10-01)
|
||
|
||
* Eine **Lease ist eine kurzlaufende, signierte Kopie der Lizenz** (gleiches Format, eigenes `valid_until`). Sie ist der „Ausweis“, mit dem ein Knoten Routen bekommt.
|
||
* **Kommerziell:** Laufzeit **7 Tage**, Erneuerung nach rund 2/3 (ab Tag 4–5); bei Wartung/Baustelle 10 Tage, Erneuerung ab Tag 5. Angelehnt an ACME (90 Tage, Erneuerung nach ~60). Die Lease endet spätestens mit dem bezahlten Abo-Zyklus (`valid_until` der Lizenz).
|
||
* **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` (umgesetzt 2026-10-02)
|
||
|
||
* **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 # 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
|
||
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"
|
||
```
|
||
|
||
* **Ü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)
|
||
|
||
Beide Richtungen nutzen dasselbe JSON-Event-Format. Server→Agent läuft über den SSE-Stream; jedes Event ist ein SSE-Rahmen `id: <event_id>`, `event: <type>`, `data: <JSON in einer Zeile>`, gefolgt von einer Leerzeile (max. 1 MiB). Agent→Server läuft über signierte HTTPS-Requests. `node_id` setzt der Server immer selbst aus der geprüften Identität.
|
||
|
||
```json
|
||
{
|
||
"event_id": "evt_01J8Z9X2Y3...",
|
||
"timestamp": 1727618531,
|
||
"type": "service_state | lease_renew | node_command | error",
|
||
"node_id": "node-proxmox-vm-42",
|
||
"payload": {}
|
||
}
|
||
|
||
```
|
||
|
||
* **Typ `service_state` (Agent -> Server):**
|
||
Enthält die vollständige Liste der aktuell aktiven Traefik-Labels des Knotens.
|
||
* **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.
|
||
|
||
### 4.3 SQLite Datenbankschema (Server State)
|
||
|
||
Der Server persistiert Mandanten, Tokens und Richtlinien lokal in SQLite (`<data-dir>/sultan.db`, Default-Datenverzeichnis `./suldata`).
|
||
|
||
* **Treiber:** `modernc.org/sqlite` (reines Go, kein cgo → statisches Single Binary).
|
||
* **Pragmas:** `foreign_keys=ON` (sonst greift `ON DELETE CASCADE` nicht), WAL, `busy_timeout`, Schreib-Transaktionen `IMMEDIATE`.
|
||
* **Migrationen:** Versioniert über `PRAGMA user_version`, nur anhängen. Eine Datenbank mit neuerer Version als das Programm wird abgelehnt.
|
||
* **Zeitstempel:** Die Anwendung setzt sie selbst (keine `CURRENT_TIMESTAMP`-Defaults), als UTC-Text fester Breite (`2006-01-02T15:04:05.000000000Z`). Dadurch funktionieren Vergleiche direkt in SQL.
|
||
* **Tokens:** Gespeichert wird nur der SHA-256-Hash.
|
||
* **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):
|
||
|
||
```sql
|
||
-- Mandanten & Abonnements
|
||
CREATE TABLE tenants (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
email TEXT NOT NULL,
|
||
tier TEXT NOT NULL DEFAULT 'community',
|
||
max_routes INTEGER NOT NULL DEFAULT 5,
|
||
allowed_domains TEXT NOT NULL, -- JSON-Array: ["*.firma.de", "shop.de"]
|
||
valid_until DATETIME NOT NULL, -- Ablaufdatum des Vertrags / Abos
|
||
status TEXT NOT NULL DEFAULT 'active', -- active, suspended_payment, quarantined, expired
|
||
created_at DATETIME NOT NULL, -- von der Anwendung gesetzt (UTC-Text)
|
||
updated_at DATETIME NOT NULL
|
||
);
|
||
|
||
-- Registrierte Agenten / Knoten
|
||
CREATE TABLE nodes (
|
||
id TEXT PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
hostname TEXT NOT NULL,
|
||
public_key TEXT NOT NULL UNIQUE, -- Ed25519 Public Key des Agenten
|
||
last_seen DATETIME NOT NULL,
|
||
ip_address TEXT NOT NULL,
|
||
status TEXT NOT NULL DEFAULT 'online' -- online, offline, drained
|
||
);
|
||
CREATE INDEX nodes_tenant_id ON nodes(tenant_id);
|
||
|
||
-- Einmalige Enrollment-Tokens
|
||
CREATE TABLE enrollment_tokens (
|
||
token_hash TEXT PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||
expires_at DATETIME NOT NULL,
|
||
used INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
CREATE INDEX enrollment_tokens_tenant_id ON enrollment_tokens(tenant_id);
|
||
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Anwendungsmodell (Application Model)
|
||
|
||
Das gesamte System ist als **Single-Binary-Architektur** in Go implementiert:
|
||
|
||
```text
|
||
sultan
|
||
├── server # Führt Registry, Stream-Hub, Policy-Engine & Traefik-Provider aus
|
||
├── agent # Führt Docker-/File-Watcher aus und verbindet sich zum Server
|
||
└── token # CLI-Tools zur Schlüsselverwaltung, Enrollment- & Lizenzausstellung
|
||
|
||
```
|
||
|
||
**Umsetzung:** `main.go` ist eine dünne Hülle (Signale → `pkg/cli.Run`); `pkg/cli` parst Befehle und Flags; `pkg/server` verdrahtet Store, Zertifikat, Master-Key, Hub, Verifier, Registry und Policy.
|
||
|
||
**Datenverzeichnis:** Default **`./suldata`** (relativ zum Arbeitsverzeichnis), für Server **und** Agent; per `--data-dir` änderbar. Es wird mit Rechten 0700 angelegt und enthält Schlüssel. Es gehört gesichert und nie ins Git (steht in `.gitignore`).
|
||
|
||
**`sultan server` – Flags und Defaults:**
|
||
|
||
| Flag | Default | Bedeutung |
|
||
|---|---|---|
|
||
| `--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. |
|
||
| `--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:**
|
||
|
||
| Port | Route | Zweck |
|
||
|---|---|---|
|
||
| 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 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:** liest `/etc/sultan.d/*.yaml` (Format 4.1b) per Polling, inkl. Healthchecks – umgesetzt (Teil 2).
|
||
|
||
|
||
2. **State Serializer:**
|
||
* Extrahiert Services, Host-Ports und Routen.
|
||
* Injiziert automatisch die Ziel-IP (LAN-IP oder NetBird-IP) in die Server-URL-Definitionen für Traefik.
|
||
|
||
|
||
3. **Transport Engine:**
|
||
* Verwaltet den permanenten TLS-Stream mit 45s-Keepalive.
|
||
* Führt automatischen Reconnect mit exponentiellem Backoff durch.
|
||
|
||
|
||
|
||
### 5.2 Server-Subsysteme
|
||
|
||
1. **Stream Hub:**
|
||
* Hält offene Sockets aller Agents im RAM.
|
||
* Verarbeitet State-Pushes und führt Dead-Node-Detection durch (Timeout: 90s).
|
||
|
||
|
||
2. **Dynamic Config Aggregator:**
|
||
* Führt die flachen Traefik-Labels aller aktiven Knoten zusammen.
|
||
* Wandelt die Labels in das verschachtelte Traefik-JSON-Format um.
|
||
* Stellt `GET /v1/traefik-config` bereit.
|
||
|
||
|
||
3. **Policy & Interception Engine:**
|
||
* **Sperrung bei Zahlungsverzug:** Modifiziert den Traefik-Router auf einen internen Redirect zu einer Bezahlseite (`HTTP 402 / Payment Required`).
|
||
* **Notfall-Wartung:** Schlägt das Backend fehl oder meldet der Agent `service_down`, liefert Traefik eine statische Statusseite mit `Retry-After: 60` (`HTTP 503`).
|
||
* **Stufen (`pkg/policy.StageFor`)**, Reihenfolge der Prüfung:
|
||
|
||
| Bedingung | Stufe | Wirkung |
|
||
|---|---|---|
|
||
| Status `quarantined` | `blocked` | keine Routen |
|
||
| Status `expired` oder Lizenz abgelaufen | `landing` | Informations-Landingpage |
|
||
| Tier `community` | `normal` | **kein Mahnwesen** |
|
||
| Status `suspended_payment` oder Mahnstufe ≥ 3 | `paywall` | Kunde ist offline: freundliche Einladung des Hosters, den Sultan-Service und weitere Angebote kennenzulernen (402), **kein Durchlass** |
|
||
| Mahnstufe 2 | `firemark` | Hinweisseite, dann Weiterleitung zum Original (längere Wartezeit) |
|
||
| 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** (`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.
|
||
* **Mahnhinweis mit Durchlass (watermark/firemark):**
|
||
1. Der Lander zeigt den Hinweis und leitet per `<meta http-equiv="refresh" content="<x>;url=<ursprüngliche URL>">` zurück. Wartezeit konfigurierbar, Default **5 s (Stufe 1)** und **10 s (Stufe 2)**.
|
||
2. Dabei setzt er das Cookie `sultan_ack=<stufe>` auf der Mandanten-Domain, gültig **60 Minuten** (durchschnittlicher Besuch). Der Hinweis darf also mehrmals am Tag erscheinen.
|
||
3. Die Policy erzeugt dafür einen zweiten Router `sultan__<tenant>__<node>__<router>` mit der Regel ``(<Originalregel>) && HeaderRegexp(`Cookie`, `(^|;\s*)sultan_ack=<stufe>(;|$)`)``. Er führt direkt zum Original, sonst würde die Weiterleitung wieder beim Lander landen (Schleife).
|
||
4. Priorität: Ohne gesetzte Priorität gewinnt der Durchlass über die längere Regel, sonst über Priorität+1.
|
||
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-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.
|
||
|
||
|
||
|
||
---
|
||
|
||
## 6. Überwachung, Lizenzfristen & Alerting (Checkmk-Integration)
|
||
|
||
Um zu verhindern, dass Kunden-Instanzen bei Ablauf von Quartals- oder Jahresabonnements unerwartet in den Wartungsmodus fallen, implementiert der Sultan-Server Schnittstellen für **Checkmk** und externe Alerting-Pipelines.
|
||
|
||
### 6.1 Checkmk Local Check Protocol
|
||
|
||
Der Server stellt einen internen Management-Endpunkt bereit: `GET /metrics/checkmk` (oder auslesbar über ein lokales Checkmk-Agent-Plugin-Script `/usr/lib/check_mk_agent/local/sultan_licenses`).
|
||
|
||
Das Ausgabeformat entspricht der nativen Checkmk Local-Check-Syntax:
|
||
|
||
```text
|
||
# Syntax: <Status> <ServiceName> <Metriken> <Nachrichtentext>
|
||
# 0 = OK, 1 = WARN, 2 = CRIT, 3 = UNKNOWN
|
||
|
||
0 "Sultan_Tenant_kunde_schmidt" days_left=74;30;14 OK - Abo aktiv bis 2026-12-15 (74 Tage verbleibend)
|
||
1 "Sultan_Tenant_meier_gmbh" days_left=12;30;14 WARN - Abo laeuft in 12 Tagen ab! Verlaengerung erforderlich.
|
||
2 "Sultan_Tenant_alt_ag" days_left=0;30;14 CRITICAL - Abo seit 1 Tagen abgelaufen! Status: suspended_payment
|
||
|
||
```
|
||
|
||
**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.
|
||
* Checkmk triggert Alarm: `CRITICAL - Service offline geschaltet`.
|
||
|
||
|
||
|
||
---
|
||
|
||
## 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
|
||
services:
|
||
traefik:
|
||
image: traefik:v3.1
|
||
container_name: traefik-ingress
|
||
restart: unless-stopped
|
||
network_mode: host
|
||
command:
|
||
- "--providers.http.endpoint=http://127.0.0.1:8080/v1/traefik-config"
|
||
- "--providers.http.pollInterval=3s"
|
||
- "--entrypoints.web.address=:80"
|
||
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
|
||
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
|
||
- "--entrypoints.websecure.address=:443"
|
||
- "--certificatesresolvers.dns-resolver.acme.dnschallenge=true"
|
||
- "--certificatesresolvers.dns-resolver.acme.dnschallenge.provider=rfc2136"
|
||
volumes:
|
||
- ./acme.json:/etc/traefik/acme.json
|
||
|
||
sultan-server:
|
||
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",
|
||
"--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!
|
||
|
||
```
|
||
|
||
### 7.2 Compose-Setup: Kunden-Host (Backend mit Docker)
|
||
|
||
```yaml
|
||
services:
|
||
sultan-agent:
|
||
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"
|
||
- "--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 node.json – persistent halten!
|
||
|
||
kunde-wordpress:
|
||
image: wordpress:latest
|
||
restart: unless-stopped
|
||
labels:
|
||
- "traefik.enable=true"
|
||
- "traefik.http.routers.wp.rule=Host(`kunde.de`) || Host(`www.kunde.de`)"
|
||
- "traefik.http.routers.wp.entrypoints=websecure"
|
||
- "traefik.http.routers.wp.tls=true"
|
||
- "traefik.http.routers.wp.tls.certresolver=dns-resolver"
|
||
- "traefik.http.middlewares.wp-www.redirectregex.regex=^https?://kunde\\.de/(.*)"
|
||
- "traefik.http.middlewares.wp-www.redirectregex.replacement=https://www.kunde.de/$${1}"
|
||
- "traefik.http.middlewares.wp-www.redirectregex.permanent=true"
|
||
- "traefik.http.routers.wp.middlewares=wp-www"
|
||
- "traefik.http.services.wp.loadbalancer.server.port=80"
|
||
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Architekturentscheidungen (Entscheidungslog)
|
||
|
||
| Datum | Entscheidung | Begründung |
|
||
|---|---|---|
|
||
| 2026-09-30 | Traefik bezieht die Konfiguration per **HTTP-Provider (Pull)** von `/v1/traefik-config`; kein Docker-Socket-Nachbau. | Gleichwertiger Traefik-Provider; ein Socket-Nachbau wäre fragil. Remote-Docker-Provider scheidet aus (Root-Zugriff auf Kunden-Hosts, eingehende Ports, nur ein Endpunkt). |
|
||
| 2026-09-30 | Labels → Traefik-Konfiguration: Router/Services typisiert, **Middleware-Optionen als unveränderte Label-Strings** durchgereicht; nicht unterstützte Labels (TCP/UDP, unbekannte Attribute) werden abgelehnt statt ignoriert. Nur mit `traefik.enable=true`. | Traefik parst Middleware-Werte beim Einlesen wie Labels; Sultan muss nicht jeden Middleware-Typ kennen. |
|
||
| 2026-09-30 | Server→Agent-Stream als **SSE**; Agent→Server über eigene signierte Requests. | Das Keepalive `:\n\n` ist SSE-Syntax; SSE ist proxy- und HTTP/1.1-tauglich. |
|
||
| 2026-10-01 | Tenant-Status **`expired`** als vierter gültiger Wert. | Abschnitt 6.2 setzt ihn bei Ablauf. |
|
||
| 2026-10-01 | SQLite über **`modernc.org/sqlite`**. | Kein cgo, statisches Single Binary. |
|
||
| 2026-10-01 | **Ed25519-Request-Signatur in jedem Tier**, auch `community`. | Kein ungeschützter Homelab-Modus; einheitliches Sicherheitsniveau. |
|
||
| 2026-10-01 | Authentifizierung sperrt nur `quarantined`; übrige Sperren über Routing-Policy. | Agent braucht den Kanal weiterhin (Lease, Reaktivierung). |
|
||
| 2026-10-01 | **Kein Klartext für Agent-Verkehr**: TLS 1.3 only, Signer nur für `https`, Verifier nur mit TLS. | Signaturen und Labels dürfen nie unverschlüsselt übertragen werden. |
|
||
| 2026-10-01 | **Token-Format `sltn_enroll_<secret>.<fingerprint>`**, Pinning mit geprüftem Erstkontakt statt blindem TOFU. | Schließt die Man-in-the-Middle-Lücke beim Erstkontakt (3.4). Entscheidet zugleich den früheren Widerspruch `sltn_token_` vs. `sltn_enroll_`. |
|
||
| 2026-10-01 | Lizenz-Signatur mit Versionspräfix `SULTAN-LICENSE-V1`; Wildcard `*.domain` deckt alle Subdomain-Ebenen, nicht die Apex-Domain; `*.<tld>` verboten. | Eindeutige, manipulationssichere Kodierung; Wildcard-Semantik entspricht der Erwartung „Mandant besitzt die Zone“, ohne versehentlich ganze TLDs freizugeben. |
|
||
| 2026-10-01 | **Leases**: signierte Lizenz-Kopie, 7 Tage (Wartung 10), Erneuerung nach ~2/3; kommerziell an Abo-Zyklus gebunden; Community automatisch neu ausgestellt; jederzeit serverseitig unterbrechbar. | Kurze Laufzeit begrenzt Schaden bei Missbrauch; Sperren wirken über Polling ohnehin sofort (ACME-Analogie). |
|
||
| 2026-10-01 | **Eskalatives Mahnwesen nur kommerziell**: Wasserzeichen → Firemark → Paywall (402); umgesetzt über einen Sultan-Interceptor, zu dem die Policy umleitet. | Mehrstufige Dringlichkeit analog zu Mahnstufen; Traefik selbst kann Seiteninhalte nicht verändern. |
|
||
| 2026-10-01 | **Policy parst Regeln** und verlangt Host-Einschränkung in jedem `\|\|`-Zweig; `HostRegexp` verboten; Namensraum `<tenant>__<node>__<name>`; `@`-Referenzen nur per Freigabeliste; `chain`/`errors` verboten. | Verhindert Catch-all-Übernahmen, Zugriff auf `api@internal` und Querverweise zwischen Mandanten. |
|
||
| 2026-10-01 | `/etc/sultan.d` als **YAML** mit Komfortschaltern + rohen Labels; Healthcheck (Status + einfache Inhaltsprüfung) im YAML; Interface/Port konfiguriert. | Für Bare-Metal-Admins lesbarer als Labels; Ports sind nicht sicher erratbar. |
|
||
| 2026-10-01 | **Benachrichtigung:** Community per Webhook; kommerziell SMTP an Mandanten + Ticketsystem (Zammad per Webhook/API). | Homelab braucht keinen Mailserver; kommerziell nachvollziehbare Kommunikation. |
|
||
| 2026-10-01 | **Admin-Zugriff:** Community nur lokale CLI; kommerziell zusätzlich Admin-API (Rechnungssystem, Ansible). | Automatisierte Lizenzpflege im kommerziellen Betrieb. |
|
||
| 2026-10-01 | **Community-Lease 14 Tage**, Erneuerung ~7 Tage mit Streuung; Wartung bis 7 Tage ohne Invalidierung, solange 50x-Lander mit Pro-Hinweis. | Homelab-Betrieb robust gegen Server-Wartung; Upsell statt Ausfall. |
|
||
| 2026-10-01 | **Mahnstufen 8/25/32 Tage** (Bank-Zahlungsfristen), vom Server berechnet. **Watermark/Firemark als Hinweisseite mit `meta refresh`** statt Einblendung ins Original; Durchlass per Cookie `sultan_ack=<stufe>` über einen zweiten Router. | Deutlich einfacher als Seiten umzuschreiben; Cookie verhindert die Weiterleitungsschleife. |
|
||
| 2026-10-01 | **Middleware-Zielbegrenzung** vorgesehen (Backend-Tupel `host:port`, per Label, erst Beobachtungsmodus). | SSRF-Risiko durch `forwardAuth` u. ä.; Regeln erst nach Beobachtung festlegen. |
|
||
| 2026-10-01 | **Admin-API mit API-Token**; TLS des Admin-Interfaces über ACME (öffentliches Zertifikat via Traefik), kein Pinning nötig. | Gängige Praxis für Rechnungssystem/Ansible; ACME ist auf dem Ingress ohnehin vorhanden. |
|
||
| 2026-10-01 | Mahnhinweis-Weiterleitung **5 s / 10 s**, Cookie **60 min**; Stufe 3 ohne Durchlass = Kunde offline mit Hoster-Einladung. | Hinweis pro Besuch statt pro Request; Eskalation endet mit Abschaltung nach zwei Checkmk-Alarmen. |
|
||
| 2026-10-01 | **Lander:** eingebaute minimale HTML-Seiten (de_DE/en_US, ohne CSS); optional eigene Vorlagen aus gemountetem Verzeichnis, max. 1 HTML-Vorlage + 1 CSS + 1 Grafik pro Seite. | Funktioniert ohne Konfiguration; Anpassung möglich ohne komplexen Webserver. |
|
||
| 2026-10-01 | Middleware-Zielbegrenzung als **Andockstelle mit Beobachtungs-/Durchsetzungsmodus**, Default dulden. | Erst beobachten, dann Regeln festlegen. |
|
||
| 2026-10-01 | **Registry**: `service_state` = vollständiger Schnappschuss je Knoten (Workloads mit Host + Labels, Liste ausgefallener Services); Workloads einzeln übersetzt, bei Namenskonflikt innerhalb eines Knotens gewinnt der alphabetisch erste; fail-static bei Directory-Ausfall; Verstöße nur beim ersten Auftreten melden. | Robust gegen Teilausfälle; Audit-Log ohne Wiederholungsflut. |
|
||
| 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. |
|
||
| 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: eigener **Zot**-Server `pod.chmln.de`, Image `pod.chmln.de/sultan` (ohne Owner-Ebene, daher kein Rewrite-Proxy nötig); Zugangsdaten für den Push nur in `deploy/.env` (gitignored), ohne Passwort bricht `push-image.sh` ab. (Ursprünglich geplant: Gitea-Registry hinter Rewrite-Proxy – verworfen.) 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; eigene Registry mit kurzen Image-Namen ohne Proxy-Tricks. |
|
||
| 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. |
|
||
|
||
---
|
||
|
||
## 9. Umsetzungsstand & Roadmap
|
||
|
||
**Umgesetzt (Go-Packages, testgetrieben):**
|
||
|
||
| Package | Inhalt |
|
||
|---|---|
|
||
| `pkg/traefik` | Labels → Traefik-Konfiguration, Merge, JSON/YAML |
|
||
| `pkg/stream` | SSE-Hub (Server) und Client (Agent) inkl. Keepalive, Backoff, Disconnect |
|
||
| `pkg/store` | SQLite: Mandanten, Knoten, Enrollment |
|
||
| `pkg/auth` | Ed25519-Request-Signatur, Verifier, Schlüsseldateien |
|
||
| `pkg/crypto` | Server-Zertifikat, TLS 1.3, Pinning, Enrollment-Token |
|
||
| `pkg/license` | Lizenz ausstellen, prüfen, Domain/Routen/Feature-Checks |
|
||
| `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`, `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 in dieser Reihenfolge (festgelegt 2026-10-02):**
|
||
|
||
**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, 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):**
|
||
|
||
* Middleware-Zielbegrenzung: genaue Grenze (Pfadsegment? Zonen-Redirects?), Label-Name, Wechsel vom Beobachtungs- in den Sperrmodus.
|