package agent
import (
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/docker"
"sultan/pkg/license"
"sultan/pkg/registry"
"sultan/pkg/server"
"sultan/pkg/store"
"sultan/pkg/stream"
)
var ctx = context.Background()
func eventually(t *testing.T, msg string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("Bedingung nicht erfüllt: %s", msg)
}
time.Sleep(10 * time.Millisecond)
}
}
// testServer ist ein echter sultan-Server auf freien Ports.
type testServer struct {
*server.Server
dataDir string
}
func startServer(t *testing.T, dataDir, agentAddr string) *testServer {
return startServerFull(t, dataDir, agentAddr, nil)
}
func startServerWith(t *testing.T, mutate func(*server.Config)) *testServer {
return startServerFull(t, "", "", mutate)
}
func startServerFull(t *testing.T, dataDir, agentAddr string, mutate func(*server.Config)) *testServer {
t.Helper()
if dataDir == "" {
dataDir = filepath.Join(t.TempDir(), "server")
}
if agentAddr == "" {
agentAddr = "127.0.0.1:0"
}
cfg := server.Config{
DataDir: dataDir, AgentAddr: agentAddr, ProviderAddr: "127.0.0.1:0", LanderAddr: "127.0.0.1:0",
RefreshInterval: 20 * time.Millisecond,
Logger: slog.New(slog.DiscardHandler),
}
if mutate != nil {
mutate(&cfg)
}
s, err := server.New(ctx, cfg)
if err != nil {
t.Fatal(err)
}
if err := s.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Shutdown(ctx) })
return &testServer{Server: s, dataDir: dataDir}
}
func (s *testServer) url() string { return "https://" + s.AgentAddr() }
func (s *testServer) db(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(ctx, filepath.Join(s.dataDir, "sultan.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
// invite legt (falls nötig) den Mandanten an und erzeugt eine Einladung.
func (s *testServer) invite(t *testing.T, tenant string) string {
t.Helper()
st := s.db(t)
if _, err := st.GetTenant(ctx, tenant); err != nil {
if _, err := st.CreateTenant(ctx, store.Tenant{
ID: tenant, Name: tenant, Email: tenant + "@example.com", Tier: store.TierPro, MaxRoutes: 10,
AllowedDomains: []string{"kunde.de", "*.kunde.de"}, ValidUntil: time.Now().AddDate(1, 0, 0),
}); err != nil {
t.Fatal(err)
}
}
tok, _ := crypto.NewEnrollmentToken(s.Fingerprint(), "")
if err := st.AddEnrollmentToken(ctx, tok.Secret, tenant, time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
return tok.String()
}
func (s *testServer) traefikConfig(t *testing.T) string {
t.Helper()
resp, err := http.Get("http://" + s.ProviderAddr() + "/v1/traefik-config")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
func (s *testServer) nodeStatus(t *testing.T, id string) store.NodeStatus {
n, err := s.db(t).GetNode(ctx, id)
if err != nil {
return ""
}
return n.Status
}
// fakeDocker bedient /containers/json und /events über einen Unix-Socket.
type fakeDocker struct {
socket string
mu sync.Mutex
containers []docker.Container
subs []chan docker.Event
}
func newFakeDocker(t *testing.T, containers ...docker.Container) *fakeDocker {
t.Helper()
dir, err := os.MkdirTemp("", "ag")
if err != nil {
t.Fatal(err)
}
f := &fakeDocker{socket: filepath.Join(dir, "d.sock"), containers: containers}
ln, err := net.Listen("unix", f.socket)
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
// Wie Docker: gestoppte Container ohne Ports in der Liste; die Zuordnung
// gibt es nur über /containers/{id}/json.
mux.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
list := []docker.Container{}
for _, c := range f.containers {
if c.State != "running" {
c.Ports = nil
}
list = append(list, c)
}
json.NewEncoder(w).Encode(list)
})
mux.HandleFunc("/containers/{id}/json", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.containers {
if c.ID != r.PathValue("id") {
continue
}
bindings := map[string][]map[string]string{}
for _, p := range c.Ports {
key := fmt.Sprintf("%d/%s", p.PrivatePort, p.Type)
bindings[key] = append(bindings[key], map[string]string{"HostIp": p.IP, "HostPort": fmt.Sprint(p.PublicPort)})
}
state := map[string]any{"Status": c.State, "StartedAt": c.StartedAt}
if c.Health != "" {
state["Health"] = map[string]string{"Status": c.Health}
}
json.NewEncoder(w).Encode(map[string]any{"Id": c.ID, "State": state, "HostConfig": map[string]any{"PortBindings": bindings}})
return
}
http.Error(w, `{"message":"No such container"}`, http.StatusNotFound)
})
mux.HandleFunc("/info", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"Name":"webdocker11","OperatingSystem":"Debian GNU/Linux 13 (trixie)","ServerVersion":"29.8.2"}`))
})
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
ch := make(chan docker.Event, 8)
f.mu.Lock()
f.subs = append(f.subs, ch)
f.mu.Unlock()
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
for {
select {
case <-r.Context().Done():
return
case ev := <-ch:
json.NewEncoder(w).Encode(ev)
w.(http.Flusher).Flush()
}
}
})
srv := &http.Server{Handler: mux}
go srv.Serve(ln)
t.Cleanup(func() { srv.Close(); os.RemoveAll(dir) })
return f
}
// set ersetzt die Containerliste und meldet ein Event.
func (f *fakeDocker) set(action string, containers ...docker.Container) {
f.mu.Lock()
f.containers = containers
subs := append([]chan docker.Event(nil), f.subs...)
f.mu.Unlock()
for _, ch := range subs {
ch <- docker.Event{Type: "container", Action: action, Actor: docker.Actor{ID: "x"}}
}
}
func wordpress() docker.Container {
return ctr("wordpress", wpLabels(), pub(80, 8081))
}
func testConfig(t *testing.T, dataDir string, s *testServer, token, socket string) Config {
return Config{
DataDir: dataDir,
ServerURL: s.url(),
Token: token,
NodeID: "node-1",
Hostname: "pi",
DockerSocket: socket,
BackendHost: "100.64.0.42",
Logger: slog.New(slog.DiscardHandler),
Debounce: 20 * time.Millisecond,
RetryInterval: 50 * time.Millisecond,
ReconnectMin: 20 * time.Millisecond,
}
}
// runAgent startet den Agenten im Hintergrund; stop beendet ihn und
// liefert den Rückgabewert von Run.
func runAgent(t *testing.T, cfg Config) (stop func() error) {
t.Helper()
a, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
actx, cancel := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- a.Run(actx) }()
var once sync.Once
var result error
stop = func() error {
once.Do(func() {
cancel()
select {
case result = <-done:
case <-time.After(5 * time.Second):
result = errors.New("Agent endet nicht")
}
})
return result
}
t.Cleanup(func() { stop() })
return stop
}
// Der ganze Weg: Enrollment, Stream, Container → Route bei Traefik,
// Container weg → Route weg, Agent weg → offline, Neustart ohne Token.
func TestRun_EndToEnd(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t, wordpress())
dataDir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dataDir, s, s.invite(t, "kunde"), dock.socket)
stop := runAgent(t, cfg)
eventually(t, "Route bei Traefik", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__wp") && strings.Contains(c, "http://100.64.0.42:8081")
})
if st := s.nodeStatus(t, "node-1"); st != store.NodeOnline {
t.Errorf("Knotenstatus %q", st)
}
// Gestoppt (docker stop, Absturz): Wartungsseite statt 404.
down := wordpress()
down.State = "exited"
dock.set("die", down)
eventually(t, "Wartungsseite nach Containerstopp", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__wp") && strings.Contains(c, "sultan__interceptor") &&
strings.Contains(c, `"maintenance"`) && strings.Contains(c, "http://100.64.0.42:8081")
})
dock.set("destroy") // entfernt (docker compose down): Route weg
eventually(t, "Route nach Entfernen des Containers weg", func() bool { return s.traefikConfig(t) == "{}" })
dock.set("start", wordpress())
eventually(t, "Route nach Containerstart zurück", func() bool {
return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp")
})
if err := stop(); err != nil {
t.Errorf("Run: %v", err)
}
eventually(t, "Knoten offline", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOffline })
// Ungeplant weg: Wartungsseite, solange die Lease gilt.
eventually(t, "Wartungsseite", func() bool { return strings.Contains(s.traefikConfig(t), "sultan__interceptor") })
// Neustart: schon enrollt, kein Token mehr nötig (Einladung ist verbraucht).
cfg.Token = ""
runAgent(t, cfg)
eventually(t, "Route nach Neustart des Agenten wieder normal", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__wp") && !strings.Contains(c, "sultan__interceptor")
})
}
// Nach einem Neustart des Servers verbindet sich der Agent neu und sendet
// seinen Zustand erneut (OnConnect).
func TestRun_ResendsStateAfterServerRestart(t *testing.T) {
serverDir := filepath.Join(t.TempDir(), "server")
s := startServer(t, serverDir, "")
addr := s.AgentAddr()
dock := newFakeDocker(t, wordpress())
runAgent(t, testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket))
eventually(t, "Route", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") })
s.Shutdown(ctx)
s2 := startServer(t, serverDir, addr) // gleiche Identität, gleiche Adresse
eventually(t, "Route nach Server-Neustart", func() bool {
return strings.Contains(s2.traefikConfig(t), "kunde__node-1__wp")
})
}
func TestEnroll_WritesIdentity(t *testing.T) {
s := startServer(t, "", "")
dataDir := filepath.Join(t.TempDir(), "suldata")
a, err := New(testConfig(t, dataDir, s, s.invite(t, "kunde"), ""))
if err != nil {
t.Fatal(err)
}
if err := a.Enroll(ctx); err != nil {
t.Fatalf("Enroll: %v", err)
}
for file, perm := range map[string]os.FileMode{"node.key": 0o600, "node.json": 0o600} {
info, err := os.Stat(filepath.Join(dataDir, file))
if err != nil || info.Mode().Perm() != perm {
t.Errorf("%s: %v %v", file, info, err)
}
}
var id Identity
b, _ := os.ReadFile(filepath.Join(dataDir, "node.json"))
if err := json.Unmarshal(b, &id); err != nil {
t.Fatal(err)
}
if id.NodeID != "node-1" || id.TenantID != "kunde" || id.Server != s.url() ||
id.MasterPublicKey != auth.EncodePublicKey(s.MasterPublicKey()) || id.ServerFingerprint != s.Fingerprint() {
t.Errorf("node.json: %+v", id)
}
if _, err := os.Stat(filepath.Join(dataDir, "known_servers")); err == nil {
t.Error("known_servers ist überflüssig – der Pin steht in node.json")
}
// Zweites Enroll: nichts zu tun, kein Token nötig.
a2, _ := New(Config{DataDir: dataDir, Logger: slog.New(slog.DiscardHandler)})
if err := a2.Enroll(ctx); err != nil {
t.Errorf("erneutes Enroll ohne Token: %v", err)
}
}
func TestEnroll_Errors(t *testing.T) {
s := startServer(t, "", "")
other := startServer(t, "", "")
t.Run("erster Start ohne Token", func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
a, _ := New(testConfig(t, dir, s, "", ""))
if err := a.Enroll(ctx); !errors.Is(err, ErrNotEnrolled) {
t.Errorf("erwartet ErrNotEnrolled, bekommen %v", err)
}
})
t.Run("Token ohne URL und ohne --server", func(t *testing.T) {
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), "")
cfg.ServerURL = ""
a, _ := New(cfg)
if err := a.Enroll(ctx); !errors.Is(err, ErrNotEnrolled) || !strings.Contains(err.Error(), "--server") {
t.Errorf("erwartet Hinweis auf --server, bekommen %v", err)
}
})
t.Run("Token eines anderen Servers", func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
a, _ := New(testConfig(t, dir, s, other.invite(t, "kunde"), ""))
if err := a.Enroll(ctx); !errors.Is(err, crypto.ErrFingerprintMismatch) {
t.Errorf("erwartet ErrFingerprintMismatch, bekommen %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "node.json")); err == nil {
t.Error("node.json trotz Fehlschlag geschrieben")
}
})
t.Run("Einladung verbraucht", func(t *testing.T) {
tok := s.invite(t, "kunde")
a, _ := New(testConfig(t, filepath.Join(t.TempDir(), "a"), s, tok, ""))
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
cfg := testConfig(t, filepath.Join(t.TempDir(), "b"), s, tok, "")
cfg.NodeID = "node-2"
b, _ := New(cfg)
if err := b.Enroll(ctx); err == nil || !strings.Contains(err.Error(), "403") {
t.Errorf("verbrauchte Einladung: %v", err)
}
})
// Registriert bei s, dann kommt eine Einladung von other: anderer
// Fingerprint = anderer Server = nur mit leerem Datenverzeichnis.
t.Run("Einladung eines anderen Servers trotz Registrierung", func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), "")
cfg.NodeID = "node-wechsel" // node-1 ist auf s schon vergeben
first, _ := New(cfg)
if err := first.Enroll(ctx); err != nil {
t.Fatal(err)
}
switched, _ := New(testConfig(t, dir, other, other.invite(t, "kunde"), ""))
if err := switched.Enroll(ctx); err == nil || !strings.Contains(err.Error(), "anderen Server") {
t.Errorf("Serverwechsel ohne neues Enrollment: %v", err)
}
})
}
func TestNew_Validation(t *testing.T) {
dir := t.TempDir()
if _, err := New(Config{DataDir: dir, ServerURL: "http://ingress:8443", Token: "x", NodeID: "n"}); err == nil {
t.Error("http-URL akzeptiert")
}
if _, err := New(Config{DataDir: dir, ServerURL: "https://ingress:8443", NodeID: "a__b"}); err == nil {
t.Error("ungültige Knoten-ID akzeptiert")
}
a, err := New(Config{DataDir: dir, ServerURL: "https://ingress:8443/", Hostname: "Mein.Pi"})
if err != nil {
t.Fatal(err)
}
if a.cfg.NodeID != "mein-pi" || a.cfg.ServerURL != "https://ingress:8443" || a.cfg.DockerSocket != "/var/run/docker.sock" {
t.Errorf("Defaults: %+v", a.cfg)
}
if def, _ := New(Config{}); def.cfg.DataDir != "./suldata" {
t.Errorf("Default-Datenverzeichnis: %q", def.cfg.DataDir)
}
}
// Ohne Docker (Bare-Metal) meldet der Agent einen leeren Zustand und bleibt
// verbunden; ein fehlender Socket ist kein Fehler.
func TestRun_WithoutDocker(t *testing.T) {
s := startServer(t, "", "")
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), "/gibt/es/nicht.sock")
runAgent(t, cfg)
eventually(t, "Knoten online", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
if c := s.traefikConfig(t); c != "{}" {
t.Errorf("Konfiguration ohne Docker: %s", c)
}
}
// Läuft Run ohne Enrollment-Möglichkeit, endet es sofort mit Fehler.
func TestRun_NotEnrolled(t *testing.T) {
s := startServer(t, "", "")
a, _ := New(testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, "", ""))
if err := a.Run(ctx); !errors.Is(err, ErrNotEnrolled) {
t.Errorf("erwartet ErrNotEnrolled, bekommen %v", err)
}
}
// Bare-Metal-Weg: Dienst aus /etc/sultan.d → Route; Healthcheck schlägt fehl →
// Wartungsseite (Interceptor); Dienst erholt sich → wieder normal; Datei
// geändert/gelöscht → Konfiguration folgt.
func TestRun_ServiceDirWithHealthcheck(t *testing.T) {
s := startServer(t, "", "")
var healthy atomic.Bool
healthy.Store(true)
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !healthy.Load() {
http.Error(w, "kaputt", http.StatusInternalServerError)
return
}
w.Write([]byte("
ShopImpressum
"))
}))
defer backend.Close()
port := backend.URL[strings.LastIndex(backend.URL, ":")+1:]
confDir := t.TempDir()
writeConf := func(content string) {
if err := os.WriteFile(filepath.Join(confDir, "shop.yaml"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
writeConf(`
services:
- name: shop
url: ` + backend.URL + `
hosts: [shop.kunde.de]
healthcheck: { interval: 30ms, failures: 2, expect_title: Shop }
`)
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), NoDocker)
cfg.ConfigDir = confDir
cfg.ConfigPoll = 20 * time.Millisecond
runAgent(t, cfg)
eventually(t, "Route aus sultan.d", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__shop") && strings.Contains(c, "http://100.64.0.42:"+port)
})
if strings.Contains(s.traefikConfig(t), "sultan__interceptor") {
t.Error("gesunder Dienst wird umgeleitet")
}
healthy.Store(false)
eventually(t, "Wartungsseite bei Ausfall", func() bool {
return strings.Contains(s.traefikConfig(t), "sultan__interceptor")
})
healthy.Store(true)
eventually(t, "zurück zum Dienst nach Erholung", func() bool {
return !strings.Contains(s.traefikConfig(t), "sultan__interceptor")
})
writeConf(`
services:
- name: shop
url: ` + backend.URL + `
hosts: [shop.kunde.de, laden.kunde.de]
`)
eventually(t, "geänderte Datei übernommen", func() bool {
return strings.Contains(s.traefikConfig(t), "laden.kunde.de")
})
os.Remove(filepath.Join(confDir, "shop.yaml"))
eventually(t, "gelöschte Datei → Route weg", func() bool { return s.traefikConfig(t) == "{}" })
}
// Docker- und Datei-Workloads werden gemeinsam gemeldet.
func TestRun_DockerAndServiceDirTogether(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t, wordpress())
confDir := t.TempDir()
os.WriteFile(filepath.Join(confDir, "blog.yaml"), []byte(`
services:
- name: blog
url: http://127.0.0.1:8090
hosts: [blog.kunde.de]
`), 0o644)
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket)
cfg.ConfigDir = confDir
cfg.ConfigPoll = 20 * time.Millisecond
runAgent(t, cfg)
eventually(t, "beide Routen", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__wp") && strings.Contains(c, "kunde__node-1__blog")
})
}
// inviteURL erzeugt eine Einladung mit eingebetteter Server-URL.
func (s *testServer) inviteURL(t *testing.T, tenant, serverURL string) string {
t.Helper()
s.invite(t, tenant) // legt den Mandanten bei Bedarf an
tok, err := crypto.NewEnrollmentToken(s.Fingerprint(), serverURL)
if err != nil {
t.Fatal(err)
}
if err := s.db(t).AddEnrollmentToken(ctx, tok.Secret, tenant, time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
return tok.String()
}
func readIdentity(t *testing.T, dataDir string) Identity {
t.Helper()
var id Identity
b, err := os.ReadFile(filepath.Join(dataDir, "node.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(b, &id); err != nil {
t.Fatal(err)
}
return id
}
// Ein einziger Token genügt: die Adresse steckt darin.
func TestEnroll_URLFromToken(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.inviteURL(t, "kunde", s.url()), "")
cfg.ServerURL = ""
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatalf("Enroll nur mit Token: %v", err)
}
if id := readIdentity(t, dir); id.Server != s.url() || id.ServerFingerprint != s.Fingerprint() {
t.Errorf("node.json: %+v", id)
}
}
// --server schlägt die URL im Token (z. B. interne Adresse statt öffentlicher).
func TestEnroll_ServerFlagOverridesTokenURL(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.inviteURL(t, "kunde", "https://nicht-erreichbar.invalid:8443"), "")
a, _ := New(cfg) // cfg.ServerURL = s.url()
if err := a.Enroll(ctx); err != nil {
t.Fatalf("Enroll mit Override: %v", err)
}
if id := readIdentity(t, dir); id.Server != s.url() {
t.Errorf("gespeicherte Adresse %q, erwartet die tatsächlich genutzte %q", id.Server, s.url())
}
}
// Ist der Agent registriert, darf dieselbe Einladung (bzw. eine des
// gleichen Servers) erneut mitgegeben werden – sie wird ignoriert.
func TestEnroll_SameServerTokenIgnoredWhenEnrolled(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
tok := s.inviteURL(t, "kunde", s.url())
cfg := testConfig(t, dir, s, tok, "")
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
again, _ := New(cfg) // gleicher, inzwischen verbrauchter Token (z. B. in docker-compose stehen geblieben)
if err := again.Enroll(ctx); err != nil {
t.Errorf("erneuter Start mit altem Token: %v", err)
}
}
// Der Pin hängt am Server, nicht an der Adresse: derselbe Server unter
// neuer Adresse funktioniert, die neue Adresse wird übernommen.
func TestRun_AddressChangePersisted(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t, wordpress())
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), dock.socket)
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
moved := "https://localhost" + s.url()[len("https://127.0.0.1"):]
cfg.Token, cfg.ServerURL = "", moved
runAgent(t, cfg)
eventually(t, "Route über neue Adresse", func() bool {
return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp")
})
eventually(t, "neue Adresse gespeichert", func() bool { return readIdentity(t, dir).Server == moved })
if id := readIdentity(t, dir); id.ServerFingerprint != s.Fingerprint() {
t.Errorf("Fingerprint verändert: %+v", id)
}
}
// `sultan agent maintenance on|off` auf dem Knoten, z. B. aus einem Patch-Skript.
func TestSetMaintenance(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t, wordpress())
dir := filepath.Join(t.TempDir(), "suldata")
runAgent(t, testConfig(t, dir, s, s.invite(t, "kunde"), dock.socket))
eventually(t, "Route", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") })
ctl, err := New(Config{DataDir: dir, Logger: slog.New(slog.DiscardHandler)})
if err != nil {
t.Fatal(err)
}
if err := ctl.SetMaintenance(ctx, true, 2*time.Hour, "Kernel-Update", "Sicherheitsupdate"); err != nil {
t.Fatalf("SetMaintenance: %v", err)
}
n, _ := s.db(t).GetNode(ctx, "node-1")
if n.Maintenance == nil || n.Maintenance.Reason != "Kernel-Update" || n.Maintenance.PublicReason != "Sicherheitsupdate" {
t.Errorf("gespeichert: %+v", n.Maintenance)
}
if !strings.Contains(s.traefikConfig(t), "sultan__interceptor") {
t.Error("Wartung nicht wirksam")
}
if err := ctl.SetMaintenance(ctx, false, 0, "", ""); err != nil {
t.Fatal(err)
}
if strings.Contains(s.traefikConfig(t), "sultan__interceptor") {
t.Error("Wartung nicht beendet")
}
notEnrolled, _ := New(Config{DataDir: filepath.Join(t.TempDir(), "leer"), Logger: slog.New(slog.DiscardHandler)})
if err := notEnrolled.SetMaintenance(ctx, true, time.Hour, "", ""); !errors.Is(err, ErrNotEnrolled) {
t.Errorf("nicht registriert: %v", err)
}
}
func TestRun_LeaseRecorded(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
var logs syncBuffer
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker)
cfg.Logger = slog.New(slog.NewTextHandler(&logs, nil))
runAgent(t, cfg)
eventually(t, "lease.json", func() bool {
_, err := os.Stat(filepath.Join(dir, "lease.json"))
return err == nil
})
b, _ := os.ReadFile(filepath.Join(dir, "lease.json"))
l, err := license.ParseLease(b, s.MasterPublicKey())
if err != nil || l.NodeID != "node-1" {
t.Errorf("lease.json: %+v %v", l, err)
}
eventually(t, "Lease im Log", func() bool { return strings.Contains(logs.String(), "Lease erhalten") })
}
// resync: Der Server fordert den Zustand neu an (hier: Container geändert,
// ohne dass Docker ein Event geschickt hat).
func TestRun_ResyncCommand(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t)
runAgent(t, testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket))
eventually(t, "online", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
dock.mu.Lock()
dock.containers = []docker.Container{wordpress()}
dock.mu.Unlock()
if err := s.db(t).EnqueueNodeCommand(ctx, "node-1", stream.CmdResync, ""); err != nil {
t.Fatal(err)
}
eventually(t, "Zustand nach resync", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") })
}
func TestRun_RotateKeyCommand(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t)
dir := filepath.Join(t.TempDir(), "suldata")
runAgent(t, testConfig(t, dir, s, s.invite(t, "kunde"), dock.socket))
eventually(t, "online", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
before, _ := os.ReadFile(filepath.Join(dir, "node.key"))
oldPub, _ := s.db(t).GetNode(ctx, "node-1")
s.db(t).EnqueueNodeCommand(ctx, "node-1", stream.CmdRotateKey, "Routine")
eventually(t, "Schlüssel gewechselt", func() bool {
n, _ := s.db(t).GetNode(ctx, "node-1")
after, _ := os.ReadFile(filepath.Join(dir, "node.key"))
return n.PublicKey != oldPub.PublicKey && string(after) != string(before)
})
if _, err := os.Stat(filepath.Join(dir, "node.key.new")); err == nil {
t.Error("node.key.new nach erfolgreicher Rotation noch vorhanden")
}
// Der Agent arbeitet mit dem neuen Schlüssel weiter.
dock.set("start", wordpress())
eventually(t, "Zustand mit neuem Schlüssel", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") })
}
// Abbruch mitten in der Rotation: Der Server hat den neuen Schlüssel schon,
// der Agent hat node.key.new noch nicht umbenannt. Beim nächsten Start wird
// das sicher abgeschlossen.
func TestRotateKey_RecoveryAfterCrash(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker)
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
newKey, _ := auth.GenerateKey()
if err := auth.WritePrivateKey(filepath.Join(dir, "node.key.new"), newKey); err != nil {
t.Fatal(err)
}
newPub := auth.EncodePublicKey(newKey.Public().(ed25519.PublicKey))
if err := s.db(t).UpdateNodePublicKey(ctx, "node-1", newPub); err != nil { // Server war schon fertig
t.Fatal(err)
}
cfg.Token = ""
runAgent(t, cfg)
eventually(t, "online mit neuem Schlüssel", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
got, err := auth.ReadPrivateKey(filepath.Join(dir, "node.key"))
if err != nil || !got.Equal(newKey) {
t.Errorf("node.key nicht durch den neuen Schlüssel ersetzt: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "node.key.new")); err == nil {
t.Error("node.key.new noch vorhanden")
}
}
// Unsignierte oder gefälschte Events werden ignoriert – kein Befehl ohne
// gültige Signatur des Servers.
func TestHandleEvent_RejectsUnsigned(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
var logs syncBuffer
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker)
cfg.Logger = slog.New(slog.NewTextHandler(&logs, nil))
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(filepath.Join(dir, "node.key"))
forged, _ := stream.NewEvent(stream.EventNodeCommand, stream.NodeCommand{Command: stream.CmdRotateKey})
if err := a.handleEvent(forged); err != nil {
t.Errorf("handleEvent darf den Stream nicht beenden: %v", err)
}
after, _ := os.ReadFile(filepath.Join(dir, "node.key"))
if string(before) != string(after) {
t.Error("unsignierter Befehl wurde ausgeführt")
}
if !strings.Contains(logs.String(), "verworfen") {
t.Errorf("keine Warnung im Log: %s", logs.String())
}
}
type syncBuffer struct {
mu sync.Mutex
b strings.Builder
}
func (s *syncBuffer) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.Write(p)
}
func (s *syncBuffer) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() }
// Der Server schickt beim Verbinden seine Adressliste; der Agent merkt sie
// sich in node.json (nur gültige https-Adressen).
func TestRun_StoresServerURLsFromConfig(t *testing.T) {
s := startServerWith(t, func(c *server.Config) {
c.PublicURLs = []string{"https://ingress.example:8443", "https://100.64.0.1:8443"}
})
dir := filepath.Join(t.TempDir(), "suldata")
runAgent(t, testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker))
eventually(t, "server_urls in node.json", func() bool {
b, err := os.ReadFile(filepath.Join(dir, "node.json"))
var id Identity
return err == nil && json.Unmarshal(b, &id) == nil && len(id.ServerURLs) == 2
})
if id := readIdentity(t, dir); id.ServerURLs[0] != "https://ingress.example:8443" || id.Server != s.url() {
t.Errorf("node.json: %+v", id)
}
}
// Ausweichweg: Die zuletzt genutzte Adresse ist tot, eine andere aus der
// Liste funktioniert – der Agent verbindet sich darüber und merkt sie sich.
func TestRun_FailoverToNextServerURL(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker)
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
id := readIdentity(t, dir)
dead := "https://127.0.0.1:1"
id.Server, id.ServerURLs = dead, []string{dead, s.url()}
if err := writeJSON(filepath.Join(dir, "node.json"), id); err != nil {
t.Fatal(err)
}
cfg.Token, cfg.ServerURL = "", ""
runAgent(t, cfg)
eventually(t, "online über Ausweichadresse", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
eventually(t, "funktionierende Adresse gemerkt", func() bool { return readIdentity(t, dir).Server == s.url() })
}
// --server hat Vorrang, die übrigen Adressen dienen als Ausweichweg.
func TestServerCandidates(t *testing.T) {
a := &Agent{cfg: Config{ServerURL: "https://override:8443"}, id: Identity{
Server: "https://zuletzt:8443", ServerURLs: []string{"https://a:8443", "https://zuletzt:8443", "https://b:8443"},
}}
want := []string{"https://override:8443", "https://zuletzt:8443", "https://a:8443", "https://b:8443"}
if got := a.serverCandidates(); !reflect.DeepEqual(got, want) {
t.Errorf("Kandidaten %v, erwartet %v", got, want)
}
a.cfg.ServerURL = ""
if got := a.serverCandidates(); got[0] != "https://zuletzt:8443" || len(got) != 3 {
t.Errorf("ohne Override: %v", got)
}
}
// Zusatzdaten für Inventar und Störfall kommen automatisch beim Server an:
// Compose-Projekt/-Verzeichnis, Startzeit, Health je Container; FQDN,
// Betriebssystem (des Hosts) und Docker-Version je Knoten. Ein Wechsel des
// Health-Status wird nachgemeldet.
func TestRun_ReportsMetadata(t *testing.T) {
s := startServer(t, "", "")
started := time.Date(2026, 10, 16, 8, 15, 30, 0, time.UTC)
wp := wordpress()
wp.Labels["com.docker.compose.project"] = "kunde-a"
wp.Labels["com.docker.compose.service"] = "wordpress"
wp.Labels["com.docker.compose.project.working_dir"] = "/srv/kunde-a"
wp.StartedAt = started
wp.Health = "healthy"
dock := newFakeDocker(t, wp)
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket)
cfg.FQDN = "webdocker11.lan.r25dc.de"
runAgent(t, cfg)
var st registry.NodeState
eventually(t, "Zustand mit Zusatzdaten", func() bool {
var ok bool
st, ok = s.NodeState("node-1")
return ok && len(st.Workloads) == 1 && st.Host != nil
})
wantMeta := ®istry.WorkloadMeta{
ComposeProject: "kunde-a", ComposeService: "wordpress", ComposeDir: "/srv/kunde-a",
State: "running", StartedAt: started, Health: "healthy",
}
if !reflect.DeepEqual(st.Workloads[0].Meta, wantMeta) {
t.Errorf("Meta = %+v, erwartet %+v", st.Workloads[0].Meta, wantMeta)
}
if h := st.Host; h.FQDN != "webdocker11.lan.r25dc.de" || h.OS != "Debian GNU/Linux 13 (trixie)" || h.DockerVersion != "29.8.2" {
t.Errorf("Host = %+v", h)
}
sick := wp
sick.Health = "unhealthy"
dock.set("health_status: unhealthy", sick)
eventually(t, "Health-Wechsel nachgemeldet", func() bool {
st, ok := s.NodeState("node-1")
return ok && len(st.Workloads) == 1 && st.Workloads[0].Meta != nil && st.Workloads[0].Meta.Health == "unhealthy"
})
}