server läuft

This commit is contained in:
br committed 2026-10-01 23:52:15 +02:00
1 parent c8df9d7651
commit 42f0c0accb
21 files changed
+4185 -10

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
package server
import (
"context"
"errors"
"fmt"
"sultan/pkg/license"
"sultan/pkg/policy"
"sultan/pkg/registry"
"sultan/pkg/store"
)
// directory liefert der Registry Mandant, Lizenz und Drain-Status eines
// Knotens aus dem Store.
//
// Solange Migration v2 fehlt (Spec Abschnitt 9), wird die Lizenz aus dem
// Mandanten-Datensatz abgeleitet: ohne Features und ohne Mahnstufe.
type directory struct {
store *store.Store
}
func (d *directory) NodeContext(ctx context.Context, nodeID string) (registry.NodeContext, error) {
node, err := d.store.GetNode(ctx, nodeID)
if errors.Is(err, store.ErrNotFound) {
return registry.NodeContext{}, fmt.Errorf("%w: %s", registry.ErrUnknownNode, nodeID)
}
if err != nil {
return registry.NodeContext{}, err
}
t, err := d.store.GetTenant(ctx, node.TenantID)
if errors.Is(err, store.ErrNotFound) {
return registry.NodeContext{}, fmt.Errorf("%w: Mandant von %s fehlt", registry.ErrUnknownNode, nodeID)
}
if err != nil {
return registry.NodeContext{}, err
}
return registry.NodeContext{
Tenant: policy.Tenant{ID: t.ID, Status: t.Status},
License: license.License{
TenantID: t.ID,
Tier: license.Tier(t.Tier),
MaxRoutes: t.MaxRoutes,
AllowedDomains: t.AllowedDomains,
Features: []license.Feature{},
ValidUntil: t.ValidUntil,
},
Drained: node.Status == store.NodeDrained,
}, nil
}
+162
View File
@@ -0,0 +1,162 @@
package server
import (
"encoding/json"
"errors"
"net"
"net/http"
"strings"
"sultan/pkg/auth"
"sultan/pkg/policy"
"sultan/pkg/registry"
"sultan/pkg/store"
)
// EnrollRequest ist der Body von POST /api/v1/enroll. Der Request ist mit
// dem neuen Schlüssel des Agenten signiert (Besitznachweis).
type EnrollRequest struct {
Token string `json:"token"` // sltn_enroll_<secret>, ohne Fingerprint
NodeID string `json:"node_id"`
Hostname string `json:"hostname"`
}
// EnrollResponse ist die Antwort auf ein erfolgreiches Enrollment.
type EnrollResponse struct {
NodeID string `json:"node_id"`
TenantID string `json:"tenant_id"`
MasterPublicKey string `json:"master_public_key"` // zum Prüfen von Lizenzen/Leases
}
func (s *Server) agentRoutes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/enroll", s.handleEnroll)
mux.HandleFunc("GET /api/v1/stream", s.handleStream)
mux.HandleFunc("POST /api/v1/state", s.handleState)
return mux
}
func (s *Server) providerRoutes() http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /v1/traefik-config", s.registry.Handler())
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("ok\n"))
})
return mux
}
func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
pub, err := s.verifier.VerifyUnregistered(r)
if err != nil {
authError(w, err)
return
}
var req EnrollRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger Body", http.StatusBadRequest)
return
}
if !policy.ValidID(req.NodeID) || strings.TrimSpace(req.Hostname) == "" {
http.Error(w, "node_id ungültig oder hostname fehlt", http.StatusBadRequest)
return
}
node, err := s.store.Enroll(r.Context(), req.Token, store.Node{
ID: req.NodeID,
Hostname: req.Hostname,
PublicKey: auth.EncodePublicKey(pub),
IPAddress: remoteIP(r),
Status: store.NodeOffline, // online erst mit offenem Stream
})
switch {
case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrTokenUsed), errors.Is(err, store.ErrTokenExpired):
// Keine Details nach außen, ob Token unbekannt, verbraucht oder abgelaufen.
s.log.Warn("Enrollment abgelehnt", "node", req.NodeID, "ip", remoteIP(r), "err", err)
http.Error(w, "Einladung ungültig", http.StatusForbidden)
return
case errors.Is(err, store.ErrExists):
http.Error(w, "Knoten-ID oder Schlüssel bereits registriert", http.StatusConflict)
return
case errors.Is(err, store.ErrInvalid):
http.Error(w, err.Error(), http.StatusBadRequest)
return
case err != nil:
s.log.Error("Enrollment", "err", err)
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
s.log.Info("Knoten registriert", "node", node.ID, "tenant", node.TenantID, "ip", node.IPAddress)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(EnrollResponse{
NodeID: node.ID,
TenantID: node.TenantID,
MasterPublicKey: auth.EncodePublicKey(s.MasterPublicKey()),
})
}
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
id, err := s.verifier.Verify(r)
if err != nil {
authError(w, err)
return
}
if err := s.store.TouchNode(r.Context(), id.Node.ID, remoteIP(r)); err != nil {
s.log.Error("Lebenszeichen speichern", "node", id.Node.ID, "err", err)
}
err = s.hub.Serve(w, r, id.Node.ID)
s.log.Debug("Stream beendet", "node", id.Node.ID, "reason", err)
}
func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
id, err := s.verifier.Verify(r)
if err != nil {
authError(w, err)
return
}
// Lebendigkeit hängt am Stream: Ohne offenen Stream würden die Routen
// beim nächsten Trennen nie wieder entfernt.
if !s.hub.Connected(id.Node.ID) {
http.Error(w, "Stream nicht verbunden", http.StatusConflict)
return
}
var state registry.NodeState
if err := json.NewDecoder(r.Body).Decode(&state); err != nil {
http.Error(w, "ungültiger Body", http.StatusBadRequest)
return
}
err = s.registry.Update(r.Context(), id.Node.ID, state)
switch {
case errors.Is(err, registry.ErrUnknownNode):
http.Error(w, "Knoten unbekannt", http.StatusUnauthorized)
return
case errors.Is(err, policy.ErrInvalidInput):
s.log.Error("Zustand abgelehnt", "node", id.Node.ID, "err", err)
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
case err != nil:
s.log.Error("Zustand übernehmen", "node", id.Node.ID, "err", err)
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
if err := s.store.TouchNode(r.Context(), id.Node.ID, remoteIP(r)); err != nil {
s.log.Error("Lebenszeichen speichern", "node", id.Node.ID, "err", err)
}
w.WriteHeader(http.StatusNoContent)
}
func authError(w http.ResponseWriter, err error) {
code := auth.StatusCode(err)
msg := http.StatusText(code)
if code == http.StatusUpgradeRequired {
msg = "nur über TLS"
}
http.Error(w, msg, code)
}
func remoteIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
+287
View File
@@ -0,0 +1,287 @@
// Package server verdrahtet alle Bausteine zum Sultan-Server (`sultan
// server`):
//
// - Agent-Port (Default :8443, nur TLS 1.3): Enrollment, SSE-Stream und
// Zustandsmeldungen der Agents, jeder Request Ed25519-signiert.
// - Provider-Port (Default 127.0.0.1:8080, Klartext, nur lokal): die
// Traefik-Konfiguration für Traefiks HTTP-Provider und /healthz.
//
// Persistenter Zustand liegt im Datenverzeichnis (Default ./suldata):
// sultan.db, server.key/server.crt (TLS) und master.key (Lizenzen).
package server
import (
"context"
"crypto/ed25519"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/policy"
"sultan/pkg/registry"
"sultan/pkg/store"
"sultan/pkg/stream"
)
// Config ist die Konfiguration des Servers; Nullwerte bekommen Defaults.
type Config struct {
DataDir string // Default ./suldata
AgentAddr string // Default :8443
ProviderAddr string // Default 127.0.0.1:8080 – Klartext, nicht öffentlich binden
InterceptorURL string // Default http://127.0.0.1:8081
RefreshInterval time.Duration // Default 1m: Neubewertung aller Knoten
Logger *slog.Logger // Default: Text auf stderr
}
func (c Config) withDefaults() Config {
if c.DataDir == "" {
c.DataDir = "./suldata"
}
if c.AgentAddr == "" {
c.AgentAddr = ":8443"
}
if c.ProviderAddr == "" {
c.ProviderAddr = "127.0.0.1:8080"
}
if c.InterceptorURL == "" {
c.InterceptorURL = "http://127.0.0.1:8081"
}
if c.RefreshInterval <= 0 {
c.RefreshInterval = time.Minute
}
if c.Logger == nil {
c.Logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
}
return c
}
// Server ist ein laufender bzw. startbereiter Sultan-Server.
type Server struct {
cfg Config
log *slog.Logger
store *store.Store
cert crypto.ServerCert
master ed25519.PrivateKey
hub *stream.Hub
verifier *auth.Verifier
registry *registry.Registry
mu sync.Mutex
agentAddr string
providerAddr string
agentSrv *http.Server
providerSrv *http.Server
serveErr chan error
stopRefresh chan struct{}
shutdownOnce sync.Once
shutdownErr error
}
// New legt das Datenverzeichnis an (0700) und lädt bzw. erzeugt Datenbank,
// TLS-Zertifikat und Master-Key. Lauschen beginnt erst mit Start.
func New(ctx context.Context, cfg Config) (*Server, error) {
cfg = cfg.withDefaults()
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
return nil, fmt.Errorf("server: Datenverzeichnis: %w", err)
}
st, err := store.Open(ctx, filepath.Join(cfg.DataDir, "sultan.db"))
if err != nil {
return nil, err
}
cert, err := crypto.LoadOrCreateServerCert(cfg.DataDir)
if err != nil {
st.Close()
return nil, err
}
master, err := loadOrCreateMasterKey(filepath.Join(cfg.DataDir, "master.key"))
if err != nil {
st.Close()
return nil, err
}
s := &Server{
cfg: cfg,
log: cfg.Logger,
store: st,
cert: cert,
master: master,
verifier: &auth.Verifier{Directory: st},
serveErr: make(chan error, 2),
stopRefresh: make(chan struct{}),
}
s.hub = &stream.Hub{OnConnect: s.nodeOnline, OnDisconnect: s.nodeOffline}
s.registry = &registry.Registry{
Directory: &directory{store: st},
Policy: &policy.Engine{InterceptorURL: cfg.InterceptorURL},
OnViolation: s.logViolation,
}
return s, nil
}
func loadOrCreateMasterKey(path string) (ed25519.PrivateKey, error) {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
key, err := auth.GenerateKey()
if err != nil {
return nil, err
}
if err := auth.WritePrivateKey(path, key); err != nil {
return nil, fmt.Errorf("server: master.key anlegen: %w", err)
}
}
key, err := auth.ReadPrivateKey(path)
if err != nil {
return nil, fmt.Errorf("server: master.key laden: %w", err)
}
return key, nil
}
// Fingerprint ist der Fingerprint des TLS-Zertifikats (für Enrollment-Tokens).
func (s *Server) Fingerprint() crypto.Fingerprint { return s.cert.Fingerprint }
// MasterPublicKey ist der Schlüssel, mit dem Lizenzen geprüft werden.
func (s *Server) MasterPublicKey() ed25519.PublicKey {
return s.master.Public().(ed25519.PublicKey)
}
// AgentAddr ist die tatsächlich gebundene Adresse des Agent-Ports ("" vor Start).
func (s *Server) AgentAddr() string { s.mu.Lock(); defer s.mu.Unlock(); return s.agentAddr }
// ProviderAddr ist die tatsächlich gebundene Adresse des Provider-Ports.
func (s *Server) ProviderAddr() string { s.mu.Lock(); defer s.mu.Unlock(); return s.providerAddr }
// Start bindet beide Ports und beginnt zu bedienen; er kehrt sofort zurück.
func (s *Server) Start() error {
agentLn, err := net.Listen("tcp", s.cfg.AgentAddr)
if err != nil {
return fmt.Errorf("server: Agent-Port %s: %w", s.cfg.AgentAddr, err)
}
providerLn, err := net.Listen("tcp", s.cfg.ProviderAddr)
if err != nil {
agentLn.Close()
return fmt.Errorf("server: Provider-Port %s: %w", s.cfg.ProviderAddr, err)
}
agentSrv := &http.Server{
Handler: s.agentRoutes(),
TLSConfig: crypto.ServerTLSConfig(s.cert),
ReadHeaderTimeout: 10 * time.Second,
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelDebug),
}
providerSrv := &http.Server{
Handler: s.providerRoutes(),
ReadHeaderTimeout: 10 * time.Second,
}
s.mu.Lock()
s.agentAddr, s.providerAddr = agentLn.Addr().String(), providerLn.Addr().String()
s.agentSrv, s.providerSrv = agentSrv, providerSrv
s.mu.Unlock()
go s.serve(func() error { return agentSrv.ServeTLS(agentLn, "", "") })
go s.serve(func() error { return providerSrv.Serve(providerLn) })
go s.refreshLoop()
s.log.Info("Sultan-Server gestartet",
"agent", s.AgentAddr(), "provider", s.ProviderAddr(),
"Fingerprint", string(s.cert.Fingerprint), "data_dir", s.cfg.DataDir)
return nil
}
func (s *Server) serve(fn func() error) {
if err := fn(); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.serveErr <- err
}
}
func (s *Server) refreshLoop() {
t := time.NewTicker(s.cfg.RefreshInterval)
defer t.Stop()
for {
select {
case <-s.stopRefresh:
return
case <-t.C:
if err := s.registry.Refresh(context.Background()); err != nil {
s.log.Error("Neubewertung fehlgeschlagen", "err", err)
}
}
}
}
// Run startet den Server und fährt ihn herunter, sobald ctx endet oder ein
// Port ausfällt.
func (s *Server) Run(ctx context.Context) error {
if err := s.Start(); err != nil {
s.Shutdown(context.Background())
return err
}
var runErr error
select {
case <-ctx.Done():
case runErr = <-s.serveErr:
}
sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return errors.Join(runErr, s.Shutdown(sctx))
}
// Shutdown beendet zuerst alle Streams (sonst warten offene SSE-Requests
// ewig), dann beide HTTP-Server und schließt die Datenbank. Mehrfacher
// Aufruf ist erlaubt.
func (s *Server) Shutdown(ctx context.Context) error {
s.shutdownOnce.Do(func() {
close(s.stopRefresh)
s.hub.Close()
s.mu.Lock()
agentSrv, providerSrv := s.agentSrv, s.providerSrv
s.mu.Unlock()
var errs []error
for _, srv := range []*http.Server{agentSrv, providerSrv} {
if srv != nil {
errs = append(errs, srv.Shutdown(ctx))
}
}
errs = append(errs, s.store.Close())
s.shutdownErr = errors.Join(errs...)
s.log.Info("Sultan-Server beendet")
})
return s.shutdownErr
}
// nodeOnline/nodeOffline halten nodes.status aktuell; ein gedrainter
// Knoten bleibt gedraint. Beim Trennen verschwinden seine Routen.
func (s *Server) nodeOnline(nodeID string) {
s.setNodeStatus(nodeID, store.NodeOnline)
s.log.Info("Knoten verbunden", "node", nodeID)
}
func (s *Server) nodeOffline(nodeID string) {
s.registry.Remove(nodeID)
s.setNodeStatus(nodeID, store.NodeOffline)
s.log.Info("Knoten getrennt", "node", nodeID)
}
func (s *Server) setNodeStatus(nodeID string, status store.NodeStatus) {
ctx := context.Background()
n, err := s.store.GetNode(ctx, nodeID)
if err != nil || n.Status == store.NodeDrained {
return
}
if err := s.store.SetNodeStatus(ctx, nodeID, status); err != nil {
s.log.Error("Knotenstatus setzen", "node", nodeID, "err", err)
}
}
func (s *Server) logViolation(v registry.Violation) {
s.log.Warn("Policy-Verstoß",
"node", v.NodeID, "kind", string(v.Kind), "router", v.Router,
"workload", v.Workload, "detail", v.Detail, "observed", v.Observed)
}
+486
View File
@@ -0,0 +1,486 @@
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")
}
}