Files
sultan/pkg/server/server.go
T

554 lines
17 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.
// - Lander-Port (Default 127.0.0.1:8081, nur lokal): Hinweis-,
// Wartungs- und Einladungsseiten, zu denen die Policy umleitet.
//
// Persistenter Zustand liegt im Datenverzeichnis (Default ./suldata):
// sultan.db, server.key/server.crt (TLS) und master.key (Lizenzen).
package server
import (
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/lander"
"sultan/pkg/notify"
"sultan/pkg/policy"
"sultan/pkg/ratelimit"
"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
// PublicURLs: Adressen, unter denen Agents den Server erreichen (z. B.
// öffentlich und NetBird). Werden gemerkt; die erste steckt in
// Einladungen, die ganze Liste bekommen Agents per config-Befehl.
PublicURLs []string
LanderAddr string // Default 127.0.0.1:8081; "none" = kein eingebauter Lander
InterceptorURL string // Default: Adresse des eingebauten Landers
HosterName string // Lander: Name des Hosting-Anbieters
HosterURL string // Lander: Angebotsseite
ProURL string // Lander: Pro-Hinweis auf Community-Wartungsseiten
RefreshInterval time.Duration // Default 1m: Neubewertung aller Knoten, Lease-Erneuerung, Befehle
LeaseDuration time.Duration // 0 = Laufzeiten je Tier (Spec 4.1a); > 0 feste Laufzeit (Tests, Betrieb)
Logger *slog.Logger // Default: Text auf stderr
// Benachrichtigungen: Slack-kompatible Webhooks und ntfy-Topics (je
// mehrfach); Notify ersetzt beides (Tests, eigene Ziele).
SlackWebhooks []string
NtfyURLs []string
NtfyToken string
Notify notify.Target
OfflineAlertAfter time.Duration // Default 15m: Alarm für getrennte Knoten
// EnrollLimit: Enrollment-Versuche pro Minute und Client-IP (Default 10,
// -1 = unbegrenzt) – gegen das Durchprobieren von Einladungen.
EnrollLimit int
// Accountant (Spec 5.3): Einladung (nur beim ersten Start) bzw.
// Adress-Override; ohne beides und ohne accountant.json meldet der
// Server nichts. Interval: Prüfen auf Änderungen (Default 10s),
// Heartbeat: Lebenszeichen ohne Änderung (Default 1h).
AccountantToken string
AccountantURL string
AccountantInterval time.Duration
AccountantHeartbeat time.Duration
Version string // für den Bericht (sultan version)
interceptorDerived bool // InterceptorURL folgt der gebundenen Lander-Adresse
}
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.LanderAddr == "" {
c.LanderAddr = "127.0.0.1:8081"
}
if c.InterceptorURL == "" {
c.InterceptorURL = "http://127.0.0.1:8081"
if c.LanderAddr != "none" {
c.InterceptorURL = "http://" + loopbackAddr(c.LanderAddr)
c.interceptorDerived = true
}
}
if c.RefreshInterval <= 0 {
c.RefreshInterval = time.Minute
}
if c.OfflineAlertAfter <= 0 {
c.OfflineAlertAfter = 15 * time.Minute
}
if c.EnrollLimit == 0 {
c.EnrollLimit = 10
}
if c.AccountantInterval <= 0 {
c.AccountantInterval = 10 * time.Second
}
if c.AccountantHeartbeat <= 0 {
c.AccountantHeartbeat = time.Hour
}
if c.Logger == nil {
c.Logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
}
return c
}
// SettingPublicURLs ist der Store-Schlüssel der gemerkten Adressen (JSON-Liste).
const SettingPublicURLs = "public_urls"
// LoadPublicURLs liest die gemerkten Adressen (leer, wenn keine gesetzt sind).
func LoadPublicURLs(ctx context.Context, st *store.Store) ([]string, error) {
raw, err := st.Setting(ctx, SettingPublicURLs)
if errors.Is(err, store.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
var urls []string
if err := json.Unmarshal([]byte(raw), &urls); err != nil {
return nil, fmt.Errorf("server: %s beschädigt: %w", SettingPublicURLs, err)
}
return urls, nil
}
// PublicURLs liefert die gemerkten Adressen.
func (s *Server) PublicURLs(ctx context.Context) ([]string, error) {
return LoadPublicURLs(ctx, s.store)
}
// 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
lander *lander.Lander
notifier notify.Target // nil = keine Benachrichtigungen
enrollRL *ratelimit.Limiter
payee *payee // nil = meldet nicht an einen Accountant
vmu sync.Mutex
violations []time.Time // Zeitpunkte der Policy-Verstöße (für Checkmk)
mu sync.Mutex
agentAddr string
providerAddr string
landerAddr string
agentSrv *http.Server
payeeRunning bool
providerSrv *http.Server
landerSrv *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()
var urls []string
for _, raw := range cfg.PublicURLs {
if strings.TrimSpace(raw) == "" {
continue // leere Einträge (z. B. leere Variable in Compose) ignorieren
}
u, err := crypto.ValidateServerURL(raw)
if err != nil {
return nil, fmt.Errorf("server: --public-url: %w", err)
}
urls = append(urls, u)
}
cfg.PublicURLs = urls
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
}
if len(cfg.PublicURLs) > 0 {
raw, _ := json.Marshal(cfg.PublicURLs)
if err := st.SetSetting(ctx, SettingPublicURLs, string(raw)); err != nil {
st.Close()
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
}
pay, err := newPayee(cfg, cfg.Logger)
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, 3),
stopRefresh: make(chan struct{}),
payee: pay,
}
if pay != nil {
pay.s = s
}
s.hub = &stream.Hub{OnConnect: s.nodeOnline, OnDisconnect: s.nodeOffline, SigningKey: master}
s.enrollRL = &ratelimit.Limiter{Limit: cfg.EnrollLimit, Window: time.Minute}
s.notifier = cfg.Notify
if s.notifier == nil {
var targets notify.Multi
for _, u := range cfg.SlackWebhooks {
targets = append(targets, notify.Slack{URL: u})
}
for _, u := range cfg.NtfyURLs {
targets = append(targets, notify.Ntfy{URL: u, Token: cfg.NtfyToken})
}
if len(targets) > 0 {
s.notifier = targets
}
}
s.lander = &lander.Lander{
Dir: filepath.Join(cfg.DataDir, "landing"),
HosterName: cfg.HosterName,
HosterURL: cfg.HosterURL,
ProURL: cfg.ProURL,
Logger: cfg.Logger,
}
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 }
// NodeState liefert den zuletzt gemeldeten Zustand eines verbundenen
// Knotens (Kopie, inkl. Zusatzdaten für das Inventar).
func (s *Server) NodeState(nodeID string) (registry.NodeState, bool) {
return s.registry.State(nodeID)
}
// 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 }
// LanderAddr ist die gebundene Adresse des Landers ("" wenn abgeschaltet).
func (s *Server) LanderAddr() string { s.mu.Lock(); defer s.mu.Unlock(); return s.landerAddr }
// loopbackAddr macht aus ":8081" bzw. "0.0.0.0:8081" eine lokal erreichbare
// Adresse (127.0.0.1:8081) für die Interceptor-URL.
func loopbackAddr(addr string) string {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) {
host = "127.0.0.1"
}
return net.JoinHostPort(host, port)
}
// 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)
}
var landerLn net.Listener
if s.cfg.LanderAddr != "none" {
if landerLn, err = net.Listen("tcp", s.cfg.LanderAddr); err != nil {
agentLn.Close()
providerLn.Close()
return fmt.Errorf("server: Lander-Port %s: %w", s.cfg.LanderAddr, err)
}
if s.cfg.interceptorDerived {
// Noch läuft nichts nebenläufig: die Policy darf angepasst werden.
s.registry.Policy.InterceptorURL = "http://" + loopbackAddr(landerLn.Addr().String())
}
}
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
if landerLn != nil {
s.landerAddr = landerLn.Addr().String()
s.landerSrv = &http.Server{Handler: s.lander, ReadHeaderTimeout: 10 * time.Second}
}
landerSrv := s.landerSrv
s.mu.Unlock()
go s.serve(func() error { return agentSrv.ServeTLS(agentLn, "", "") })
go s.serve(func() error { return providerSrv.Serve(providerLn) })
if landerSrv != nil {
go s.serve(func() error { return landerSrv.Serve(landerLn) })
}
go s.refreshLoop()
if s.payee != nil {
s.mu.Lock()
s.payeeRunning = true
s.mu.Unlock()
go s.payee.run()
}
s.log.Info("Sultan-Server gestartet",
"agent", s.AgentAddr(), "provider", s.ProviderAddr(), "lander", s.LanderAddr(),
"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:
s.pruneDisconnected()
for _, id := range s.hub.Nodes() {
s.ensureLease(id, false)
s.dispatchCommands(id)
}
if err := s.registry.Refresh(context.Background()); err != nil {
s.log.Error("Neubewertung fehlgeschlagen", "err", err)
}
s.runJobs(context.Background())
}
}
}
// 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, landerSrv := s.agentSrv, s.providerSrv, s.landerSrv
s.mu.Unlock()
var errs []error
for _, srv := range []*http.Server{agentSrv, providerSrv, landerSrv} {
if srv != nil {
errs = append(errs, srv.Shutdown(ctx))
}
}
s.mu.Lock()
payeeRunning := s.payeeRunning
s.mu.Unlock()
if payeeRunning {
s.payee.shutdown() // vor dem Schließen der Datenbank
}
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 bleiben die Routen als Wartungsseite
// stehen, solange eine Wartung angemeldet ist oder die Lease gilt; erst
// danach (pruneDisconnected) verschwinden sie. drain entfernt sie sofort.
func (s *Server) nodeOnline(nodeID string) {
s.setNodeStatus(nodeID, store.NodeOnline)
s.log.Info("Knoten verbunden", "node", nodeID)
s.ensureLease(nodeID, true)
s.dispatchCommands(nodeID)
s.sendServerURLs(nodeID)
if s.notifier != nil {
go s.notifyBackOnline(nodeID) // nicht den Stream-Aufbau aufhalten
}
}
// sendServerURLs schickt dem Agenten die Adressliste (Ausweichweg).
func (s *Server) sendServerURLs(nodeID string) {
urls, err := s.PublicURLs(context.Background())
if err != nil || len(urls) == 0 {
return
}
payload, _ := json.Marshal(stream.NodeCommand{Command: stream.CmdConfig, ServerURLs: urls})
if err := s.hub.Send(nodeID, stream.Event{Type: stream.EventNodeCommand, Payload: payload}); err != nil {
s.log.Debug("Adressliste nicht zugestellt", "node", nodeID, "err", err)
}
}
func (s *Server) nodeOffline(nodeID string) {
s.setNodeStatus(nodeID, store.NodeOffline)
if err := s.store.MarkNodeSeen(context.Background(), nodeID); err != nil {
s.log.Debug("offline-seit setzen", "node", nodeID, "err", err)
}
if s.inMaintenance(nodeID) || s.leaseValid(nodeID) {
s.log.Info("Knoten getrennt – Routen bleiben als Wartungsseite (Wartung bzw. Lease)", "node", nodeID)
if err := s.registry.Refresh(context.Background()); err != nil {
s.log.Error("Neubewertung nach Trennung", "err", err)
}
return
}
s.registry.Remove(nodeID)
s.log.Info("Knoten getrennt – Routen entfernt", "node", nodeID)
}
func (s *Server) inMaintenance(nodeID string) bool {
n, err := s.store.GetNode(context.Background(), nodeID)
return err == nil && n.Maintenance.Active(time.Now())
}
// pruneDisconnected entfernt Routen getrennter Knoten, deren Wartung und
// Lease inzwischen abgelaufen sind.
func (s *Server) pruneDisconnected() {
for _, id := range s.registry.Nodes() {
if !s.hub.Connected(id) && !s.inMaintenance(id) && !s.leaseValid(id) {
s.registry.Remove(id)
s.log.Info("Getrennter Knoten: Wartung und Lease abgelaufen – Routen entfernt", "node", id)
}
}
}
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)
s.recordViolation(time.Now())
if s.notifier == nil {
return
}
// Asynchron: OnViolation läuft unter der Sperre der Registry. Die
// Registry meldet jeden Verstoß nur beim ersten Auftreten.
msg := notify.Message{
Severity: notify.Warning,
Title: "Policy-Verstoß auf Knoten " + v.NodeID,
Body: fmt.Sprintf("Knoten *%s*, Router `%s`, Art `%s`: %s", v.NodeID, v.Router, v.Kind, v.Detail),
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := s.notifier.Send(ctx, msg); err != nil {
s.log.Warn("Benachrichtigung über Policy-Verstoß nicht zugestellt", "err", err)
}
}()
}