Files
sultan/.ai/sultan-system-architecture.md
T

1004 lines
104 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
* **TCP / TLS-Passthrough (umgesetzt 2026-10-11):** für Dienste mit eigener CA bzw. eigener TLS-Verwaltung (z. B. tinyCA im Kundenlabor). Traefik routet per SNI und reicht die TLS-Verbindung unverändert zum Backend durch.
* Labels: `traefik.tcp.routers.<n>.{rule,entrypoints,service,priority,tls,tls.passthrough}` und `traefik.tcp.services.<n>.loadbalancer.server.port`. TCP-Middlewares, TLS-Terminierung (certresolver/options), proxyProtocol und UDP bleiben `ErrUnsupportedLabel`.
* Nur mit Feature **`tcp_stream`** (in jedem Tier vergebbar, auch `community`).
* Nur **`tls.passthrough=true`**, nur Entrypoint **`websecure`** (wird immer gesetzt; ein anderer Entrypoint ist ein Verstoß).
* Regel: Jeder `||`-Zweig muss durch `HostSNI(...)` beschränkt sein, **nur explizite Hosts**: ``HostSNI(`*`)``, Wildcards und `HostSNIRegexp` sind verboten. Jeder Host muss in `allowed_domains` stehen; eine Wildcard dort (`*.lab.kunde.de`) deckt einzelne Hosts darunter.
* Jeder TCP-Router zählt gegen `max_routes`; HTTP-Router werden zuerst vergeben, danach TCP (jeweils nach Namen).
* Gleicher Namensraum `<tenant>__<node>__<name>`.
* **Stufen:** Ohne HTTP gibt es keinen Interceptor. Mahnstufe 1/2 lassen durch; ab Paywall (auch `landing`) sowie bei jeder Wartung (Knoten, Dienst, Healthcheck, getrennter Knoten) wird die Route **entfernt**; Sperre/drain wie bei HTTP.
### 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 |
| `--fqdn` / `SULTAN_FQDN` | automatisch | Name des Hosts fürs Inventar (5.3), nur Information |
* **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.
* **Gestoppt vs. entfernt:** Der Agent listet auch gestoppte Container (`/containers/json?all=1`).
* **Gestoppt** (`docker stop`, Absturz, `restarting`, `paused`, `created`): Der Container wird weiter gemeldet, seine Services stehen in `down_services` → **Wartungsseite (503)** statt 404. Die Ports stammen aus `HostConfig.PortBindings` (die Liste nennt bei gestoppten Containern keine Ports).
* **Entfernt** (`docker rm`, `docker compose down`): Die Route verschwindet – bewusstes Abschalten.
* Teilt ein gestoppter Container Router- oder Service-Namen mit einem laufenden (z. B. alte Kopie), entfällt der gestoppte.
* **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.
### 5.3 Accountant: zentrales Inventar & Abrechnung (Entscheidung 2026-10-16; 6a–6c umgesetzt)
**Zweck (Priorität):** (1) Im Störfall sofort wissen, auf welchem Backend-Host und in welchem Compose-Stack eine URL läuft – ohne Umweg über DNS, Reverse-Proxy und VPN-Suche. (2) Inventar je Mandant (Grundlage für spätere Statusberichte an Mandanten). (3) Abrechnung markierter Services.
**Rollen:**
* **Accountant** – `sultan accountant` (gleiches Binary, eigene Rolle): nimmt Berichte entgegen, führt Inventar und Historie. Eigenes Datenverzeichnis mit **eigener `accountant.db`**, eigenem TLS-Zertifikat (gepinnt wie beim Server) und eigenem Agent-Port (Default `:8444`).
* **Payee** – ein normaler `sultan server` (Ingress mit Traefik), der zusätzlich an den Accountant meldet: `--accountant=https://…:8444` (bzw. `SULTAN_ACCOUNTANT`) plus Einladung des Accountants. Nur Server melden; Agents (Docker wie systemd) sind nicht meldepflichtig, sie liefern ihrem Server lediglich Zusatzdaten.
**Warum eigene DB:** andere Lebensdauer (Historie über Jahre, eigene Aufbewahrung/Backups), anderes Vertrauen (Berichte sind Eingaben fremder Server und werden nie mit dem eigenen Mandanten- und Schlüsselbestand vermischt), kein Ballast und keine Migrationen auf jedem Ingress. Migrationsmechanik und Zeitformat werden aus `pkg/store` wiederverwendet.
**Transport & Sicherheit:** wie Agent → Server. Payee erzeugt lokal `payee.key` (Ed25519, im Datenverzeichnis des Servers), löst eine Einladung `sltn_enroll_<secret>.<fingerprint>.<url>` des Accountants ein (`sultan accountant invite --payee=<id>`), pinnt dessen Fingerprint, signiert jeden Request (`pkg/auth`). Nur TLS 1.3, kein Klartext.
**Bericht = vollständiger Schnappschuss** (`POST /api/v1/report`), kein Delta: sofort nach Änderungen (Knoten online/offline, `service_state`, Mandantenänderung; 10 s gebündelt) und stündlich als Lebenszeichen. Inhalt:
* Payee: Name, Version, Zeitpunkt.
* Mandanten: ID, Name, Tier, Status.
* Knoten: ID, Mandant, Hostname, **FQDN**, IP, Status, `last_seen`, Betriebssystem, Docker-Version, NetBird-IP (falls vorhanden).
* Routen: Router, Hosts, Mandant, Knoten, Ziel als **`<fqdn>:<port>`** und `<ip>:<port>`, Container, **Compose-Projekt und -Verzeichnis**, Startzeit, Docker-Health, Zustand (läuft / down / Wartung / Mahnstufe), Produkt aus `sultan.accountant.billing`.
**Zusatzdaten vom Agent** (umgesetzt 2026-10-16; nur Information, ändern das Routing nie; **ohne zusätzliche Labels**, alles automatisch erkannt):
* je Container `meta` im Workload: `compose_project`, `compose_service`, `compose_dir` (aus den Labels, die Docker Compose selbst setzt: `com.docker.compose.project`, `….service`, `….project.working_dir`), `state`, `started_at`, `health` (aus `GET /containers/{id}/json`, `docker.Client.Inspect`). Ein Health-Wechsel löst über das Docker-Event `health_status` eine neue Meldung aus. sultan.d-Dienste haben kein `meta`.
* je Knoten `host_info` im `service_state`: `fqdn`, `os`, `docker_version`, `netbird_ip`.
* **FQDN:** `--fqdn` > Hostname mit Punkt > Hostname über die Suchdomain aufgelöst (CNAME/kanonischer Name) > Reverse-Lookup der Backend-Adresse > kurzer Hostname; einmal beim Start, je Abfrage max. 2 s.
* **Betriebssystem/Docker-Version:** aus `GET /info` (das OS des Hosts, auch wenn der Agent im Container läuft); ohne Docker `PRETTY_NAME` aus `/etc/os-release`.
* **NetBird-IP:** IPv4 auf `wt0`, sonst die erste Adresse aus 100.64.0.0/10.
* Der Server liest `service_state` tolerant: ältere Server ignorieren die Felder, ältere Agents schicken sie nicht. Die Registry speichert sie mit dem Zustand (`Registry.State`, `Server.NodeState`) für den Bericht an den Accountant.
* Das Ziel `<fqdn>:<port>` je URL entsteht beim Bericht aus `host_info.fqdn` und dem (bereits auf den Host-Port umgerechneten) Service-Port.
**Umsetzung Accountant, Teil 1 (6b, 2026-10-16):**
* `pkg/accountant`: `New` (Datenverzeichnis 0700, Default `./accdata`; `accountant.db`; eigenes `server.crt`/`server.key`), `Start`/`Run`/`Shutdown`, TLS-Port Default `:8444`, `POST /api/v1/enroll` (signiert mit `payee.key`, `auth.Verifier.VerifyUnregistered`; Body `{token, hostname}`, Antwort `{payee_id}`; 403 ohne Details bei unbekannter/verbrauchter/abgelaufener Einladung, 409 bei doppeltem Schlüssel). `--public-url` wird gemerkt (`settings.public_urls`) und steckt in Einladungen.
* `pkg/ledger` (`accountant.db`, Migrationen über `store.OpenDB`): `payees` (ID aus der Einladung, Hostname, Schlüssel, IP, Zeitpunkt), `invites` (nur Hash, einmalig, Ablauf), `settings`. Ein registrierter Payee bekommt keine neue Einladung, bevor er gelöscht ist.
* CLI: `sultan accountant [--addr --public-url --data-dir]`, `accountant invite --payee ID [--ttl] [--server-url]`, `accountant payee list`, `accountant payee delete --id ID`. Verwaltungsbefehle legen nie ein neues Datenverzeichnis an (Schutz vor Tippfehlern).
* Rate-Limit für `/enroll` wie beim Server (`--enroll-limit`, Default 10/min je IP, `pkg/ratelimit`; schützt vor Last und Log-Flut, nicht vor Raten – dafür sorgen 256 Bit Zufall). Compose-Vorlage: `deploy/compose/accountant` (beides umgesetzt 2026-10-18).
**Umsetzung Payee & Berichte (6c, 2026-10-17):**
* Server: `--accountant-token` (`SULTAN_ACCOUNTANT_TOKEN`, Einladung, nur beim ersten Start) und `--accountant` (`SULTAN_ACCOUNTANT`, Adress-Override; Vorrang: Override > Adresse in der Einladung > `accountant.json`). Im Datenverzeichnis des Servers: `payee.key` (0600) und `accountant.json` (`url`, `fingerprint`, `payee_id`). Ohne Einladung und ohne `accountant.json` meldet der Server nichts.
* **Fehlerverhalten:** Unbrauchbare Angaben (Einladung unlesbar, `http://`, Einladung ohne jede Adresse, `--accountant` ohne Einladung) verhindern den Start – das sind Tippfehler. Ein nicht erreichbarer Accountant hält den Ingress **nie** auf: Enrollment und Berichte laufen im Hintergrund und werden je Prüfintervall wiederholt; Fehler werden nur bei Änderung geloggt. Einladung eines **anderen** Accountants bei bestehender Registrierung: Fehler im Log, keine Berichte, Server läuft (kein stiller Wechsel).
* **Wann gemeldet wird:** Der Server baut den Schnappschuss je Prüfintervall (Default 10 s) und sendet nur, wenn sich der Inhalt (Hash ohne Zeitstempel) geändert hat oder das Lebenszeichen fällig ist (Default 1 h). Polling statt Ereignis-Hooks: erfasst auch Mandanten-, Wartungs- und Statusänderungen ohne zusätzliche Kopplung. Fehlgeschlagene Berichte werden beim nächsten Intervall wiederholt.
* **Inhalt** (`accountant.Report`): Mandanten, alle Knoten aus `sultan.db` (verbunden, Wartung, `last_seen`, Host-Infos aus dem letzten `service_state`), Routen aus den gemeldeten Zuständen (`reportRoutes`: Hosts per `HostsFromRule`/`HostsFromSNIRule`, `backend`, `backend_fqdn` = `<fqdn>:<port>`, Compose-Angaben, Produkt aus `sultan.accountant.billing`, `down` aus `down_services`, `active` = Router steht in der Traefik-Konfiguration, also nicht von der Policy verworfen).
* Accountant: `POST /api/v1/report` (signiert, Schlüssel muss registriert sein, max. 8 MiB, `generated_at` Pflicht), speichert den **letzten** Bericht je Payee normalisiert (`reports`, Migration v2 von `accountant.db`). CLI: `payee list` mit `LAST_REPORT`, `payee report --id` (JSON).
**Umsetzung Checkmk (6e, 2026-10-18):**
* `--metrics-addr` (Default `127.0.0.1:8082`, Klartext, nur Loopback; `none` = aus), `GET /metrics/checkmk`. Quelle: letzter Bericht je Payee (keine Historie nötig).
* Services: `Sultan Payee <id>` (OK; WARN ab `--stale-warn` 2 h bzw. ohne Bericht; CRIT ab `--stale-crit` 6 h) und `Sultan URL <host>` je Host jeder Route (OK; **WARN** bei „von Policy verworfen“, „gestoppt/Wartungsseite“, „unhealthy“, „Knoten getrennt“; angekündigte **Wartung** des Knotens bleibt OK mit Hinweis; veralteter Bericht des Payees überträgt WARN/CRIT). Derselbe Host mehrfach → Zusatz `(payee/knoten/router)`.
* Text per `text/template`: eingebaut (`defaultCheckmkTemplate`), eigene `<data-dir>/checkmk/checkmk.tmpl` hat Vorrang und wirkt sofort; fehlerhafte Vorlage → eingebaute greift + CRIT-Service „Sultan Accountant Vorlage“. `sultan accountant dump-checkmk [--force]`. Felder bereinigt (ASCII, einzeilig, `|`→`/`, `"`→`'`) und HTML-escaped; `.Unescaped.<Feld>` nur bereinigt. Funktion `status` (0/1/2 → OK/WARN/CRIT).
* Links: Die Standardvorlage schreibt `https://<host>` und `http://<fqdn>:<port>` als Text; ob Checkmk sie klickbar darstellt, hängt von Version/Einstellung ab – Anpassung über die Vorlage.
**Historie:** Der Accountant führt je (Payee, Mandant, Host, Router) Zeiträume `first_seen` / `ended_at`. Fehlt eine Route in einem *frischen* Schnappschuss, endet ihr Zeitraum (Offboarding). Bleibt das Lebenszeichen eines Payees aus (> 2 h), endet **nichts** – der Server ist weg, nicht seine Services; Checkmk meldet den Payee als veraltet.
**Abrechnung – Modell (Antworten 2026-10-18; Umsetzung 6d).** Der Accountant gewinnt: Das Label `sultan.accountant.billing=<produkt>` ist der bequeme Weg für den Hoster (viele Kunden pflegen ihre Services nicht selbst; der Hoster trägt das Label am Kunden-Container ein), Vorgaben am Accountant haben aber immer Vorrang. **Alle** Routen werden erfasst; nichts verschwindet aus dem Bericht.
* **Kunde** (zentral am Accountant): trägt Zahlweise, Pauschale und **Kostenverursacher-Gruppe** (z. B. `extern`, `intern`, `familie` – zum Clustern, keine Buchhaltungs-Kostenstelle; Posten haben keine eigene) sowie eine **Notiz** für persönliche Nebenabreden; Gruppe und Notiz sind im Bericht sichtbar und filterbar. Mandanten werden Kunden zugeordnet – gleiche Mandanten-ID auf allen Payees = derselbe Kunde (automatisch: der Kunde entsteht beim ersten Auftauchen der Mandanten-ID mit dem Mandantennamen und Zahlweise „nicht eingeordnet“; maßgeblich ist der Mandant aus der Einladung auf dem Server, nicht eine Angabe des Agents – von Hand nur Zahlweise, Pauschale, Gruppe, Notiz und Ausnahmen); eine Zuordnung je Payee + Mandant überschreibt das (Tippfehler auf einem Nebencluster, historische Abweichungen). Mandanten ohne Kunde erscheinen im Bericht als „Kunde unbekannt“.
* **Pauschale** ist ein Posten am Kunden (monatlich); die meisten Kunden zahlen pauschal.
* **Posten = URL bzw. Router:** je Router ein Posten (seine Hosts werden aufgeführt); alle Posten gehören dem Kunden des Mandanten. Einem Posten kann ein „Dienst“ zugeordnet sein (z. B. OLAT) – das ist dann der abgerechnete Zusatz-Service.
* **Klassen eines Postens:**
* `included` – in der Pauschale enthalten (eingepreist), nur aufgeführt;
* `billable` – dazugebuchter Zusatz-Service, eigene Position;
* `free` – Give-away, kostet derzeit nichts, wird erfasst und kann später (mit Gültig-ab-Datum) abrechenbar werden;
* `internal` – keine Rechnung, aber über die Gruppe des Kunden zuordenbar (z. B. Infrastruktur wie `sultan.chmln.de`, Familie) und mit **Schattenpreis**, damit Fragen wie „Was habe ich dem Neffen geschenkt?“ oder „Was müsste das Brautpaar eigentlich zahlen?“ beantwortbar sind; ein Posten kann später an einen echten Kunden übergehen.
* **Klasse/Produkt einer Route** (erste zutreffende Regel): Zuordnung am Accountant für Route/Domain > Label `sultan.accountant.billing` (Produkt aus dem Katalog bestimmt die Klasse) > Kunde mit Pauschale: `included` > sonst „nicht zugeordnet“ (fällt im Bericht auf). Klassen- und Preisänderungen gelten ab Datum.
* **Laufzeit:** Abrechnung immer in vollen Monaten (Stichtag Monatserster/Monatsultimo). **Beginn:** Der Monat, in dem ein Posten erstmals auftaucht, zählt voll ab dem 1. Liegen Go-live und Bestellung weit auseinander, setzt der Hoster den Beginn je Posten manuell auf einen späteren Monatsersten (die Zwischenzeit läuft als Einmalbetrag im Projekt, außerhalb von Sultan). Gestoppte Routen, Wartung, Mahnstufen ändern nichts – erst das Verschwinden aus dem Bericht beendet einen Posten. SLA-Verletzungen werden manuell vergütet (bleibt so). Kündigung: Standard zum Monatsultimo; zusätzlich gibt der Bericht eine **tagesgenaue Gegenrechnung** als Vorschlag aus (beschleunigt seltene Kündigungen).
* **Zahlweise je Kunde:** monatlich, quartalsweise, jährlich – oder **„noch nicht eingeordnet“** (Feedback der Buchhaltung fehlt). Die Monats-CSV weist bei Quartals-/Jahreszahlern den Monatsanteil aus und vermerkt, dass die Rechnung das 3- bzw. 12-Fache beträgt (bzw. führt den Periodenbetrag mit); bei „nicht eingeordnet“ steht ein Vermerk.
* **Preise** im Accountant: **optional** (Katalogpreis je Produkt, Kundenpreis überschreibt, Gültig-ab). Nicht buchhaltungsrelevant – führend sind die Abos in Lexware Office; Preise dienen Summen, Plausibilität und Schattenpreisen. Ohne Preis bleibt die Spalte leer.
* **Ausgabe:** CSV je Monat (Abrechnung erfolgt in Lexware Office). Spalten werden an der Praxis nachgeschärft; kein Templating, bis die Buchhaltungsfragen geklärt sind.
* **Geparkt:** Abo-Revision (Abgleich mit den Lexware-Abos) bleibt vorerst manuell. **Ausblick (TODO):** Der Accountant als „Realität“ statt der Abopflege in Lexware – also Lexware aus dem Bestand speisen statt umgekehrt.
* **Offene Fachfragen vor 6d:** siehe Abschnitt 9.
**Abfrage:**
* CLI auf dem Accountant: `sultan accountant where <domain>` (Mandant, Payee, Knoten, `<fqdn>:<port>`, Container, Compose-Verzeichnis, Zustand), `routes [--tenant=…]`, `billing --month=JJJJ-MM`, `payee list`, `invite`.
* Checkmk (`GET /metrics/checkmk` auf Loopback, umgesetzt 2026-10-18, siehe unten): ein Service je URL mit Link auf `https://<host>` und auf das interne Ziel `http://<fqdn>:<port>`, dazu Frische je Payee. **Text per Vorlage** (Go `text/template`, wie beim Lander): eingebaute Default-Vorlage, eigene unter `<data-dir>/checkmk/` hat Vorrang; `sultan accountant dump-checkmk` exportiert die Vorlage zum Anpassen. Die Default-Vorlage wird mit den Releases weiterentwickelt. **Werte aus Berichten sind Fremdeingaben** (Containername, Compose-Verzeichnis stammen vom Kunden-Host): Sie werden vor dem Einsetzen bereinigt (keine Zeilenumbrüche, kein `|`, ASCII) und HTML-escaped; rohe Werte nur über eine explizit benannte Funktion. Grund: Wer in Checkmk das HTML-Escaping abschaltet, damit Links klickbar sind, öffnet sonst Script-Injektion in die Checkmk-Oberfläche.
* Später: HTTP-API als Vorstufe für den Export ins Dokuwiki; Statusberichte an Mandanten. (Uptime Kuma verworfen: keine offizielle API.)
**Umsetzung Historie, `where`, `routes` (6d Schritt 1, 2026-10-18):**
* `accountant.db` v3: `route_spans` – ein Zeitraum je **Ort** (Payee, Mandant, Knoten, Protokoll, Router) mit Hosts, Backend, `<fqdn>:<port>`, Workload, Compose-Verzeichnis, Produkt, `first_seen`/`last_seen`/`ended_at`. `SaveReport` führt sie im selben Schritt wie den letzten Bericht fort: neue Orte öffnen, bestehende übernehmen geänderte Angaben (`first_seen` bleibt), fehlende enden mit dem Eingang des Berichts. Nur ein Bericht des Payees selbst beendet dessen Orte; ein schweigender Payee beendet nichts. Die Historie bleibt, wenn ein Payee gelöscht wird. Ein Umzug auf einen anderen Knoten ist ein neuer Ort; die Abrechnung fasst Orte später je Mandant + Router zum Posten zusammen.
* `sultan accountant where <host|mandant>`: Teiltreffer im Host (ohne Groß-/Kleinschreibung) oder exakte Mandanten-ID; je Treffer URL, Mandant, Ingress (Zeitpunkt des Berichts), Backend `<fqdn>:<port>` (+ IP, NetBird), Container + Compose-Verzeichnis, Zustand, Knoten (verbunden, OS, Docker), **seit** wann am Ort, **vorher** (beendete Orte derselben Route), dazu passende Routen, die es **nicht mehr** gibt. Kein Treffer → Exit 1.
* `sultan accountant routes [--tenant] [--payee]`: Tabelle aller aktuellen Routen (Host, Mandant, Payee, Knoten, Ziel, Zustand, seit).
**Umsetzung Abrechnung (6d Schritte 2–4, 2026-10-18):**
* **Kunden** (`accountant.db` v4: `customers`, `customer_map`): `StoreReport` (Endpunkt) ruft `EnsureCustomers` – unzugeordnete Mandanten-IDs werden Kunden (`auto`, Name folgt dem Mandanten, bis der Kunde von Hand bearbeitet wird). Auflösung `CustomerResolver.Resolve(payee, mandant)`: Zuordnung für den Payee > für alle Payees > Kunde mit gleicher ID. CLI `customer add|set|list|map|unmap|delete`; `set` ändert nur angegebene Felder; `list --group`, `--unclassified`. `where` zeigt den Kunden.
* **Katalog** (v5: `products`, `product_versions`, `customer_prices`, `assignments`): Klasse und optionaler Preis je Monat (`product set --from JJJJ-MM`, erste Version gilt von Anfang an), Kundenpreis mit Vorrang. **Zuordnung je Posten** (Mandant, Protokoll, Router) mit Produkt, Klasse und/oder Beginn: `assign --host …` (Host → Posten über die Historie; mehrdeutig → `--tenant/--router`), `assign --clear`, `assignments`.
* **Monatsabrechnung** (`Billing`, `billing --month JJJJ-MM [--out]`): Posten = alle Orte eines (Mandant, Protokoll, Router); abgerechnet, wenn der Monat zwischen Beginn (Zuordnung, sonst Monat des ersten Auftauchens, voll) und dem Monat des Endes (bis Ultimo) liegt. Klasse: Zuordnung > Produkt (Zuordnung, sonst Label) > Pauschale des Kunden → `included` > leer („nicht zugeordnet“; Produkt nicht im Katalog wird benannt). Beträge: billable = Preis (fehlt → Hinweis), included 0, free/internal 0 + Schattenpreis. Pauschale als eigene Zeile je Kunde mit Pauschale (Hinweis, wenn keine URL aktiv). Periodenbetrag = Monatsbetrag × 1/3/12; Hinweise für Quartals-/Jahreszahler und „noch nicht eingeordnet“; Ende im Monat → „beendet am …“ + tagesgenaue Gegenrechnung. Mandanten ohne Kunde: Hinweis „Kunde unbekannt“, am Ende der Liste.
* **CSV:** UTF-8 mit BOM, Semikolon, Dezimalkomma; Spalten Monat, Kunde, Kundenname, Gruppe, Zahlweise, Posten, Hosts, Mandant, Klasse, Produkt, Von, Bis, Monatsbetrag, Periodenbetrag, Schattenpreis, Hinweis, Notiz; je Kunde die Pauschale zuerst. Spalten werden an der Praxis nachgeschärft.
---
## 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,accountant}`; die Ausschnitte hier zeigen das Prinzip. **Einstellungen nur über `.env`** (`env_file: .env`): `compose.yaml` wiederholt keine Variablen, alle Optionen samt Defaults stehen in `.env.example` (`SULTAN_<FLAG>` je Flag, leer = Default). Das Image legt `/suldata` und `/accdata` für den nonroot-User an; `WORKDIR /` macht beide zu den Defaults. 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 `zot.chmln.de`, Image `zot.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. |
| 2026-10-03 | **Gestoppter Container = Wartungsseite, entfernter = Route weg.** Der Agent meldet gestoppte Container mit (Ports aus `PortBindings`) und deren Services als `down_services`; laufende Container gewinnen bei Namensgleichheit. Leerer `--public-url`-Wert wird ignoriert; `sultan version` zeigt die Zeitzone; `TZ` kommt über Compose (distroless enthält tzdata). Server-Compose mit `network_mode: host` neben Traefik. | Erster Test gegen echtes Traefik: `docker stop` lieferte 404 statt 503. Abschalten bleibt über Entfernen möglich. |
| 2026-10-11 | **TCP nur als TLS-Passthrough** (Feature `tcp_stream`, auch Community): `HostSNI` mit expliziten Hosts aus `allowed_domains`, keine Wildcard-/Regexp-Router; nur Entrypoint `websecure`; zählt gegen `max_routes`. Ohne Interceptor: Mahnhinweis lässt durch, ab Paywall und bei Wartung Route entfernt. UDP und übrige TCP-Labels weiterhin abgelehnt. | Kunden mit eigener CA/TLS (tinyCA) brauchen Passthrough; DNS-Wildcard ist kein Grund für einen Wildcard-Router – host-genau bleibt prüf- und abrechenbar. Seiten lassen sich bei TCP nicht einblenden. |
| 2026-10-16 | **Accountant** (5.3): zentrales Inventar über eigene Rolle `sultan accountant` mit eigener `accountant.db`; nur Server (Payees, `--accountant`) melden, per signiertem, gepinntem Push eines vollständigen Schnappschusses bei Änderung und stündlich. Abrechnung: Vorgaben am Accountant (Zuordnung, Abo pauschal/einzeln, Mandantenpreis) gewinnen vor dem Label `sultan.accountant.billing`; alle Routen werden erfasst, ungelabelte gelten bei Abo als „im Abo enthalten“, sonst „nicht zugeordnet“. Abfrage zuerst per CLI (`where`) und Checkmk (Text per `text/template`-Vorlage mit eingebautem Default); HTTP-API später, Uptime Kuma verworfen. | Störfall: Backend-Host sofort finden; Kunden-Labels können Routen nicht vor der Abrechnung verstecken; bewährte Mechanik (Pinning, Signatur, Schnappschuss) statt neuem Protokoll; viele Services sind eingepreist; Vorlagen-Mechanik wie beim Lander, ohne neue Abhängigkeit. |
| 2026-10-16 | **6a/6b umgesetzt:** Zusatzdaten automatisch aus Docker (Compose-Labels, Inspect, `/info`), DNS und Interfaces – keine neuen Pflicht-Labels; einziges neues Label ist `sultan.accountant.billing` (statt `sultan.billing`). Accountant mit Default `./accdata` und Port `:8444`; `accountant.db` über das exportierte `store.OpenDB`; Payee-ID kommt aus der Einladung (`invite --payee`). | Kein Pflegeaufwand an Kunden-Compose-Dateien; Label-Namensraum zeigt, wer das Label auswertet; getrenntes Datenverzeichnis erlaubt Accountant und Server auf demselben Host. |
| 2026-10-17 | **6c umgesetzt:** Payee meldet per Polling (Prüfintervall 10 s, Senden nur bei geändertem Inhalt, Lebenszeichen 1 h) statt über Ereignis-Hooks; Konfigurationsfehler verhindern den Start, ein nicht erreichbarer Accountant nie; Einladung eines anderen Accountants wird bei bestehender Registrierung ignoriert (Log-Fehler). Accountant hält bis 6d nur den letzten Bericht je Payee. Routen tragen `active` (in Traefik) und `down`. | Ingress-Verfügbarkeit vor Inventar; Polling erfasst alle Änderungsquellen ohne Kopplung; kein stiller Wechsel der Zentrale. |
| 2026-10-18 | **Compose: Einstellungen nur über `.env`** (`env_file`), Defaults dokumentiert in `.env.example`; Accountant-Vorlage `deploy/compose/accountant` mit Datenverzeichnis `/accdata`. | Keine doppelte Pflege von Variablen und Defaults; neue Flags brauchen keine Compose-Änderung. |
| 2026-10-18 | **Abrechnungsmodell** (5.3): Posten = Router/URL; Pauschale am Kunden; Klassen `included`/`billable`/`free`/`internal` (Kostenstelle, Schattenpreis); volle Monate, Kündigung zum Ultimo mit tagesgenauer Gegenrechnung als Vorschlag; Zahlweise m/q/j je Kunde mit Vermerk in der Monats-CSV; Kunden-Zuordnung automatisch über gleiche Mandanten-ID, Ausnahme je Payee. | Spiegelt die Praxis (Lexware-Abos, meist Pauschale, Zusatz-Services je URL); interne und geschenkte Dienste bleiben bewertbar. |
| 2026-10-18 | **Abrechnung, Details:** Beginn = voller Monat ab dem 1. des ersten Auftauchens, manuell verschiebbar je Posten; Zahlweise je Kunde; Preise optional und nicht buchhaltungsrelevant (Lexware führt); Gruppe (Kostenverursacher) und Notiz (Nebenabreden) je Kunde, filterbar; Abo-Abgleich bleibt manuell. | Projekt- und Einmalbeträge laufen außerhalb; persönliche Kundenverhältnisse bleiben nachvollziehbar, ohne Buchhaltungsstruktur nachzubauen. |
| 2026-10-18 | **6e umgesetzt:** Checkmk des Accountants auf eigenem Loopback-Port `127.0.0.1:8082`, Quelle = letzter Bericht; Wartung dämpft auf OK; Rate-Limiter nach `pkg/ratelimit` verschoben und für `/enroll` des Accountants genutzt. | Unabhängig von 6d nutzbar; einheitliches Verhalten von Server und Accountant. |
| 2026-10-18 | **Historie je Ort** (Payee, Mandant, Knoten, Protokoll, Router) statt je Posten; Kunden entstehen automatisch aus der Mandanten-ID der Berichte. | `where` braucht den Ort (wohin springen, wo vorher); Posten für die Abrechnung lassen sich daraus ableiten; der Mandant ist durch die Einladung auf dem Server verlässlich, also keine Nachpflege. |
| 2026-10-18 | **6d umgesetzt:** Posten = Orte je (Mandant, Protokoll, Router) zusammengefasst; Beginn voller Monat (Zuordnung verschiebt), Ende bis Ultimo mit tagesgenauer Gegenrechnung als Hinweis; Klasse Zuordnung > Produkt > Pauschale > nicht zugeordnet; Preise optional, Schattenpreis für free/internal; Pauschale als eigene Zeile; CSV mit BOM, Semikolon, Dezimalkomma. | Deckt sich mit der Praxis (Lexware, Pauschale + Zusatz-Services); Lücken (kein Kunde, kein Produkt, keine Zahlweise) werden sichtbar statt still verschluckt. |
| 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 (HTTP, TCP-Passthrough), 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.
**Projektstand 2026-10-03: vorerst fertig.** Erster Feldtest (webproxy11 + dockweb11) erfolgreich; Betriebsdoku unter `docs/` (`community.md`, `wartung.md`, `hoster.md`). Weitere Iterationen nach Schwierigkeiten im Betrieb. Offen aus dem Abschluss:
* **Ungetestet im Feld:** Agent als systemd-Dienst (ohne Docker); kommerzieller Betrieb (Lizenzen, Mahnwesen, Vertragsende, pro/enterprise).
* **`tenant update`** (Vertragsende, Domains, Tier, Routenlimit) – Voraussetzung für zahlende Kunden; `store.UpdateTenant` existiert, CLI fehlt.
* **Lander-Vorlagen gestalten** („hübsch“): die eingebauten Seiten sind bewusst minimal ohne CSS.
* **Registry mit Vanity-URL:** `zot.chmln.de` ist noch nicht weiter getrieben (Image-Name unabhängig vom Registry-Host, z. B. `cr.<domain>/sultan`).
* **Geparkt:** Veröffentlichung auf GitHub (Quellen, Releases, ggf. ghcr.io-Spiegel).
**Schritt 6 – Accountant (5.3)** – 6a/6b ✅ 2026-10-16, 6c ✅ 2026-10-17, 6d/6e ✅ 2026-10-18
* ✅ 6a Agent: Zusatzdaten (Compose-Projekt/-Verzeichnis, Startzeit, Health; FQDN, OS, Docker-Version, NetBird-IP) im `service_state`, automatisch erkannt.
* ✅ 6b `sultan accountant`: Datenverzeichnis, Zertifikat, `accountant.db`, Enrollment-Endpunkt, `invite`, `payee list`/`delete`.
* ✅ 6c Payee: `--accountant-token`/`--accountant`, Enrollment, Schnappschuss bei Änderung (Prüfintervall 10 s) und stündlich; Accountant speichert den letzten Bericht.
* ✅ 6d Accountant (2026-10-18): Schritt 1 Historie (Zeiträume je Ort), `where`, `routes`; Schritt 2 Kunden + Zuordnung (automatisch aus der Mandanten-ID); Schritt 3 Katalog + `assign`; Schritt 4 Monats-CSV. Inhalt: Pauschale, Klassen (`included`/`billable`/`free`/`internal` mit Schattenpreis), Gruppe + Notiz je Kunde, Produktkatalog und optionale Preise mit Gültig-ab, Zahlweise je Kunde, Monats-CSV, tagesgenaue Kündigungs-Gegenrechnung. Fachfragen geklärt 2026-10-18 (Beginn voller Monat ab dem 1., manueller späterer Beginn je Posten; Zahlweise je Kunde; Abo-Abgleich manuell; Preise optional; Kostenverursacher-Gruppe + Notiz je Kunde).
* ✅ 6e Checkmk-Ausgabe per Vorlage (Default eingebaut, `dump-checkmk`) mit Links; dazu Rate-Limit für `/enroll` des Accountants (2026-10-18).
* Später: HTTP-API (Dokuwiki-Export), Mandanten-Statusberichte.
**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.
* **`public_reason` zweisprachig** (klein): Die Wartungsseite ist de/en, der öffentliche Grund bisher nur ein Text. Vorschlag: `--public-reason` mehrfach mit Sprachpräfix (`de:…`, `en:…`; ohne Präfix = für alle Sprachen), Label analog `sultan.maintenance.public_reason.<lang>`; Übertragung im bestehenden Header `X-Sultan-Public-Reason` als URL-kodierte Form (`de=…&en=…`), Lander wählt je Sprachblock, fehlt eine Sprache → der andere Text.
* 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.