838 lines
28 KiB
Go
838 lines
28 KiB
Go
package agent
|
||
|
||
import (
|
||
"context"
|
||
"crypto/ed25519"
|
||
"encoding/json"
|
||
"errors"
|
||
"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()
|
||
mux.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
json.NewEncoder(w).Encode(f.containers)
|
||
})
|
||
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)
|
||
}
|
||
|
||
dock.set("die") // Container gestoppt
|
||
eventually(t, "Route nach Containerstopp entfernt", 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("<html><head><title>Shop</title></head><body><h2>Impressum</h2></body></html>"))
|
||
}))
|
||
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)
|
||
}
|
||
}
|