// 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 = ®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 } // 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) } }() }