software steht so weit.
jetzt build + docker build und testen
This commit is contained in:
1 parent
42f0c0accb
commit
ca6cdcca63
90 files changed
+10298
-480
No files matched your search
+227
-13
@@ -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 = ®istry.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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in new issue
Block a user