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

288 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}