487 lines
15 KiB
Go
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")
|
|
}
|
|
}
|