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/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)}) } json.NewEncoder(w).Encode(map[string]any{"Id": c.ID, "HostConfig": map[string]any{"PortBindings": bindings}}) return } http.Error(w, `{"message":"No such container"}`, http.StatusNotFound) }) 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("Shop

Impressum

")) })) 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) } }