server läuft
This commit is contained in:
1 parent
c8df9d7651
commit
42f0c0accb
21 files changed
+4185
-10
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = ®istry.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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user