software steht so weit.

jetzt build + docker build und testen
This commit is contained in:
br committed 2026-10-02 13:25:34 +02:00
1 parent 42f0c0accb
commit ca6cdcca63
90 files changed
+10298 -480

No files matched your search

+227 -13
View File
@@ -5,6 +5,8 @@
// 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).
@@ -13,6 +15,7 @@ package server
import (
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -25,6 +28,8 @@ import (
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/lander"
"sultan/pkg/notify"
"sultan/pkg/policy"
"sultan/pkg/registry"
"sultan/pkg/store"
@@ -33,12 +38,35 @@ import (
// 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
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
interceptorDerived bool // InterceptorURL folgt der gebundenen Lander-Adresse
}
func (c Config) withDefaults() Config {
@@ -51,18 +79,55 @@ func (c Config) withDefaults() Config {
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.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
@@ -73,12 +138,20 @@ type Server struct {
hub *stream.Hub
verifier *auth.Verifier
registry *registry.Registry
lander *lander.Lander
notifier notify.Target // nil = keine Benachrichtigungen
enrollRL *rateLimiter
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
providerSrv *http.Server
landerSrv *http.Server
serveErr chan error
stopRefresh chan struct{}
shutdownOnce sync.Once
@@ -89,6 +162,13 @@ type Server struct {
// TLS-Zertifikat und Master-Key. Lauschen beginnt erst mit Start.
func New(ctx context.Context, cfg Config) (*Server, error) {
cfg = cfg.withDefaults()
for i, raw := range cfg.PublicURLs {
u, err := crypto.ValidateServerURL(raw)
if err != nil {
return nil, fmt.Errorf("server: --public-url: %w", err)
}
cfg.PublicURLs[i] = u
}
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
return nil, fmt.Errorf("server: Datenverzeichnis: %w", err)
}
@@ -96,6 +176,13 @@ func New(ctx context.Context, cfg Config) (*Server, error) {
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()
@@ -114,10 +201,31 @@ func New(ctx context.Context, cfg Config) (*Server, error) {
cert: cert,
master: master,
verifier: &auth.Verifier{Directory: st},
serveErr: make(chan error, 2),
serveErr: make(chan error, 3),
stopRefresh: make(chan struct{}),
}
s.hub = &stream.Hub{OnConnect: s.nodeOnline, OnDisconnect: s.nodeOffline}
s.hub = &stream.Hub{OnConnect: s.nodeOnline, OnDisconnect: s.nodeOffline, SigningKey: master}
s.enrollRL = &rateLimiter{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},
@@ -157,6 +265,22 @@ func (s *Server) AgentAddr() string { s.mu.Lock(); defer s.mu.Unlock(); return s
// 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)
@@ -168,6 +292,18 @@ func (s *Server) Start() error {
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(),
@@ -183,14 +319,22 @@ func (s *Server) Start() error {
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()
s.log.Info("Sultan-Server gestartet",
"agent", s.AgentAddr(), "provider", s.ProviderAddr(),
"agent", s.AgentAddr(), "provider", s.ProviderAddr(), "lander", s.LanderAddr(),
"Fingerprint", string(s.cert.Fingerprint), "data_dir", s.cfg.DataDir)
return nil
}
@@ -209,9 +353,15 @@ func (s *Server) refreshLoop() {
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())
}
}
}
@@ -241,10 +391,10 @@ func (s *Server) Shutdown(ctx context.Context) error {
close(s.stopRefresh)
s.hub.Close()
s.mu.Lock()
agentSrv, providerSrv := s.agentSrv, s.providerSrv
agentSrv, providerSrv, landerSrv := s.agentSrv, s.providerSrv, s.landerSrv
s.mu.Unlock()
var errs []error
for _, srv := range []*http.Server{agentSrv, providerSrv} {
for _, srv := range []*http.Server{agentSrv, providerSrv, landerSrv} {
if srv != nil {
errs = append(errs, srv.Shutdown(ctx))
}
@@ -257,16 +407,62 @@ func (s *Server) Shutdown(ctx context.Context) error {
}
// nodeOnline/nodeOffline halten nodes.status aktuell; ein gedrainter
// Knoten bleibt gedraint. Beim Trennen verschwinden seine Routen.
// 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.registry.Remove(nodeID)
s.setNodeStatus(nodeID, store.NodeOffline)
s.log.Info("Knoten getrennt", "node", nodeID)
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) {
@@ -284,4 +480,22 @@ 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)
}
}()
}