15 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Instruktionen
Lies die .ai/*.md Files. Diese liefern den Context für die App, die hier entstehen soll.
Maßgeblich ist aktuell .ai/sultan-system-architecture.md (Netzwerk-, Sicherheits-, Daten- und Anwendungsmodell inkl. SQL-Schema, Event-Format und Referenz-Compose-Files). Bei Widersprüchen zwischen diesem File und der Spec gilt die Spec. Architekturentscheidungen werden in der Spec nachgepflegt (betroffene Abschnitte + Entscheidungslog in Abschnitt 8) – das gehört zu jeder Änderung, die Architektur festlegt.
Projektstatus
Go-Modul sultan (Go 1.26). main.go ist nur eine Hülle um pkg/cli; umgesetzt sind sultan server|agent|token|dump-landing|version|help. Packages:
-
pkg/traefik – übersetzt flache
traefik.*-Labels (FromLabels) in Traefiks dynamische Konfiguration, führt Knoten-Configs zusammen (Merge) und rendert JSON/YAML (Marshal) für/v1/traefik-config. Traefik pollt diesen Endpunkt über seinen HTTP-Provider; Sultan schreibt nie in Traefik und emuliert keinen Docker-Socket. -
pkg/stream – Server→Agent-Kanal als SSE:
Hub(Server, ein Stream pro Knoten, Keepalive:\n\n, Neuverbindung ersetzt alte mitErrReplaced,OnConnect/OnDisconnectnur bei echtem Online/Offline),Client(Agent, Idle-Timeout 90s,Runmit exponentiellem Backoff,Preparezum Signieren),Decoder. Authentifizierung macht der Aufrufer vorHub.Serve. Agent→Server (service_state) läuft nicht über diesen Stream, sondern über eigene Requests. -
pkg/store – SQLite-Persistenz (Schema Spec 4.3) über
modernc.org/sqlite(reines Go, kein cgo → statisches Binary). Migrationen sind eine append-only Liste instore.go, Version inPRAGMA user_version; neuere DB-Version →ErrSchemaTooNew. Zeitstempel werden aus Go gesetzt (injizierbare UhrStore.now) und als UTC-Text fester Breite gespeichert, damit SQL-Vergleiche funktionieren – keineCURRENT_TIMESTAMP-Defaults verwenden. Enrollment-Tokens nur als SHA-256;Enrollverbraucht Token und legt Knoten atomar an. Fehler:ErrNotFound/ErrExists/ErrInvalid/ErrTokenUsed/ErrTokenExpired. -
pkg/auth – Ed25519-Request-Signatur, in jedem Tier Pflicht (kein Bypass, auch nicht für
community/Homelab).Signer.Sign(passt alsstream.Client.Prepare) signiert Methode, Pfad+Query, Zeitstempel, Nonce und Body-Hash (X-Sultan-*-Header).Verifier.Verifyprüft in der Reihenfolge TLS → Signatur → Zeitfenster (±5min) → Nonce → Store, liefertIdentity{Node, Tenant}; die Knoten-ID fürHub.Servekommt nur daraus.VerifyUnregisteredfür Enrollment (Besitznachweis des Schlüssels). Gesperrt (ErrForbidden) wird nur bei Quarantäne; Zahlungsverzug/Ablauf/Drain regelt die Policy über das Routing.StatusCode(err)für HTTP-Antworten. Schlüsseldateien: PKCS#8-PEM, 0600, werden nie überschrieben. -
Kein Klartext:
Signersigniert nurhttps-URLs,Verifierlehnt Requests ohner.TLSab (ErrInsecureTransport, 426). Tests für Agent↔Server laufen daher über TLS-Testserver. -
pkg/crypto – Transport:
LoadOrCreateServerCert(dataDir)(selbstsigniert ECDSA P-256, keine CA, 20 Jahre,server.key0600; fehlt eine der Dateien → Fehler statt Neuerzeugung),ServerTLSConfig(nur TLS 1.3),PinnedTLSConfig(fingerprint)– Pinning im Handshake am Fingerprint, unabhängig von der Adresse; ohne FingerprintErrUnknownServer(kein blindes TOFU). Enrollment-Tokensltn_enroll_<secret>.<fingerprint>[.<base64url(server-url)>](NewEnrollmentToken(fp, url)/ParseEnrollmentToken,ValidateServerURL); in den Store kommt nurToken.Secret. Paketname kollidiert mit Stdlib-crypto– bei Bedarf beim Import aliasen. -
pkg/license – Lizenz nach Spec 4.1:
Issuer.Issue(normalisiert + signiert mit Master-Key, PräfixSULTAN-LICENSE-V1),Parse(strikt, unbekannte Felder abgelehnt) /Verify,AllowsDomain(*.x.de= alle Subdomains, nicht Apex;*.<tld>verboten),AllowsRoutes(-1 = unbegrenzt),HasFeature,Expired/DaysLeft. -
pkg/policy –
HostsFromRule(echter Parser für Traefik-Regeln; jeder||-Zweig muss Host-beschränkt sein,HostRegexpverboten),StageFor(blocked/landing/paywall/firemark/watermark, Mahnwesen nur kommerziell) undEngine.Apply: prüft Router (Domain, Referenzen, Middleware-Gates, Routenlimit), Namensraum<tenant>__<node>__<name>, leitet bei Stufe ≠ normal oder Healthcheck-Ausfall zusultan__interceptorum (HeaderX-Sultan-Stage/Tenant/Tier); bei watermark/firemark zusätzlich Durchlass-Routersultan__<tenant>__<node>__<router>per Cookiesultan_ack=<stufe>.DunningSchedule(8/25/32 Tage) berechnet die Mahnstufe. Wartung:Input.Maintenance(Knoten) undInput.ServiceMaintenance(Labelsultan.maintenance), Vorrang Ablauf/Paywall > Wartung > Mahnhinweis, HeaderX-Sultan-Until/X-Sultan-Public-Reason(URL-kodiert). AndockstelleMiddlewareTargetCheck(+EnforceMiddlewareTargets) für die geplante Middleware-Zielbegrenzung; nil = dulden. -
pkg/registry – Aggregator:
Update(nodeID, NodeState)(vollständiger Schnappschuss: Workloads mit Host+Labels,DownServices),Remove(der Server ruft es beim Trennen nur ohne aktive Wartung auf;pruneDisconnectedräumt nach Wartungsende),Refresh(zeitabhängige Stufen, Directory-Änderungen; fail-static bei Fehlern,ErrUnknownNodeentfernt),Config,Handlerfür/v1/traefik-config.OnViolationmeldet jeden Verstoß nur beim ersten Auftreten. Mandant/Lizenz kommen über dasDirectory-Interface (Server-Implementierung:pkg/server/directory.go). Verstöße entfernen nur den Router und landen inResult.Violations. Ergebnisse mehrerer Knoten mittraefik.Mergekombinieren. -
pkg/server –
sultan server:New(Datenverzeichnis 0700, DB, Zertifikat,master.key),Start/Run/Shutdown(Hub zuerst schließen, sonst hängen SSE-Requests), Routen beider Ports (handlers.go), Knotenstatus über Hub-Callbacks, Refresh-Schleife, Audit-Log „Policy-Verstoß“ perslog. Tests laufen gegen echte Ports (127.0.0.1:0) mit Test-Agent (Pinning + Signatur). -
pkg/cli –
Run(ctx, args, stdout, stderr) int; Flags/Defaults vonsultan server;sultan tokenintoken.go(BefehlstabelletokenCmds,usageError→ Exit 2,openDataDirverweigert nicht initialisierte Verzeichnisse). Version per-ldflags "-X sultan/pkg/cli.Version=…". -
pkg/docker – minimaler Docker-Engine-Client über Unix-Socket (
Listmitall=1,PortBindingsfür gestoppte Container,Events), bewusst ohne SDK. -
pkg/agent –
sultan agent:Enroll(node.key, Pinning über Einladungs-Fingerprint, node.json mitserver_fingerprint; Adresse:--server/SULTAN_SERVER> URL in der Einladung > node.json, neue Adresse wird nach erfolgreichem Verbinden übernommen),Run(Stream mitOnConnect→ Zustand neu senden, Docker-Watcher, Sammelintervall, kein Senden bei Docker-API-Fehler),Workloads(Container-Port → veröffentlichter Host-Port, Default-Service, unerreichbare Ports weglassen; gestoppte Container bleiben mit ihren Services indown→ Wartungsseite, entfernte verschwinden, laufende gewinnen bei Namensgleichheit),backendHost(explizit → Interface → Route zum Server). Dazu/etc/sultan.d-YAML (sultand.go:ParseServices,LoadServiceDir,FileWorkloads→ Traefik-Labels; Polling überdirFingerprint) und Healthchecks (health.go:checkHealth,healthTrackermit Fehlerschwelle) →DownServices. Tests laufen gegen echtenpkg/servermit gefälschtem Docker-Socket. -
pkg/lander – Interceptor-Seiten je
X-Sultan-Stage(eingebettetetemplates/*.html, zweisprachig), Cookiesultan_ackbei watermark/firemark,localTargeterlaubt nur lokale Pfade (html/template filtertmeta refreshnicht!), eigene Vorlagen aus<data-dir>/landingmit eingebettetem CSS/Bild,Dump. Läuft im Server auf--lander-addr; die Interceptor-URL der Policy folgt der gebundenen Adresse. Test-Server immer mitLanderAddr: "127.0.0.1:0"starten (sonst Portkonflikt auf 8081).
Leases & Befehle (Schritt 2): license.TermsFor/IssueLease/ParseLease (Präfix SULTAN-LEASE-V1); der Server stellt Leases beim Verbinden und im Refresh aus (pkg/server/leases.go: ensureLease, dispatchCommands, leaseValid), getrennte Knoten behalten Routen als Wartungsseite bis Lease-Ende (Disconnected → Policy-Stufe maintenance), drain entfernt sofort. Hub signiert alle Events (Hub.SigningKey, stream.SignEvent/VerifyEvent); der Agent verwirft unsignierte (pkg/agent/events.go). Befehle: Warteschlange node_commands (CLI reiht ein), feste Liste stream.Cmd*; rotate_key über node.key.new + POST /api/v1/rotate-key (abbruchsicher). Signer im Agent nur über currentSigner() lesen (wird im Betrieb getauscht). Tests mit kurzen Leases über Config.LeaseDuration.
Monitoring & Jobs (Schritt 3): pkg/checkmk (Render(State, now), reine Funktion, ASCII) hinter GET /metrics/checkmk; pkg/notify (Slack, Ntfy, Multi); pkg/server/jobs.go: runJobs bei jedem Refresh (kommerzielle Verträge → expired, Benachrichtigungen über sendOnce mit Schlüssel inkl. Bezugsdatum in Tabelle notifications, Aufräumen nach 10 Tagen). Policy-Verstöße werden asynchron gemeldet (OnViolation läuft unter der Registry-Sperre). Tests nutzen Config.Notify mit einem aufzeichnenden Ziel.
Mehrere Adressen (Schritt 4): Config.PublicURLs (gemerkt als settings.public_urls, server.LoadPublicURLs), Versand per config-Befehl in nodeOnline; Agent: Identity.ServerURLs, serverCandidates/nextServerURL (Wechsel in OnError des Stream-Clients), a.serverURL nur über currentServerURL() lesen.
- deploy/ – Auslieferung:
Dockerfileim Root (distroless, nonroot 65532, Multi-Arch, keinENTRYPOINT,WORKDIR /),build.sh(Binaries amd64/arm64 +SHA256SUMSnachdist/<version>/),push-image.sh(liestdeploy/.envmitREGISTRY/REGISTRY_USER/REGISTRY_PASSWORD, Vorlagedeploy/.env.example; buildx → Zot-Registryzot.chmln.de/sultan; ohne Passwort Exit 1),publish-static.sh(rsync nachsultan/releases/<version>/),compose/{server,agent}(Agent mitnetwork_mode: host). Version überall ausgit describe;-dirtywird nicht veröffentlicht..dockerignorehältsuldata/,.ai/,CLAUDE.mdaus dem Build-Kontext.
CLI-Umgebung: pkg/cli/env.go – annotateEnv/applyEnv machen jedes Flag als SULTAN_<FLAG> setzbar (nach fs.Parse, nur für nicht gesetzte Flags; Listen-Flags stringList kommagetrennt). Neue Flags bekommen das automatisch. Rate-Limit: pkg/server/ratelimit.go (EnrollLimit, -1 = aus).
Betriebsdoku: docs/community.md, docs/wartung.md, docs/hoster.md – bei Verhaltensänderungen mitpflegen. Projekt gilt seit 2026-10-03 vorerst als fertig; offene Punkte (tenant update, systemd-Agent und kommerzieller Betrieb ungetestet, Lander-Design, Vanity-Registry, GitHub geparkt) und Fachfragen: Spec Abschnitt 9.
Tests werden zuerst als Vorgaben geschrieben, dann implementiert. Zeitabhängige Tests nutzen kurze Intervalle über die Konfigurationsfelder bzw. das injizierbare Client.sleep; mit -race -count=30 auf Stabilität prüfen.
go build -o sultan . && ./sultan server # Daten in ./suldata (gitignored, enthält Schlüssel!)
go vet ./...
go test -race ./... # alle Tests
go test -run TestFromLabels_Errors ./pkg/traefik # einzelner Test
go test -cover ./...
Middleware-Optionen werden bewusst als unveränderte Label-Strings in verschachtelten Maps durchgereicht (Traefik parst sie beim Einlesen wie Labels); nur Router- und Service-Felder sind typisiert. Nicht unterstützte Labels (TCP/UDP, unbekannte Attribute) liefern ErrUnsupportedLabel statt still ignoriert zu werden.
Big Picture
Sultan ersetzt manuell gepflegtes Ingress-Routing (z. B. Nginx Proxy Manager): Backends deklarieren ihr Routing selbst (Traefik-Labels an Docker-Containern oder Drop-ins in /etc/sultan.d/*.conf für Bare-Metal/LAMP), ein Agent meldet das an einen zentralen Server, und Traefik auf dem Ingress-Knoten pollt daraus seine Konfiguration.
Single Binary in Go mit drei Subcommands:
sultan server– Registry, Stream-Hub (hält Agent-Verbindungen im RAM, Dead-Node-Timeout 90s), Policy-Engine, Traefik-HTTP-Providersultan agent– Watcher (Docker Events API auftraefik.*-Labels bzw. inotify auf/etc/sultan.d), State-Serializer (injiziert LAN- oder NetBird-IP100.64.x.yals Backend-Ziel), Transport mit Reconnect + exponentiellem Backoffsultan token– CLI für Schlüssel, Enrollment-Tokens, Lizenzausstellung
Ports / Endpunkte des Servers:
:8443(TLS) – Control Plane, ausschließlich ausgehend vom Agent (NAT/CGNAT/DS-Lite-tauglich). Persistenter SSE-Stream (entschieden, siehepkg/stream), 45s-Keepalive, Events werden sofort gepusht. Enrollment:POST /api/v1/enroll.127.0.0.1:8080–GET /v1/traefik-config(Traefik pollt alle 2–5s; flache Labels aller aktiven Nodes → verschachteltes Traefik-JSON) undGET /metrics/checkmk(Checkmk Local-Check-Format).
Sicherheitsmodell (Kerninvarianten):
- Server-TLS-Zertifikat self-signed (ECDSA P-256, TLS 1.3), Agent pinnt den SHA256-Fingerprint (aus der Einladung, danach
server_fingerprintinnode.json) – adressunabhängig, kein blindes TOFU. Kein Agent-Verkehr im Klartext. - Agent-Keys (Ed25519) werden nur lokal erzeugt und verlassen den Host nie; Enrollment tauscht Public Key + einmaligen Token gegen Mandanten-Zuordnung und signierten Capability-Lease. Danach ist jeder Heartbeat mit
node.keysigniert. - Jeder Agent-Payload wird serverseitig gegen die Mandanten-Policy geprüft: Hosts in
Host(...)-Regeln müssen inallowed_domainsstehen (sonst ablehnen + Audit-Log), Routen übermax_routeswerden verworfen. - Lizenzen (
license.json) sind mit dem Ed25519-Server-Master-Key signiert.
Stream-Events (JSON: event_id, timestamp, type, node_id, payload): service_state (Agent→Server, vollständiger Label-Snapshot, nicht Delta), node_command (re_enroll/drain/maintenance), lease_renew, error.
Persistenz: SQLite unter <data-dir>/sultan.db (Default-Datenverzeichnis ./suldata, auch beim Agent), Tabellen tenants, nodes, enrollment_tokens (Tokens nur als Hash gespeichert).
Policy/Lizenzablauf: Mandanten-Status steuert das generierte Routing – Zahlungsverzug → Router auf Bezahlseite (402), Backend down/service_down → Statusseite 503 mit Retry-After: 60. Checkmk-Schwellen: WARN bei 30, CRIT bei 14 Tagen Restlaufzeit, bei Ablauf Umleitung auf Landingpage.
Unstimmigkeiten in der Spec
Bisherige Widersprüche sind aufgelöst und im Entscheidungslog der Spec (Abschnitt 8) dokumentiert. Neue Unklarheiten vor der Implementierung mit dem User klären statt raten.