Files
sultan/pkg/server/server_test.go
T
2026-10-01 23:52:15 +02:00

487 lines
15 KiB
Go

package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/policy"
"sultan/pkg/registry"
"sultan/pkg/store"
"sultan/pkg/stream"
)
var ctx = context.Background()
// syncBuffer sammelt Log-Ausgaben threadsicher.
type syncBuffer struct {
mu sync.Mutex
b bytes.Buffer
}
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() }
func eventually(t *testing.T, msg string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("Bedingung nicht erfüllt: %s", msg)
}
time.Sleep(5 * time.Millisecond)
}
}
// startServer startet einen Server auf freien Ports in einem frischen
// Datenverzeichnis.
func startServer(t *testing.T, mutate ...func(*Config)) (*Server, *syncBuffer) {
t.Helper()
logs := &syncBuffer{}
cfg := Config{
DataDir: filepath.Join(t.TempDir(), "suldata"),
AgentAddr: "127.0.0.1:0",
ProviderAddr: "127.0.0.1:0",
RefreshInterval: time.Hour,
Logger: slog.New(slog.NewTextHandler(logs, nil)),
}
for _, m := range mutate {
m(&cfg)
}
s, err := New(ctx, cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := s.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() {
sctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
s.Shutdown(sctx)
})
return s, logs
}
func createTenant(t *testing.T, s *Server, id string, domains ...string) {
t.Helper()
if _, err := s.store.CreateTenant(ctx, store.Tenant{
ID: id, Name: id, Email: id + "@example.com", Tier: store.TierPro, MaxRoutes: 10,
AllowedDomains: domains, ValidUntil: time.Now().AddDate(1, 0, 0),
}); err != nil {
t.Fatal(err)
}
}
// issueToken erzeugt einen Enrollment-Token wie `sultan token` es tun wird.
func issueToken(t *testing.T, s *Server, tenant string) string {
t.Helper()
tok, err := crypto.NewEnrollmentToken(s.Fingerprint())
if err != nil {
t.Fatal(err)
}
if err := s.store.AddEnrollmentToken(ctx, tok.Secret, tenant, time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
return tok.String()
}
// testAgent spielt die Agent-Seite mit Pinning und Signatur.
type testAgent struct {
base string
hc *http.Client
signer *auth.Signer
secret string
}
func newAgent(t *testing.T, s *Server, token string) *testAgent {
t.Helper()
tok, err := crypto.ParseEnrollmentToken(token)
if err != nil {
t.Fatal(err)
}
ks := &crypto.KnownServers{Path: filepath.Join(t.TempDir(), "known_servers")}
priv, _ := auth.GenerateKey()
return &testAgent{
base: "https://" + s.AgentAddr(),
hc: &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.ClientTLSConfig(s.AgentAddr(), ks, tok.Fingerprint)}},
signer: &auth.Signer{Key: priv},
secret: tok.Secret,
}
}
func (a *testAgent) do(t *testing.T, method, path string, body any) *http.Response {
t.Helper()
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, a.base+path, rd)
if err := a.signer.Sign(req); err != nil {
t.Fatal(err)
}
resp, err := a.hc.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
t.Cleanup(func() { resp.Body.Close() })
return resp
}
func (a *testAgent) enroll(t *testing.T, nodeID string) *http.Response {
return a.do(t, http.MethodPost, "/api/v1/enroll", EnrollRequest{Token: a.secret, NodeID: nodeID, Hostname: "host-" + nodeID})
}
// connect hält den Stream im Hintergrund offen, bis cancel aufgerufen wird.
func (a *testAgent) connect(t *testing.T) (cancel func()) {
t.Helper()
sctx, cancel := context.WithCancel(ctx)
c := &stream.Client{URL: a.base + "/api/v1/stream", HTTPClient: a.hc, Prepare: a.signer.Sign, MinBackoff: 10 * time.Millisecond}
done := make(chan struct{})
go func() { c.Run(sctx, func(stream.Event) error { return nil }); close(done) }()
t.Cleanup(func() { cancel(); <-done })
return cancel
}
func wpState(domain string) registry.NodeState {
return registry.NodeState{Workloads: []registry.Workload{{
Name: "wordpress", Host: "100.64.0.42",
Labels: map[string]string{
"traefik.enable": "true",
"traefik.http.routers.wp.rule": "Host(`" + domain + "`)",
"traefik.http.services.wp.loadbalancer.server.port": "80",
},
}}}
}
func providerConfig(t *testing.T, s *Server) 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 TestConfigDefaults(t *testing.T) {
c := Config{}.withDefaults()
if c.DataDir != "./suldata" || c.AgentAddr != ":8443" || c.ProviderAddr != "127.0.0.1:8080" {
t.Errorf("Defaults: %+v", c)
}
if c.InterceptorURL != "http://127.0.0.1:8081" || c.RefreshInterval != time.Minute || c.Logger == nil {
t.Errorf("Defaults: %+v", c)
}
}
func TestNew_CreatesDataDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "a", "suldata")
s, err := New(ctx, Config{DataDir: dir, Logger: slog.New(slog.DiscardHandler)})
if err != nil {
t.Fatalf("New: %v", err)
}
defer s.Shutdown(ctx)
info, err := os.Stat(dir)
if err != nil || info.Mode().Perm() != 0o700 {
t.Fatalf("Datenverzeichnis: %v, %v", info, err)
}
for _, f := range []string{"sultan.db", "server.key", "server.crt", "master.key"} {
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
t.Errorf("%s fehlt: %v", f, err)
}
}
if info, _ := os.Stat(filepath.Join(dir, "master.key")); info.Mode().Perm() != 0o600 {
t.Errorf("master.key hat %o", info.Mode().Perm())
}
}
// Nach einem Neustart müssen Zertifikat und Master-Key gleich bleiben,
// sonst sperren sich alle Agents aus bzw. alle Lizenzen werden ungültig.
func TestNew_StableIdentityAcrossRestarts(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
cfg := Config{DataDir: dir, Logger: slog.New(slog.DiscardHandler)}
a, err := New(ctx, cfg)
if err != nil {
t.Fatal(err)
}
fp, mk := a.Fingerprint(), a.MasterPublicKey()
a.Shutdown(ctx)
b, err := New(ctx, cfg)
if err != nil {
t.Fatalf("Neustart: %v", err)
}
defer b.Shutdown(ctx)
if b.Fingerprint() != fp || !b.MasterPublicKey().Equal(mk) {
t.Error("Identität hat sich beim Neustart geändert")
}
}
func TestProviderPort(t *testing.T) {
s, _ := startServer(t)
if got := providerConfig(t, s); got != "{}" {
t.Errorf("leere Konfiguration = %q", got)
}
resp, err := http.Get("http://" + s.ProviderAddr() + "/healthz")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("/healthz: %d", resp.StatusCode)
}
// Agent-Routen gibt es auf dem Klartext-Port nicht.
resp, _ = http.Post("http://"+s.ProviderAddr()+"/api/v1/enroll", "application/json", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("Agent-Route auf Provider-Port: %d", resp.StatusCode)
}
}
func TestAgentPort_NoPlainHTTP(t *testing.T) {
s, _ := startServer(t)
resp, err := http.Get("http://" + s.AgentAddr() + "/api/v1/stream")
if err == nil {
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
t.Error("Agent-Port antwortet im Klartext mit 200")
}
}
}
// Ablauf wie im Betrieb: Enrollment, Stream, Zustand melden, Route bei
// Traefik, Agent weg → Route weg und Knoten offline.
func TestEndToEnd(t *testing.T) {
s, _ := startServer(t)
createTenant(t, s, "kunde", "kunde.de", "*.kunde.de")
agent := newAgent(t, s, issueToken(t, s, "kunde"))
resp := agent.enroll(t, "node-1")
if resp.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("Enrollment: %d %s", resp.StatusCode, b)
}
var er EnrollResponse
json.NewDecoder(resp.Body).Decode(&er)
if er.NodeID != "node-1" || er.TenantID != "kunde" || er.MasterPublicKey != auth.EncodePublicKey(s.MasterPublicKey()) {
t.Errorf("Antwort: %+v", er)
}
stop := agent.connect(t)
eventually(t, "Stream verbunden", func() bool { return s.hub.Connected("node-1") })
eventually(t, "Knoten online", func() bool {
n, _ := s.store.GetNode(ctx, "node-1")
return n.Status == store.NodeOnline
})
if resp := agent.do(t, http.MethodPost, "/api/v1/state", wpState("kunde.de")); resp.StatusCode != http.StatusNoContent {
t.Fatalf("State: %d", resp.StatusCode)
}
if cfg := providerConfig(t, s); !strings.Contains(cfg, "kunde__node-1__wp") || !strings.Contains(cfg, "http://100.64.0.42:80") {
t.Errorf("Traefik-Konfiguration: %s", cfg)
}
stop()
eventually(t, "Route nach Trennung entfernt", func() bool { return providerConfig(t, s) == "{}" })
eventually(t, "Knoten offline", func() bool {
n, _ := s.store.GetNode(ctx, "node-1")
return n.Status == store.NodeOffline
})
}
func TestEnroll_Errors(t *testing.T) {
s, _ := startServer(t)
createTenant(t, s, "kunde", "kunde.de")
used := newAgent(t, s, issueToken(t, s, "kunde"))
if r := used.enroll(t, "n1"); r.StatusCode != http.StatusCreated {
t.Fatalf("erstes Enrollment: %d", r.StatusCode)
}
again := newAgent(t, s, issueToken(t, s, "kunde"))
again.secret = used.secret // bereits eingelöst
if r := again.enroll(t, "n2"); r.StatusCode != http.StatusForbidden {
t.Errorf("verbrauchter Token: %d", r.StatusCode)
}
unknown := newAgent(t, s, issueToken(t, s, "kunde"))
unknown.secret = crypto.TokenPrefix + strings.Repeat("A", 43)
if r := unknown.enroll(t, "n3"); r.StatusCode != http.StatusForbidden {
t.Errorf("unbekannter Token: %d", r.StatusCode)
}
if r := newAgent(t, s, issueToken(t, s, "kunde")).enroll(t, "a__b"); r.StatusCode != http.StatusBadRequest {
t.Errorf("ungültige Knoten-ID: %d", r.StatusCode)
}
if r := newAgent(t, s, issueToken(t, s, "kunde")).enroll(t, "n1"); r.StatusCode != http.StatusConflict {
t.Errorf("doppelte Knoten-ID: %d", r.StatusCode)
}
// Ohne Signatur gibt es nichts.
plain := newAgent(t, s, issueToken(t, s, "kunde"))
req, _ := http.NewRequest(http.MethodPost, plain.base+"/api/v1/enroll", strings.NewReader(`{}`))
resp, err := plain.hc.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("unsigniert: %d", resp.StatusCode)
}
}
func TestState_Errors(t *testing.T) {
s, _ := startServer(t)
createTenant(t, s, "kunde", "kunde.de")
agent := newAgent(t, s, issueToken(t, s, "kunde"))
agent.enroll(t, "n1")
// Zustand nur bei offenem Stream (Lebendigkeit hängt am Stream).
if r := agent.do(t, http.MethodPost, "/api/v1/state", wpState("kunde.de")); r.StatusCode != http.StatusConflict {
t.Errorf("ohne Stream: %d", r.StatusCode)
}
agent.connect(t)
eventually(t, "Stream", func() bool { return s.hub.Connected("n1") })
req, _ := http.NewRequest(http.MethodPost, agent.base+"/api/v1/state", strings.NewReader("kein json"))
agent.signer.Sign(req)
resp, err := agent.hc.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("kaputtes JSON: %d", resp.StatusCode)
}
stranger := newAgent(t, s, issueToken(t, s, "kunde")) // nicht enrollt
if r := stranger.do(t, http.MethodPost, "/api/v1/state", wpState("kunde.de")); r.StatusCode != http.StatusUnauthorized {
t.Errorf("nicht registriert: %d", r.StatusCode)
}
}
func TestState_ViolationsAreLogged(t *testing.T) {
s, logs := startServer(t)
createTenant(t, s, "kunde", "kunde.de")
agent := newAgent(t, s, issueToken(t, s, "kunde"))
agent.enroll(t, "n1")
agent.connect(t)
eventually(t, "Stream", func() bool { return s.hub.Connected("n1") })
if r := agent.do(t, http.MethodPost, "/api/v1/state", wpState("bank.de")); r.StatusCode != http.StatusNoContent {
t.Fatalf("State: %d", r.StatusCode)
}
if cfg := providerConfig(t, s); cfg != "{}" {
t.Errorf("fremde Domain ausgeliefert: %s", cfg)
}
out := logs.String()
if !strings.Contains(out, "Policy-Verstoß") || !strings.Contains(out, "kind=domain") || !strings.Contains(out, "node=n1") {
t.Errorf("Audit-Log fehlt:\n%s", out)
}
}
func TestRefreshLoopAppliesTenantChanges(t *testing.T) {
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond })
createTenant(t, s, "kunde", "kunde.de")
agent := newAgent(t, s, issueToken(t, s, "kunde"))
agent.enroll(t, "n1")
agent.connect(t)
eventually(t, "Stream", func() bool { return s.hub.Connected("n1") })
agent.do(t, http.MethodPost, "/api/v1/state", wpState("kunde.de"))
if err := s.store.SetTenantStatus(ctx, "kunde", store.TenantSuspendedPayment); err != nil {
t.Fatal(err)
}
eventually(t, "Umleitung zum Interceptor", func() bool {
return strings.Contains(providerConfig(t, s), policy.InterceptorService)
})
}
func TestDirectory(t *testing.T) {
s, _ := startServer(t)
createTenant(t, s, "kunde", "kunde.de")
if _, err := s.store.CreateNode(ctx, store.Node{ID: "n1", TenantID: "kunde", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"}); err != nil {
t.Fatal(err)
}
d := &directory{store: s.store}
nc, err := d.NodeContext(ctx, "n1")
if err != nil {
t.Fatal(err)
}
if nc.Tenant.ID != "kunde" || nc.License.TenantID != "kunde" || nc.License.MaxRoutes != 10 ||
!nc.License.AllowsDomain("kunde.de") || nc.Drained {
t.Errorf("NodeContext: %+v", nc)
}
s.store.SetNodeStatus(ctx, "n1", store.NodeDrained)
if nc, _ := d.NodeContext(ctx, "n1"); !nc.Drained {
t.Error("gedraint nicht erkannt")
}
if _, err := d.NodeContext(ctx, "fehlt"); !errors.Is(err, registry.ErrUnknownNode) {
t.Errorf("unbekannter Knoten: %v", err)
}
}
// Herunterfahren darf nicht an offenen SSE-Streams hängen bleiben.
func TestRun_ShutdownWithOpenStream(t *testing.T) {
cfg := Config{
DataDir: filepath.Join(t.TempDir(), "suldata"), AgentAddr: "127.0.0.1:0", ProviderAddr: "127.0.0.1:0",
Logger: slog.New(slog.DiscardHandler),
}
s, err := New(ctx, cfg)
if err != nil {
t.Fatal(err)
}
runCtx, cancel := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- s.Run(runCtx) }()
eventually(t, "gestartet", func() bool { return s.AgentAddr() != "" && !strings.HasSuffix(s.AgentAddr(), ":0") })
createTenant(t, s, "kunde", "kunde.de")
agent := newAgent(t, s, issueToken(t, s, "kunde"))
agent.enroll(t, "n1")
agent.connect(t)
eventually(t, "Stream", func() bool { return s.hub.Connected("n1") })
cancel()
select {
case err := <-done:
if err != nil {
t.Errorf("Run: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("Run hängt beim Herunterfahren")
}
}
func TestStart_PortInUse(t *testing.T) {
a, _ := startServer(t)
b, err := New(ctx, Config{
DataDir: filepath.Join(t.TempDir(), "suldata"), AgentAddr: a.AgentAddr(), ProviderAddr: "127.0.0.1:0",
Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatal(err)
}
defer b.Shutdown(ctx)
if err := b.Start(); err == nil {
t.Error("belegter Port: erwartet Fehler")
}
}