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,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)
|
||||
}
|
||||
Reference in new issue
Block a user