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

+411
View File
@@ -0,0 +1,411 @@
// Package agent ist `sultan agent`: Er registriert den Host beim Server
// (Enrollment mit Einladung und Pinning), hält den SSE-Stream offen,
// beobachtet Docker und meldet seinen vollständigen Zustand
// (service_state) – nach jedem (Wieder-)Verbinden und bei jeder Änderung.
//
// Im Datenverzeichnis (Default ./suldata) liegen node.key (0600) und
// node.json (inkl. server_fingerprint, dem Pin des Servers).
package agent
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/docker"
"sultan/pkg/policy"
"sultan/pkg/registry"
"sultan/pkg/stream"
)
// NoDocker als DockerSocket bzw. ConfigDir schaltet die jeweilige Quelle ab.
const NoDocker = "none"
// Config ist die Konfiguration des Agenten; Nullwerte bekommen Defaults.
type Config struct {
DataDir string // Default ./suldata
ServerURL string // https://ingress:8443 – Override; sonst aus der Einladung bzw. node.json
Token string // Einladung, nur beim ersten Start
NodeID string // Default aus dem Hostnamen
Hostname string // Default os.Hostname()
DockerSocket string // Default /var/run/docker.sock; "none" schaltet Docker ab
BackendHost string // Adresse, unter der der Ingress die Dienste erreicht
BackendInterface string // alternativ: IP dieses Interfaces (z. B. wt0)
ConfigDir string // Default /etc/sultan.d (*.yaml, Spec 4.1b); "none" schaltet ab
ConfigPoll time.Duration // Default 5s: Prüfintervall für Änderungen in ConfigDir
Logger *slog.Logger
Debounce time.Duration // Default 500ms: Änderungen sammeln, bevor gesendet wird
RetryInterval time.Duration // Default 5s: erneuter Sendeversuch nach Fehler
ReconnectMin time.Duration // Default 1s: kleinste Wartezeit vor Neuverbindung
}
// Agent ist ein startbereiter Agent.
type Agent struct {
cfg Config
log *slog.Logger
nodeIDErr error
signer *auth.Signer
id Identity
serverURL string // tatsächlich genutzte Adresse (Override oder node.json)
mu sync.Mutex
fileSpecs []ServiceSpec
health healthTracker
keyMu sync.RWMutex // schützt signer (rotate_key tauscht ihn im Betrieb)
kick func() // Zustand neu senden (in Run gesetzt)
runCtx context.Context
hc *http.Client
}
// httpClient liefert den gepinnten Client für den Server.
func (a *Agent) httpClient() *http.Client {
if a.hc == nil {
a.hc = &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(a.id.ServerFingerprint)}}
}
return a.hc
}
// New prüft die Konfiguration und setzt Defaults; Dateien werden erst von
// Enroll/Run angefasst.
func New(cfg Config) (*Agent, error) {
if cfg.DataDir == "" {
cfg.DataDir = "./suldata"
}
if cfg.DockerSocket == "" {
cfg.DockerSocket = "/var/run/docker.sock"
}
if cfg.ConfigDir == "" {
cfg.ConfigDir = "/etc/sultan.d"
}
if cfg.ConfigPoll <= 0 {
cfg.ConfigPoll = 5 * time.Second
}
if cfg.Logger == nil {
cfg.Logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
}
if cfg.Debounce <= 0 {
cfg.Debounce = 500 * time.Millisecond
}
if cfg.RetryInterval <= 0 {
cfg.RetryInterval = 5 * time.Second
}
if cfg.ReconnectMin <= 0 {
cfg.ReconnectMin = time.Second
}
if cfg.ServerURL != "" {
u, err := crypto.ValidateServerURL(cfg.ServerURL)
if err != nil {
return nil, fmt.Errorf("agent: --server: %w", err)
}
cfg.ServerURL = u
}
if cfg.Hostname == "" {
cfg.Hostname, _ = os.Hostname()
}
a := &Agent{cfg: cfg, log: cfg.Logger}
if cfg.NodeID != "" {
if !policy.ValidID(cfg.NodeID) {
return nil, fmt.Errorf("agent: Knoten-ID %q ungültig (a–z, 0–9, - und _, kein \"__\")", cfg.NodeID)
}
} else {
a.cfg.NodeID, a.nodeIDErr = DefaultNodeID(cfg.Hostname)
}
return a, nil
}
// errNotConnected: Der Server nimmt den Zustand erst bei offenem Stream an.
var errNotConnected = errors.New("Stream noch nicht verbunden")
// Run registriert den Agenten (falls nötig) und arbeitet, bis ctx endet.
func (a *Agent) Run(ctx context.Context) error {
if err := a.Enroll(ctx); err != nil {
return err
}
host, err := backendHost(a.cfg.BackendHost, a.cfg.BackendInterface, a.serverURL)
if err != nil {
return err
}
hc := a.httpClient()
a.runCtx = ctx
// Abgebrochener Schlüsselwechsel? Vor dem Verbinden abschließen.
if _, err := os.Stat(a.path("node.key.new")); err == nil {
if err := a.completeRotation(ctx); err != nil {
a.log.Error("Begonnenen Schlüsselwechsel abschließen", "err", err)
}
}
a.log.Info("Agent gestartet", "node", a.id.NodeID, "server", a.serverURL, "backend", host,
"docker", a.cfg.DockerSocket, "config_dir", a.cfg.ConfigDir)
trigger := make(chan struct{}, 1)
kick := func() {
select {
case trigger <- struct{}{}:
default:
}
}
a.kick = kick
var sc *stream.Client
sc = &stream.Client{
URL: a.currentServerURL() + "/api/v1/stream",
HTTPClient: hc,
Prepare: func(r *http.Request) error { return a.currentSigner().Sign(r) },
MinBackoff: a.cfg.ReconnectMin,
OnConnect: func() {
a.log.Info("Mit Server verbunden")
a.rememberServerURL()
kick() // Server hat die Routen beim Trennen entfernt: Zustand neu senden
},
OnError: func(err error, retryIn time.Duration) {
// Ausweichweg: nächster Versuch über die nächste bekannte Adresse.
next := a.nextServerURL()
sc.URL = next + "/api/v1/stream"
a.log.Warn("Verbindung zum Server unterbrochen", "err", err, "retry_in", retryIn, "next", next)
},
}
var wg sync.WaitGroup
wg.Go(func() { sc.Run(ctx, a.handleEvent) })
if a.cfg.DockerSocket != NoDocker {
wg.Go(func() { a.watchDocker(ctx, kick) })
}
if a.cfg.ConfigDir != NoDocker {
wg.Go(func() { a.watchServiceDir(ctx, kick) })
}
wg.Go(func() { a.sendLoop(ctx, hc, host, trigger, kick) })
<-ctx.Done()
wg.Wait()
a.log.Info("Agent beendet")
return nil
}
// sendLoop sendet den Zustand, sobald ausgelöst – gesammelt über Debounce.
func (a *Agent) sendLoop(ctx context.Context, hc *http.Client, host string, trigger <-chan struct{}, kick func()) {
for {
select {
case <-ctx.Done():
return
case <-trigger:
}
select {
case <-ctx.Done():
return
case <-time.After(a.cfg.Debounce):
}
state, err := a.collect(ctx, host)
if err == nil {
err = a.postState(ctx, hc, state)
}
switch {
case err == nil:
a.log.Debug("Zustand gesendet", "workloads", len(state.Workloads))
case ctx.Err() != nil:
return
case errors.Is(err, errNotConnected):
// Kein erneuter Versuch nötig: OnConnect löst ohnehin aus.
default:
a.log.Warn("Zustand senden fehlgeschlagen", "err", err, "retry_in", a.cfg.RetryInterval)
time.AfterFunc(a.cfg.RetryInterval, kick)
}
}
}
// collect ermittelt den aktuellen Zustand: Docker-Container und Dienste aus
// ConfigDir samt Healthcheck-Ausfällen. Ohne Docker-Socket (Bare-Metal)
// entfallen die Container; schlägt die Docker-API fehl, wird nichts
// gesendet (sonst würden alle Container-Routen entfernt).
func (a *Agent) collect(ctx context.Context, host string) (registry.NodeState, error) {
state := registry.NodeState{Workloads: []registry.Workload{}}
if a.dockerAvailable() {
containers, err := (&docker.Client{Socket: a.cfg.DockerSocket}).List(ctx)
if err != nil {
return state, err
}
ws, warnings := Workloads(containers, host)
for _, w := range warnings {
a.log.Warn("Container nicht übernommen", "grund", w)
}
state.Workloads = append(state.Workloads, ws...)
}
a.mu.Lock()
specs := a.fileSpecs
a.mu.Unlock()
state.Workloads = append(state.Workloads, FileWorkloads(specs, host)...)
state.DownServices = a.health.down()
return state, nil
}
func (a *Agent) dockerAvailable() bool {
if a.cfg.DockerSocket == NoDocker {
return false
}
_, err := os.Stat(a.cfg.DockerSocket)
return !errors.Is(err, os.ErrNotExist)
}
// watchServiceDir prüft ConfigDir regelmäßig auf Änderungen (Polling statt
// inotify: zuverlässig auch bei Bind-Mounts und atomar ersetzten Dateien).
// Bei einer Änderung werden die Dienste neu geladen, die Healthchecks neu
// gestartet und der Zustand gesendet.
func (a *Agent) watchServiceDir(ctx context.Context, kick func()) {
var last string
var stopHealth context.CancelFunc = func() {}
var healthWG sync.WaitGroup
defer func() { stopHealth(); healthWG.Wait() }()
ticker := time.NewTicker(a.cfg.ConfigPoll)
defer ticker.Stop()
for {
if fp := dirFingerprint(a.cfg.ConfigDir); fp != last {
last = fp
specs, warnings := LoadServiceDir(a.cfg.ConfigDir)
for _, w := range warnings {
a.log.Warn("Dienstdatei fehlerhaft", "grund", w)
}
a.log.Info("Dienste aus Konfigurationsverzeichnis geladen", "dir", a.cfg.ConfigDir, "anzahl", len(specs))
a.mu.Lock()
a.fileSpecs = specs
a.mu.Unlock()
stopHealth()
healthWG.Wait()
hctx, cancel := context.WithCancel(ctx)
stopHealth = cancel
keep := map[string]bool{}
for _, s := range specs {
if s.Healthcheck != nil {
keep[s.Name] = true
healthWG.Go(func() { a.healthLoop(hctx, s.Name, *s.Healthcheck, kick) })
}
}
a.health.retain(keep)
kick()
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (a *Agent) healthLoop(ctx context.Context, name string, spec HealthSpec, kick func()) {
client := &http.Client{}
ticker := time.NewTicker(spec.Interval.Duration)
defer ticker.Stop()
for {
err := checkHealth(ctx, client, spec)
if ctx.Err() != nil {
return
}
if a.health.report(name, spec.Failures, err == nil) {
if err != nil {
a.log.Warn("Dienst ausgefallen – Wartungsseite aktiv", "service", name, "grund", err)
} else {
a.log.Info("Dienst wieder erreichbar", "service", name)
}
kick()
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
// dirFingerprint fasst Namen und Inhalte aller YAML-Dateien zusammen.
func dirFingerprint(dir string) string {
entries, err := os.ReadDir(dir)
if err != nil {
return "fehlt"
}
h := sha256.New()
for _, e := range entries {
if ext := filepath.Ext(e.Name()); e.IsDir() || (ext != ".yaml" && ext != ".yml") {
continue
}
data, _ := os.ReadFile(filepath.Join(dir, e.Name()))
fmt.Fprintf(h, "%s\x00%d\x00", e.Name(), len(data))
h.Write(data)
}
return hex.EncodeToString(h.Sum(nil))
}
func (a *Agent) postState(ctx context.Context, hc *http.Client, state registry.NodeState) error {
body, err := json.Marshal(state)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.currentServerURL()+"/api/v1/state", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if err := a.currentSigner().Sign(req); err != nil {
return err
}
resp, err := hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusNoContent:
return nil
case http.StatusConflict:
return errNotConnected
default:
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("Server antwortet %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
}
}
// watchDocker löst bei jedem Container-Event und nach jedem (Wieder-)Aufbau
// des Event-Streams ein erneutes Senden aus.
func (a *Agent) watchDocker(ctx context.Context, kick func()) {
client := &docker.Client{Socket: a.cfg.DockerSocket}
backoff := a.cfg.ReconnectMin
for ctx.Err() == nil {
events, errc := client.Events(ctx)
kick() // verpasste Änderungen nachholen
received := false
for range events {
received = true
kick()
}
err := <-errc
if ctx.Err() != nil {
return
}
if received {
backoff = a.cfg.ReconnectMin
}
a.log.Debug("Docker-Events unterbrochen", "err", err, "retry_in", backoff)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
backoff = min(backoff*2, 30*time.Second)
}
}
+837
View File
@@ -0,0 +1,837 @@
package agent
import (
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/docker"
"sultan/pkg/license"
"sultan/pkg/server"
"sultan/pkg/store"
"sultan/pkg/stream"
)
var ctx = context.Background()
func eventually(t *testing.T, msg string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("Bedingung nicht erfüllt: %s", msg)
}
time.Sleep(10 * time.Millisecond)
}
}
// testServer ist ein echter sultan-Server auf freien Ports.
type testServer struct {
*server.Server
dataDir string
}
func startServer(t *testing.T, dataDir, agentAddr string) *testServer {
return startServerFull(t, dataDir, agentAddr, nil)
}
func startServerWith(t *testing.T, mutate func(*server.Config)) *testServer {
return startServerFull(t, "", "", mutate)
}
func startServerFull(t *testing.T, dataDir, agentAddr string, mutate func(*server.Config)) *testServer {
t.Helper()
if dataDir == "" {
dataDir = filepath.Join(t.TempDir(), "server")
}
if agentAddr == "" {
agentAddr = "127.0.0.1:0"
}
cfg := server.Config{
DataDir: dataDir, AgentAddr: agentAddr, ProviderAddr: "127.0.0.1:0", LanderAddr: "127.0.0.1:0",
RefreshInterval: 20 * time.Millisecond,
Logger: slog.New(slog.DiscardHandler),
}
if mutate != nil {
mutate(&cfg)
}
s, err := server.New(ctx, cfg)
if err != nil {
t.Fatal(err)
}
if err := s.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Shutdown(ctx) })
return &testServer{Server: s, dataDir: dataDir}
}
func (s *testServer) url() string { return "https://" + s.AgentAddr() }
func (s *testServer) db(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(ctx, filepath.Join(s.dataDir, "sultan.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
// invite legt (falls nötig) den Mandanten an und erzeugt eine Einladung.
func (s *testServer) invite(t *testing.T, tenant string) string {
t.Helper()
st := s.db(t)
if _, err := st.GetTenant(ctx, tenant); err != nil {
if _, err := st.CreateTenant(ctx, store.Tenant{
ID: tenant, Name: tenant, Email: tenant + "@example.com", Tier: store.TierPro, MaxRoutes: 10,
AllowedDomains: []string{"kunde.de", "*.kunde.de"}, ValidUntil: time.Now().AddDate(1, 0, 0),
}); err != nil {
t.Fatal(err)
}
}
tok, _ := crypto.NewEnrollmentToken(s.Fingerprint(), "")
if err := st.AddEnrollmentToken(ctx, tok.Secret, tenant, time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
return tok.String()
}
func (s *testServer) traefikConfig(t *testing.T) string {
t.Helper()
resp, err := http.Get("http://" + s.ProviderAddr() + "/v1/traefik-config")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
func (s *testServer) nodeStatus(t *testing.T, id string) store.NodeStatus {
n, err := s.db(t).GetNode(ctx, id)
if err != nil {
return ""
}
return n.Status
}
// fakeDocker bedient /containers/json und /events über einen Unix-Socket.
type fakeDocker struct {
socket string
mu sync.Mutex
containers []docker.Container
subs []chan docker.Event
}
func newFakeDocker(t *testing.T, containers ...docker.Container) *fakeDocker {
t.Helper()
dir, err := os.MkdirTemp("", "ag")
if err != nil {
t.Fatal(err)
}
f := &fakeDocker{socket: filepath.Join(dir, "d.sock"), containers: containers}
ln, err := net.Listen("unix", f.socket)
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
json.NewEncoder(w).Encode(f.containers)
})
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
ch := make(chan docker.Event, 8)
f.mu.Lock()
f.subs = append(f.subs, ch)
f.mu.Unlock()
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
for {
select {
case <-r.Context().Done():
return
case ev := <-ch:
json.NewEncoder(w).Encode(ev)
w.(http.Flusher).Flush()
}
}
})
srv := &http.Server{Handler: mux}
go srv.Serve(ln)
t.Cleanup(func() { srv.Close(); os.RemoveAll(dir) })
return f
}
// set ersetzt die Containerliste und meldet ein Event.
func (f *fakeDocker) set(action string, containers ...docker.Container) {
f.mu.Lock()
f.containers = containers
subs := append([]chan docker.Event(nil), f.subs...)
f.mu.Unlock()
for _, ch := range subs {
ch <- docker.Event{Type: "container", Action: action, Actor: docker.Actor{ID: "x"}}
}
}
func wordpress() docker.Container {
return ctr("wordpress", wpLabels(), pub(80, 8081))
}
func testConfig(t *testing.T, dataDir string, s *testServer, token, socket string) Config {
return Config{
DataDir: dataDir,
ServerURL: s.url(),
Token: token,
NodeID: "node-1",
Hostname: "pi",
DockerSocket: socket,
BackendHost: "100.64.0.42",
Logger: slog.New(slog.DiscardHandler),
Debounce: 20 * time.Millisecond,
RetryInterval: 50 * time.Millisecond,
ReconnectMin: 20 * time.Millisecond,
}
}
// runAgent startet den Agenten im Hintergrund; stop beendet ihn und
// liefert den Rückgabewert von Run.
func runAgent(t *testing.T, cfg Config) (stop func() error) {
t.Helper()
a, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
actx, cancel := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- a.Run(actx) }()
var once sync.Once
var result error
stop = func() error {
once.Do(func() {
cancel()
select {
case result = <-done:
case <-time.After(5 * time.Second):
result = errors.New("Agent endet nicht")
}
})
return result
}
t.Cleanup(func() { stop() })
return stop
}
// Der ganze Weg: Enrollment, Stream, Container → Route bei Traefik,
// Container weg → Route weg, Agent weg → offline, Neustart ohne Token.
func TestRun_EndToEnd(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t, wordpress())
dataDir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dataDir, s, s.invite(t, "kunde"), dock.socket)
stop := runAgent(t, cfg)
eventually(t, "Route bei Traefik", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__wp") && strings.Contains(c, "http://100.64.0.42:8081")
})
if st := s.nodeStatus(t, "node-1"); st != store.NodeOnline {
t.Errorf("Knotenstatus %q", st)
}
dock.set("die") // Container gestoppt
eventually(t, "Route nach Containerstopp entfernt", func() bool { return s.traefikConfig(t) == "{}" })
dock.set("start", wordpress())
eventually(t, "Route nach Containerstart zurück", func() bool {
return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp")
})
if err := stop(); err != nil {
t.Errorf("Run: %v", err)
}
eventually(t, "Knoten offline", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOffline })
// Ungeplant weg: Wartungsseite, solange die Lease gilt.
eventually(t, "Wartungsseite", func() bool { return strings.Contains(s.traefikConfig(t), "sultan__interceptor") })
// Neustart: schon enrollt, kein Token mehr nötig (Einladung ist verbraucht).
cfg.Token = ""
runAgent(t, cfg)
eventually(t, "Route nach Neustart des Agenten wieder normal", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__wp") && !strings.Contains(c, "sultan__interceptor")
})
}
// Nach einem Neustart des Servers verbindet sich der Agent neu und sendet
// seinen Zustand erneut (OnConnect).
func TestRun_ResendsStateAfterServerRestart(t *testing.T) {
serverDir := filepath.Join(t.TempDir(), "server")
s := startServer(t, serverDir, "")
addr := s.AgentAddr()
dock := newFakeDocker(t, wordpress())
runAgent(t, testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket))
eventually(t, "Route", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") })
s.Shutdown(ctx)
s2 := startServer(t, serverDir, addr) // gleiche Identität, gleiche Adresse
eventually(t, "Route nach Server-Neustart", func() bool {
return strings.Contains(s2.traefikConfig(t), "kunde__node-1__wp")
})
}
func TestEnroll_WritesIdentity(t *testing.T) {
s := startServer(t, "", "")
dataDir := filepath.Join(t.TempDir(), "suldata")
a, err := New(testConfig(t, dataDir, s, s.invite(t, "kunde"), ""))
if err != nil {
t.Fatal(err)
}
if err := a.Enroll(ctx); err != nil {
t.Fatalf("Enroll: %v", err)
}
for file, perm := range map[string]os.FileMode{"node.key": 0o600, "node.json": 0o600} {
info, err := os.Stat(filepath.Join(dataDir, file))
if err != nil || info.Mode().Perm() != perm {
t.Errorf("%s: %v %v", file, info, err)
}
}
var id Identity
b, _ := os.ReadFile(filepath.Join(dataDir, "node.json"))
if err := json.Unmarshal(b, &id); err != nil {
t.Fatal(err)
}
if id.NodeID != "node-1" || id.TenantID != "kunde" || id.Server != s.url() ||
id.MasterPublicKey != auth.EncodePublicKey(s.MasterPublicKey()) || id.ServerFingerprint != s.Fingerprint() {
t.Errorf("node.json: %+v", id)
}
if _, err := os.Stat(filepath.Join(dataDir, "known_servers")); err == nil {
t.Error("known_servers ist überflüssig – der Pin steht in node.json")
}
// Zweites Enroll: nichts zu tun, kein Token nötig.
a2, _ := New(Config{DataDir: dataDir, Logger: slog.New(slog.DiscardHandler)})
if err := a2.Enroll(ctx); err != nil {
t.Errorf("erneutes Enroll ohne Token: %v", err)
}
}
func TestEnroll_Errors(t *testing.T) {
s := startServer(t, "", "")
other := startServer(t, "", "")
t.Run("erster Start ohne Token", func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
a, _ := New(testConfig(t, dir, s, "", ""))
if err := a.Enroll(ctx); !errors.Is(err, ErrNotEnrolled) {
t.Errorf("erwartet ErrNotEnrolled, bekommen %v", err)
}
})
t.Run("Token ohne URL und ohne --server", func(t *testing.T) {
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), "")
cfg.ServerURL = ""
a, _ := New(cfg)
if err := a.Enroll(ctx); !errors.Is(err, ErrNotEnrolled) || !strings.Contains(err.Error(), "--server") {
t.Errorf("erwartet Hinweis auf --server, bekommen %v", err)
}
})
t.Run("Token eines anderen Servers", func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
a, _ := New(testConfig(t, dir, s, other.invite(t, "kunde"), ""))
if err := a.Enroll(ctx); !errors.Is(err, crypto.ErrFingerprintMismatch) {
t.Errorf("erwartet ErrFingerprintMismatch, bekommen %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "node.json")); err == nil {
t.Error("node.json trotz Fehlschlag geschrieben")
}
})
t.Run("Einladung verbraucht", func(t *testing.T) {
tok := s.invite(t, "kunde")
a, _ := New(testConfig(t, filepath.Join(t.TempDir(), "a"), s, tok, ""))
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
cfg := testConfig(t, filepath.Join(t.TempDir(), "b"), s, tok, "")
cfg.NodeID = "node-2"
b, _ := New(cfg)
if err := b.Enroll(ctx); err == nil || !strings.Contains(err.Error(), "403") {
t.Errorf("verbrauchte Einladung: %v", err)
}
})
// Registriert bei s, dann kommt eine Einladung von other: anderer
// Fingerprint = anderer Server = nur mit leerem Datenverzeichnis.
t.Run("Einladung eines anderen Servers trotz Registrierung", func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), "")
cfg.NodeID = "node-wechsel" // node-1 ist auf s schon vergeben
first, _ := New(cfg)
if err := first.Enroll(ctx); err != nil {
t.Fatal(err)
}
switched, _ := New(testConfig(t, dir, other, other.invite(t, "kunde"), ""))
if err := switched.Enroll(ctx); err == nil || !strings.Contains(err.Error(), "anderen Server") {
t.Errorf("Serverwechsel ohne neues Enrollment: %v", err)
}
})
}
func TestNew_Validation(t *testing.T) {
dir := t.TempDir()
if _, err := New(Config{DataDir: dir, ServerURL: "http://ingress:8443", Token: "x", NodeID: "n"}); err == nil {
t.Error("http-URL akzeptiert")
}
if _, err := New(Config{DataDir: dir, ServerURL: "https://ingress:8443", NodeID: "a__b"}); err == nil {
t.Error("ungültige Knoten-ID akzeptiert")
}
a, err := New(Config{DataDir: dir, ServerURL: "https://ingress:8443/", Hostname: "Mein.Pi"})
if err != nil {
t.Fatal(err)
}
if a.cfg.NodeID != "mein-pi" || a.cfg.ServerURL != "https://ingress:8443" || a.cfg.DockerSocket != "/var/run/docker.sock" {
t.Errorf("Defaults: %+v", a.cfg)
}
if def, _ := New(Config{}); def.cfg.DataDir != "./suldata" {
t.Errorf("Default-Datenverzeichnis: %q", def.cfg.DataDir)
}
}
// Ohne Docker (Bare-Metal) meldet der Agent einen leeren Zustand und bleibt
// verbunden; ein fehlender Socket ist kein Fehler.
func TestRun_WithoutDocker(t *testing.T) {
s := startServer(t, "", "")
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), "/gibt/es/nicht.sock")
runAgent(t, cfg)
eventually(t, "Knoten online", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
if c := s.traefikConfig(t); c != "{}" {
t.Errorf("Konfiguration ohne Docker: %s", c)
}
}
// Läuft Run ohne Enrollment-Möglichkeit, endet es sofort mit Fehler.
func TestRun_NotEnrolled(t *testing.T) {
s := startServer(t, "", "")
a, _ := New(testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, "", ""))
if err := a.Run(ctx); !errors.Is(err, ErrNotEnrolled) {
t.Errorf("erwartet ErrNotEnrolled, bekommen %v", err)
}
}
// Bare-Metal-Weg: Dienst aus /etc/sultan.d → Route; Healthcheck schlägt fehl →
// Wartungsseite (Interceptor); Dienst erholt sich → wieder normal; Datei
// geändert/gelöscht → Konfiguration folgt.
func TestRun_ServiceDirWithHealthcheck(t *testing.T) {
s := startServer(t, "", "")
var healthy atomic.Bool
healthy.Store(true)
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !healthy.Load() {
http.Error(w, "kaputt", http.StatusInternalServerError)
return
}
w.Write([]byte("<html><head><title>Shop</title></head><body><h2>Impressum</h2></body></html>"))
}))
defer backend.Close()
port := backend.URL[strings.LastIndex(backend.URL, ":")+1:]
confDir := t.TempDir()
writeConf := func(content string) {
if err := os.WriteFile(filepath.Join(confDir, "shop.yaml"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
writeConf(`
services:
- name: shop
url: ` + backend.URL + `
hosts: [shop.kunde.de]
healthcheck: { interval: 30ms, failures: 2, expect_title: Shop }
`)
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), NoDocker)
cfg.ConfigDir = confDir
cfg.ConfigPoll = 20 * time.Millisecond
runAgent(t, cfg)
eventually(t, "Route aus sultan.d", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__shop") && strings.Contains(c, "http://100.64.0.42:"+port)
})
if strings.Contains(s.traefikConfig(t), "sultan__interceptor") {
t.Error("gesunder Dienst wird umgeleitet")
}
healthy.Store(false)
eventually(t, "Wartungsseite bei Ausfall", func() bool {
return strings.Contains(s.traefikConfig(t), "sultan__interceptor")
})
healthy.Store(true)
eventually(t, "zurück zum Dienst nach Erholung", func() bool {
return !strings.Contains(s.traefikConfig(t), "sultan__interceptor")
})
writeConf(`
services:
- name: shop
url: ` + backend.URL + `
hosts: [shop.kunde.de, laden.kunde.de]
`)
eventually(t, "geänderte Datei übernommen", func() bool {
return strings.Contains(s.traefikConfig(t), "laden.kunde.de")
})
os.Remove(filepath.Join(confDir, "shop.yaml"))
eventually(t, "gelöschte Datei → Route weg", func() bool { return s.traefikConfig(t) == "{}" })
}
// Docker- und Datei-Workloads werden gemeinsam gemeldet.
func TestRun_DockerAndServiceDirTogether(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t, wordpress())
confDir := t.TempDir()
os.WriteFile(filepath.Join(confDir, "blog.yaml"), []byte(`
services:
- name: blog
url: http://127.0.0.1:8090
hosts: [blog.kunde.de]
`), 0o644)
cfg := testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket)
cfg.ConfigDir = confDir
cfg.ConfigPoll = 20 * time.Millisecond
runAgent(t, cfg)
eventually(t, "beide Routen", func() bool {
c := s.traefikConfig(t)
return strings.Contains(c, "kunde__node-1__wp") && strings.Contains(c, "kunde__node-1__blog")
})
}
// inviteURL erzeugt eine Einladung mit eingebetteter Server-URL.
func (s *testServer) inviteURL(t *testing.T, tenant, serverURL string) string {
t.Helper()
s.invite(t, tenant) // legt den Mandanten bei Bedarf an
tok, err := crypto.NewEnrollmentToken(s.Fingerprint(), serverURL)
if err != nil {
t.Fatal(err)
}
if err := s.db(t).AddEnrollmentToken(ctx, tok.Secret, tenant, time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
return tok.String()
}
func readIdentity(t *testing.T, dataDir string) Identity {
t.Helper()
var id Identity
b, err := os.ReadFile(filepath.Join(dataDir, "node.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(b, &id); err != nil {
t.Fatal(err)
}
return id
}
// Ein einziger Token genügt: die Adresse steckt darin.
func TestEnroll_URLFromToken(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.inviteURL(t, "kunde", s.url()), "")
cfg.ServerURL = ""
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatalf("Enroll nur mit Token: %v", err)
}
if id := readIdentity(t, dir); id.Server != s.url() || id.ServerFingerprint != s.Fingerprint() {
t.Errorf("node.json: %+v", id)
}
}
// --server schlägt die URL im Token (z. B. interne Adresse statt öffentlicher).
func TestEnroll_ServerFlagOverridesTokenURL(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.inviteURL(t, "kunde", "https://nicht-erreichbar.invalid:8443"), "")
a, _ := New(cfg) // cfg.ServerURL = s.url()
if err := a.Enroll(ctx); err != nil {
t.Fatalf("Enroll mit Override: %v", err)
}
if id := readIdentity(t, dir); id.Server != s.url() {
t.Errorf("gespeicherte Adresse %q, erwartet die tatsächlich genutzte %q", id.Server, s.url())
}
}
// Ist der Agent registriert, darf dieselbe Einladung (bzw. eine des
// gleichen Servers) erneut mitgegeben werden – sie wird ignoriert.
func TestEnroll_SameServerTokenIgnoredWhenEnrolled(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
tok := s.inviteURL(t, "kunde", s.url())
cfg := testConfig(t, dir, s, tok, "")
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
again, _ := New(cfg) // gleicher, inzwischen verbrauchter Token (z. B. in docker-compose stehen geblieben)
if err := again.Enroll(ctx); err != nil {
t.Errorf("erneuter Start mit altem Token: %v", err)
}
}
// Der Pin hängt am Server, nicht an der Adresse: derselbe Server unter
// neuer Adresse funktioniert, die neue Adresse wird übernommen.
func TestRun_AddressChangePersisted(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t, wordpress())
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), dock.socket)
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
moved := "https://localhost" + s.url()[len("https://127.0.0.1"):]
cfg.Token, cfg.ServerURL = "", moved
runAgent(t, cfg)
eventually(t, "Route über neue Adresse", func() bool {
return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp")
})
eventually(t, "neue Adresse gespeichert", func() bool { return readIdentity(t, dir).Server == moved })
if id := readIdentity(t, dir); id.ServerFingerprint != s.Fingerprint() {
t.Errorf("Fingerprint verändert: %+v", id)
}
}
// `sultan agent maintenance on|off` auf dem Knoten, z. B. aus einem Patch-Skript.
func TestSetMaintenance(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t, wordpress())
dir := filepath.Join(t.TempDir(), "suldata")
runAgent(t, testConfig(t, dir, s, s.invite(t, "kunde"), dock.socket))
eventually(t, "Route", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") })
ctl, err := New(Config{DataDir: dir, Logger: slog.New(slog.DiscardHandler)})
if err != nil {
t.Fatal(err)
}
if err := ctl.SetMaintenance(ctx, true, 2*time.Hour, "Kernel-Update", "Sicherheitsupdate"); err != nil {
t.Fatalf("SetMaintenance: %v", err)
}
n, _ := s.db(t).GetNode(ctx, "node-1")
if n.Maintenance == nil || n.Maintenance.Reason != "Kernel-Update" || n.Maintenance.PublicReason != "Sicherheitsupdate" {
t.Errorf("gespeichert: %+v", n.Maintenance)
}
if !strings.Contains(s.traefikConfig(t), "sultan__interceptor") {
t.Error("Wartung nicht wirksam")
}
if err := ctl.SetMaintenance(ctx, false, 0, "", ""); err != nil {
t.Fatal(err)
}
if strings.Contains(s.traefikConfig(t), "sultan__interceptor") {
t.Error("Wartung nicht beendet")
}
notEnrolled, _ := New(Config{DataDir: filepath.Join(t.TempDir(), "leer"), Logger: slog.New(slog.DiscardHandler)})
if err := notEnrolled.SetMaintenance(ctx, true, time.Hour, "", ""); !errors.Is(err, ErrNotEnrolled) {
t.Errorf("nicht registriert: %v", err)
}
}
func TestRun_LeaseRecorded(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
var logs syncBuffer
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker)
cfg.Logger = slog.New(slog.NewTextHandler(&logs, nil))
runAgent(t, cfg)
eventually(t, "lease.json", func() bool {
_, err := os.Stat(filepath.Join(dir, "lease.json"))
return err == nil
})
b, _ := os.ReadFile(filepath.Join(dir, "lease.json"))
l, err := license.ParseLease(b, s.MasterPublicKey())
if err != nil || l.NodeID != "node-1" {
t.Errorf("lease.json: %+v %v", l, err)
}
eventually(t, "Lease im Log", func() bool { return strings.Contains(logs.String(), "Lease erhalten") })
}
// resync: Der Server fordert den Zustand neu an (hier: Container geändert,
// ohne dass Docker ein Event geschickt hat).
func TestRun_ResyncCommand(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t)
runAgent(t, testConfig(t, filepath.Join(t.TempDir(), "suldata"), s, s.invite(t, "kunde"), dock.socket))
eventually(t, "online", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
dock.mu.Lock()
dock.containers = []docker.Container{wordpress()}
dock.mu.Unlock()
if err := s.db(t).EnqueueNodeCommand(ctx, "node-1", stream.CmdResync, ""); err != nil {
t.Fatal(err)
}
eventually(t, "Zustand nach resync", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") })
}
func TestRun_RotateKeyCommand(t *testing.T) {
s := startServer(t, "", "")
dock := newFakeDocker(t)
dir := filepath.Join(t.TempDir(), "suldata")
runAgent(t, testConfig(t, dir, s, s.invite(t, "kunde"), dock.socket))
eventually(t, "online", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
before, _ := os.ReadFile(filepath.Join(dir, "node.key"))
oldPub, _ := s.db(t).GetNode(ctx, "node-1")
s.db(t).EnqueueNodeCommand(ctx, "node-1", stream.CmdRotateKey, "Routine")
eventually(t, "Schlüssel gewechselt", func() bool {
n, _ := s.db(t).GetNode(ctx, "node-1")
after, _ := os.ReadFile(filepath.Join(dir, "node.key"))
return n.PublicKey != oldPub.PublicKey && string(after) != string(before)
})
if _, err := os.Stat(filepath.Join(dir, "node.key.new")); err == nil {
t.Error("node.key.new nach erfolgreicher Rotation noch vorhanden")
}
// Der Agent arbeitet mit dem neuen Schlüssel weiter.
dock.set("start", wordpress())
eventually(t, "Zustand mit neuem Schlüssel", func() bool { return strings.Contains(s.traefikConfig(t), "kunde__node-1__wp") })
}
// Abbruch mitten in der Rotation: Der Server hat den neuen Schlüssel schon,
// der Agent hat node.key.new noch nicht umbenannt. Beim nächsten Start wird
// das sicher abgeschlossen.
func TestRotateKey_RecoveryAfterCrash(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker)
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
newKey, _ := auth.GenerateKey()
if err := auth.WritePrivateKey(filepath.Join(dir, "node.key.new"), newKey); err != nil {
t.Fatal(err)
}
newPub := auth.EncodePublicKey(newKey.Public().(ed25519.PublicKey))
if err := s.db(t).UpdateNodePublicKey(ctx, "node-1", newPub); err != nil { // Server war schon fertig
t.Fatal(err)
}
cfg.Token = ""
runAgent(t, cfg)
eventually(t, "online mit neuem Schlüssel", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
got, err := auth.ReadPrivateKey(filepath.Join(dir, "node.key"))
if err != nil || !got.Equal(newKey) {
t.Errorf("node.key nicht durch den neuen Schlüssel ersetzt: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "node.key.new")); err == nil {
t.Error("node.key.new noch vorhanden")
}
}
// Unsignierte oder gefälschte Events werden ignoriert – kein Befehl ohne
// gültige Signatur des Servers.
func TestHandleEvent_RejectsUnsigned(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
var logs syncBuffer
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker)
cfg.Logger = slog.New(slog.NewTextHandler(&logs, nil))
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(filepath.Join(dir, "node.key"))
forged, _ := stream.NewEvent(stream.EventNodeCommand, stream.NodeCommand{Command: stream.CmdRotateKey})
if err := a.handleEvent(forged); err != nil {
t.Errorf("handleEvent darf den Stream nicht beenden: %v", err)
}
after, _ := os.ReadFile(filepath.Join(dir, "node.key"))
if string(before) != string(after) {
t.Error("unsignierter Befehl wurde ausgeführt")
}
if !strings.Contains(logs.String(), "verworfen") {
t.Errorf("keine Warnung im Log: %s", logs.String())
}
}
type syncBuffer struct {
mu sync.Mutex
b strings.Builder
}
func (s *syncBuffer) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.Write(p)
}
func (s *syncBuffer) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() }
// Der Server schickt beim Verbinden seine Adressliste; der Agent merkt sie
// sich in node.json (nur gültige https-Adressen).
func TestRun_StoresServerURLsFromConfig(t *testing.T) {
s := startServerWith(t, func(c *server.Config) {
c.PublicURLs = []string{"https://ingress.example:8443", "https://100.64.0.1:8443"}
})
dir := filepath.Join(t.TempDir(), "suldata")
runAgent(t, testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker))
eventually(t, "server_urls in node.json", func() bool {
b, err := os.ReadFile(filepath.Join(dir, "node.json"))
var id Identity
return err == nil && json.Unmarshal(b, &id) == nil && len(id.ServerURLs) == 2
})
if id := readIdentity(t, dir); id.ServerURLs[0] != "https://ingress.example:8443" || id.Server != s.url() {
t.Errorf("node.json: %+v", id)
}
}
// Ausweichweg: Die zuletzt genutzte Adresse ist tot, eine andere aus der
// Liste funktioniert – der Agent verbindet sich darüber und merkt sie sich.
func TestRun_FailoverToNextServerURL(t *testing.T) {
s := startServer(t, "", "")
dir := filepath.Join(t.TempDir(), "suldata")
cfg := testConfig(t, dir, s, s.invite(t, "kunde"), NoDocker)
a, _ := New(cfg)
if err := a.Enroll(ctx); err != nil {
t.Fatal(err)
}
id := readIdentity(t, dir)
dead := "https://127.0.0.1:1"
id.Server, id.ServerURLs = dead, []string{dead, s.url()}
if err := writeJSON(filepath.Join(dir, "node.json"), id); err != nil {
t.Fatal(err)
}
cfg.Token, cfg.ServerURL = "", ""
runAgent(t, cfg)
eventually(t, "online über Ausweichadresse", func() bool { return s.nodeStatus(t, "node-1") == store.NodeOnline })
eventually(t, "funktionierende Adresse gemerkt", func() bool { return readIdentity(t, dir).Server == s.url() })
}
// --server hat Vorrang, die übrigen Adressen dienen als Ausweichweg.
func TestServerCandidates(t *testing.T) {
a := &Agent{cfg: Config{ServerURL: "https://override:8443"}, id: Identity{
Server: "https://zuletzt:8443", ServerURLs: []string{"https://a:8443", "https://zuletzt:8443", "https://b:8443"},
}}
want := []string{"https://override:8443", "https://zuletzt:8443", "https://a:8443", "https://b:8443"}
if got := a.serverCandidates(); !reflect.DeepEqual(got, want) {
t.Errorf("Kandidaten %v, erwartet %v", got, want)
}
a.cfg.ServerURL = ""
if got := a.serverCandidates(); got[0] != "https://zuletzt:8443" || len(got) != 3 {
t.Errorf("ohne Override: %v", got)
}
}
+175
View File
@@ -0,0 +1,175 @@
package agent
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"sultan/pkg/auth"
"sultan/pkg/license"
"sultan/pkg/server"
"sultan/pkg/stream"
)
// currentSigner liefert den aktuellen Signierer (rotate_key tauscht ihn aus).
func (a *Agent) currentSigner() *auth.Signer {
a.keyMu.RLock()
defer a.keyMu.RUnlock()
return a.signer
}
func (a *Agent) setSigner(s *auth.Signer) {
a.keyMu.Lock()
a.signer = s
a.keyMu.Unlock()
}
// handleEvent verarbeitet Events vom Server. Nur Events mit gültiger
// Signatur des Master-Keys werden beachtet; Fehler beenden nie den Stream.
func (a *Agent) handleEvent(ev stream.Event) error {
pub, err := auth.DecodePublicKey(a.id.MasterPublicKey)
if err != nil {
a.log.Error("Master-Key in node.json unlesbar – Event verworfen", "err", err)
return nil
}
if err := stream.VerifyEvent(ev, pub); err != nil {
a.log.Warn("Event ohne gültige Server-Signatur verworfen", "type", ev.Type, "err", err)
return nil
}
switch ev.Type {
case stream.EventLeaseRenew:
a.recordLease(ev.Payload, pub)
case stream.EventNodeCommand:
cmd, err := stream.ParseNodeCommand(ev)
if err != nil {
a.log.Warn("Befehl unlesbar", "err", err)
return nil
}
a.runCommand(cmd)
default:
a.log.Info("Ereignis vom Server", "type", ev.Type)
}
return nil
}
// recordLease prüft die Lease, legt sie als lease.json ab und protokolliert
// sie (vorerst ohne weitere Konsequenz, Spec 4a).
func (a *Agent) recordLease(doc []byte, pub ed25519.PublicKey) {
l, err := license.ParseLease(doc, pub)
if err != nil {
a.log.Warn("Lease ungültig – verworfen", "err", err)
return
}
if l.NodeID != a.id.NodeID {
a.log.Warn("Lease für anderen Knoten – verworfen", "node", l.NodeID)
return
}
tmp := a.path("lease.json.tmp")
if err := os.WriteFile(tmp, doc, 0o600); err == nil {
err = os.Rename(tmp, a.path("lease.json"))
}
if err != nil {
a.log.Warn("lease.json nicht gespeichert", "err", err)
}
a.log.Info("Lease erhalten", "valid_until", l.ValidUntil, "renew_after", l.RenewAfter, "tier", l.Tier)
}
func (a *Agent) runCommand(cmd stream.NodeCommand) {
switch cmd.Command {
case stream.CmdResync:
a.log.Info("Server fordert Zustand an (resync)")
if a.kick != nil {
a.kick()
}
case stream.CmdRotateKey:
a.log.Info("Server fordert Schlüsselwechsel an", "reason", cmd.Reason)
ctx := a.runCtx
if ctx == nil {
ctx = context.Background()
}
if err := a.rotateKey(ctx); err != nil {
a.log.Error("Schlüsselwechsel fehlgeschlagen", "err", err)
}
case stream.CmdConfig:
a.updateServerURLs(cmd.ServerURLs)
case stream.CmdDrain, stream.CmdMaintenance, stream.CmdResume:
a.log.Info("Hinweis vom Server", "command", cmd.Command, "reason", cmd.Reason)
default:
a.log.Warn("Unbekannter Befehl ignoriert", "command", cmd.Command)
}
}
// rotateKey erzeugt einen neuen Schlüssel (node.key.new) und meldet ihn.
func (a *Agent) rotateKey(ctx context.Context) error {
newPath := a.path("node.key.new")
if _, err := os.Stat(newPath); errors.Is(err, os.ErrNotExist) {
key, err := auth.GenerateKey()
if err != nil {
return err
}
if err := auth.WritePrivateKey(newPath, key); err != nil {
return err
}
}
return a.completeRotation(ctx)
}
// completeRotation meldet node.key.new beim Server und benennt die Datei erst
// danach um. Abbruch-sicher: Lehnt der Server den alten Schlüssel ab (er hat
// den neuen schon), wird mit dem neuen wiederholt.
func (a *Agent) completeRotation(ctx context.Context) error {
newPath := a.path("node.key.new")
newKey, err := auth.ReadPrivateKey(newPath)
if err != nil {
return err
}
body, _ := json.Marshal(server.RotateKeyRequest{
PublicKey: auth.EncodePublicKey(newKey.Public().(ed25519.PublicKey)),
Proof: auth.RotationProof(a.id.NodeID, newKey),
})
newSigner := &auth.Signer{Key: newKey}
status, err := a.postSigned(ctx, "/api/v1/rotate-key", body, a.currentSigner())
if err == nil && status == http.StatusUnauthorized {
status, err = a.postSigned(ctx, "/api/v1/rotate-key", body, newSigner)
}
if err != nil {
return err
}
if status != http.StatusNoContent {
return fmt.Errorf("Server lehnt Schlüsselwechsel ab (%d)", status)
}
if err := os.Rename(newPath, a.path("node.key")); err != nil {
return err
}
a.setSigner(newSigner)
a.log.Info("Knotenschlüssel gewechselt")
return nil
}
func (a *Agent) postSigned(ctx context.Context, path string, body []byte, signer *auth.Signer) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.currentServerURL()+path, bytes.NewReader(body))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
if err := signer.Sign(req); err != nil {
return 0, err
}
resp, err := a.httpClient().Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusUnauthorized {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return resp.StatusCode, fmt.Errorf("Server antwortet %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
}
return resp.StatusCode, nil
}
+137
View File
@@ -0,0 +1,137 @@
package agent
import (
"context"
"fmt"
"io"
"net/http"
"regexp"
"slices"
"strings"
"sync"
)
var (
tagRe = regexp.MustCompile(`(?s)<[^>]*>`)
spaceRe = regexp.MustCompile(`\s+`)
)
// checkHealth ruft spec.URL ab und prüft Status sowie optional <title> und
// ein Element mit Text. Bewusst einfacher Textabgleich statt HTML-Parser:
// Es geht um "liefert die Seite noch das Erwartete", nicht um Validierung.
func checkHealth(ctx context.Context, client *http.Client, spec HealthSpec) error {
ctx, cancel := context.WithTimeout(ctx, spec.Timeout.Duration)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, spec.URL, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != spec.ExpectStatus {
return fmt.Errorf("Status %d statt %d", resp.StatusCode, spec.ExpectStatus)
}
if spec.ExpectTitle == "" && spec.ExpectContains == nil {
return nil
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
if spec.ExpectTitle != "" {
titles := elementTexts(string(body), "title")
if len(titles) == 0 || titles[0] != strings.TrimSpace(spec.ExpectTitle) {
return fmt.Errorf("<title> %q statt %q", strings.Join(titles, ""), spec.ExpectTitle)
}
}
if c := spec.ExpectContains; c != nil {
for _, text := range elementTexts(string(body), c.Tag) {
if strings.Contains(text, c.Text) {
return nil
}
}
return fmt.Errorf("kein <%s> mit %q", c.Tag, c.Text)
}
return nil
}
// elementTexts liefert den Textinhalt aller <tag>-Elemente (ohne innere Tags,
// Leerraum zusammengefasst). Groß-/Kleinschreibung des Tags ist egal.
func elementTexts(html, tag string) []string {
t := regexp.QuoteMeta(tag)
re := regexp.MustCompile(`(?is)<` + t + `\b[^>]*>(.*?)</` + t + `\s*>`)
var out []string
for _, m := range re.FindAllStringSubmatch(html, -1) {
text := tagRe.ReplaceAllString(m[1], " ")
out = append(out, strings.TrimSpace(spaceRe.ReplaceAllString(text, " ")))
}
return out
}
// healthTracker merkt sich den Zustand je Dienst: "down" erst nach
// threshold Fehlschlägen in Folge, "up" nach einem Erfolg.
type healthTracker struct {
mu sync.Mutex
state map[string]*healthState
}
type healthState struct {
fails int
down bool
}
// report verbucht ein Ergebnis und meldet, ob sich up/down geändert hat.
func (t *healthTracker) report(name string, threshold int, ok bool) bool {
t.mu.Lock()
defer t.mu.Unlock()
if t.state == nil {
t.state = map[string]*healthState{}
}
st := t.state[name]
if st == nil {
st = &healthState{}
t.state[name] = st
}
if ok {
st.fails = 0
if st.down {
st.down = false
return true
}
return false
}
st.fails++
if !st.down && st.fails >= threshold {
st.down = true
return true
}
return false
}
// down liefert die ausgefallenen Dienste, sortiert (nil, wenn keiner).
func (t *healthTracker) down() []string {
t.mu.Lock()
defer t.mu.Unlock()
var out []string
for name, st := range t.state {
if st.down {
out = append(out, name)
}
}
slices.Sort(out)
return out
}
// retain vergisst Dienste, die es nicht mehr gibt.
func (t *healthTracker) retain(keep map[string]bool) {
t.mu.Lock()
defer t.mu.Unlock()
for name := range t.state {
if !keep[name] {
delete(t.state, name)
}
}
}
+98
View File
@@ -0,0 +1,98 @@
package agent
import (
"context"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"time"
)
const page = `<!doctype html><html><head><TITLE> Shop - Willkommen </TITLE></head>
<body><h1>Hallo</h1><h2 class="fuss"><a href="/impressum">Impressum</a></h2></body></html>`
func healthServer(t *testing.T, status int, body string, delay time.Duration) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if delay > 0 {
select {
case <-time.After(delay):
case <-r.Context().Done():
return
}
}
w.WriteHeader(status)
w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv.URL
}
func TestCheckHealth(t *testing.T) {
ok := healthServer(t, 200, page, 0)
tests := []struct {
name string
url string
spec HealthSpec
ok bool
}{
{"nur Status", ok, HealthSpec{ExpectStatus: 200}, true},
{"Titel passt (Leerraum, Großschreibung des Tags egal)", ok, HealthSpec{ExpectStatus: 200, ExpectTitle: "Shop - Willkommen"}, true},
{"Tag mit Text, verschachtelt", ok, HealthSpec{ExpectStatus: 200, ExpectContains: &ContainsSpec{Tag: "H2", Text: "Impressum"}}, true},
{"falscher Status", healthServer(t, 500, page, 0), HealthSpec{ExpectStatus: 200}, false},
{"anderer erwarteter Status", healthServer(t, 204, "", 0), HealthSpec{ExpectStatus: 204}, true},
{"Titel weicht ab", ok, HealthSpec{ExpectStatus: 200, ExpectTitle: "Wartung"}, false},
{"Text fehlt", ok, HealthSpec{ExpectStatus: 200, ExpectContains: &ContainsSpec{Tag: "h2", Text: "Datenschutz"}}, false},
{"Text im falschen Tag", ok, HealthSpec{ExpectStatus: 200, ExpectContains: &ContainsSpec{Tag: "h1", Text: "Impressum"}}, false},
{"Zeitüberschreitung", healthServer(t, 200, page, time.Second), HealthSpec{ExpectStatus: 200, Timeout: Duration{50 * time.Millisecond}}, false},
{"nicht erreichbar", "http://127.0.0.1:1", HealthSpec{ExpectStatus: 200}, false},
}
for _, tt := range tests {
if tt.spec.Timeout.Duration == 0 {
tt.spec.Timeout = Duration{2 * time.Second}
}
tt.spec.URL = tt.url
err := checkHealth(context.Background(), http.DefaultClient, tt.spec)
if (err == nil) != tt.ok {
t.Errorf("%s: err=%v, erwartet ok=%v", tt.name, err, tt.ok)
}
}
}
// Ausfall erst nach N Fehlschlägen in Folge, Erholung nach einem Erfolg –
// gegen Flattern.
func TestHealthTracker(t *testing.T) {
var tr healthTracker
steps := []struct {
ok bool
changed bool
down []string
}{
{true, false, nil},
{false, false, nil},
{true, false, nil}, // Zähler zurückgesetzt
{false, false, nil},
{false, true, []string{"shop"}},
{false, false, []string{"shop"}},
{true, true, nil},
}
for i, s := range steps {
if changed := tr.report("shop", 2, s.ok); changed != s.changed {
t.Errorf("Schritt %d: changed=%v, erwartet %v", i, changed, s.changed)
}
if got := tr.down(); !reflect.DeepEqual(got, s.down) {
t.Errorf("Schritt %d: down=%v, erwartet %v", i, got, s.down)
}
}
tr.report("a", 1, false)
tr.report("b", 1, false)
if got := tr.down(); !reflect.DeepEqual(got, []string{"a", "b"}) {
t.Errorf("sortiert: %v", got)
}
tr.retain(map[string]bool{"b": true})
if got := tr.down(); !reflect.DeepEqual(got, []string{"b"}) {
t.Errorf("nach retain: %v", got)
}
}
+94
View File
@@ -0,0 +1,94 @@
package agent
import (
"fmt"
"net"
"net/url"
"regexp"
"strings"
"sultan/pkg/policy"
)
// backendHost bestimmt die Adresse, unter der der Ingress die Dienste
// dieses Hosts erreicht: explizit gesetzt, sonst die IP des angegebenen
// Interfaces (z. B. wt0 für NetBird), sonst die lokale IP, über die der
// Server erreicht wird.
func backendHost(explicit, iface, serverURL string) (string, error) {
if explicit != "" {
return explicit, nil
}
if iface != "" {
return interfaceIP(iface)
}
addr, err := hostPort(serverURL)
if err != nil {
return "", err
}
// UDP-"Verbindung" sendet nichts, ermittelt aber die ausgehende Adresse.
conn, err := net.Dial("udp", addr)
if err != nil {
return "", fmt.Errorf("agent: Backend-Adresse ermitteln: %w", err)
}
defer conn.Close()
return conn.LocalAddr().(*net.UDPAddr).IP.String(), nil
}
func interfaceIP(name string) (string, error) {
ifc, err := net.InterfaceByName(name)
if err != nil {
return "", fmt.Errorf("agent: Interface %s: %w", name, err)
}
addrs, err := ifc.Addrs()
if err != nil {
return "", err
}
var v6 string
for _, a := range addrs {
ipnet, ok := a.(*net.IPNet)
if !ok {
continue
}
if ip4 := ipnet.IP.To4(); ip4 != nil {
return ip4.String(), nil
}
if v6 == "" && !ipnet.IP.IsLinkLocalUnicast() {
v6 = ipnet.IP.String()
}
}
if v6 != "" {
return v6, nil
}
return "", fmt.Errorf("agent: Interface %s hat keine nutzbare IP", name)
}
// hostPort liefert host:port der Server-URL (Default-Port 443).
func hostPort(serverURL string) (string, error) {
u, err := url.Parse(serverURL)
if err != nil || u.Hostname() == "" {
return "", fmt.Errorf("agent: Server-URL %q ungültig", serverURL)
}
if u.Port() == "" {
return net.JoinHostPort(u.Hostname(), "443"), nil
}
return u.Host, nil
}
var (
nodeIDUnsafe = regexp.MustCompile(`[^a-z0-9_-]+`)
nodeIDDashes = regexp.MustCompile(`-{2,}`)
nodeIDUnders = regexp.MustCompile(`_{2,}`)
)
// DefaultNodeID leitet aus dem Hostnamen eine gültige Knoten-ID ab
// (klein, a–z/0–9/-/_, kein "__").
func DefaultNodeID(hostname string) (string, error) {
id := nodeIDUnsafe.ReplaceAllString(strings.ToLower(strings.TrimSpace(hostname)), "-")
id = nodeIDDashes.ReplaceAllString(id, "-")
id = nodeIDUnders.ReplaceAllString(id, "_")
id = strings.Trim(id, "-_")
if !policy.ValidID(id) {
return "", fmt.Errorf("agent: aus Hostname %q lässt sich keine Knoten-ID ableiten – --node-id angeben", hostname)
}
return id, nil
}
+44
View File
@@ -0,0 +1,44 @@
package agent
import "testing"
func TestBackendHost(t *testing.T) {
tests := []struct {
name, explicit, iface, server, want string
wantErr bool
}{
{"explizit", "100.64.0.42", "", "https://127.0.0.1:8443", "100.64.0.42", false},
{"explizit schlägt Interface", "192.168.1.5", "lo", "https://127.0.0.1:8443", "192.168.1.5", false},
{"Interface", "", "lo", "https://127.0.0.1:8443", "127.0.0.1", false},
{"unbekanntes Interface", "", "gibtsnicht0", "https://127.0.0.1:8443", "", true},
{"automatisch: Route zum Server", "", "", "https://127.0.0.1:8443", "127.0.0.1", false},
{"automatisch ohne Port", "", "", "https://127.0.0.1", "127.0.0.1", false},
}
for _, tt := range tests {
got, err := backendHost(tt.explicit, tt.iface, tt.server)
if (err != nil) != tt.wantErr || got != tt.want {
t.Errorf("%s: %q, %v; erwartet %q (Fehler=%v)", tt.name, got, err, tt.want, tt.wantErr)
}
}
}
func TestDefaultNodeID(t *testing.T) {
tests := map[string]string{
"pi": "pi",
"Proxmox-VM.42.local": "proxmox-vm-42-local",
"web__01": "web_01",
" -host- ": "host",
"äöü-box": "box",
}
for in, want := range tests {
got, err := DefaultNodeID(in)
if err != nil || got != want {
t.Errorf("DefaultNodeID(%q) = %q, %v; erwartet %q", in, got, err, want)
}
}
for _, bad := range []string{"", "---", "äöü"} {
if _, err := DefaultNodeID(bad); err == nil {
t.Errorf("DefaultNodeID(%q): erwartet Fehler", bad)
}
}
}
+286
View File
@@ -0,0 +1,286 @@
package agent
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"time"
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/server"
)
// ErrNotEnrolled: Es gibt noch keine Registrierung und keine (vollständige) Einladung.
var ErrNotEnrolled = errors.New("agent: noch nicht registriert – beim ersten Start --token angeben (und --server, falls die Einladung keine Adresse enthält)")
// Identity ist das Ergebnis des Enrollments (node.json). ServerFingerprint
// ist der Pin: Er gilt für den Server, nicht für eine Adresse – Server kann
// sich ändern (DNS-Umzug, NetBird), solange das Zertifikat dasselbe ist.
type Identity struct {
Server string `json:"server"`
ServerFingerprint crypto.Fingerprint `json:"server_fingerprint"`
// ServerURLs: weitere Adressen des Servers (per config-Befehl), Ausweichweg.
ServerURLs []string `json:"server_urls,omitempty"`
NodeID string `json:"node_id"`
TenantID string `json:"tenant_id"`
MasterPublicKey string `json:"master_public_key"`
}
func (a *Agent) path(name string) string { return filepath.Join(a.cfg.DataDir, name) }
// Enroll stellt sicher, dass der Agent registriert ist: Liegt node.json
// vor, wird sie geladen; sonst wird die Einladung eingelöst. Der Server wird
// dabei gegen den Fingerprint aus der Einladung geprüft.
//
// Server-Adresse: --server vor der Adresse aus der Einladung vor node.json.
func (a *Agent) Enroll(ctx context.Context) error {
if err := os.MkdirAll(a.cfg.DataDir, 0o700); err != nil {
return fmt.Errorf("agent: Datenverzeichnis: %w", err)
}
key, err := loadOrCreateKey(a.path("node.key"))
if err != nil {
return err
}
a.setSigner(&auth.Signer{Key: key})
if data, err := os.ReadFile(a.path("node.json")); err == nil {
var id Identity
if err := json.Unmarshal(data, &id); err != nil {
return fmt.Errorf("agent: node.json beschädigt: %w", err)
}
if id.ServerFingerprint == "" {
return errors.New("agent: node.json ohne server_fingerprint – Datenverzeichnis leeren und neu einladen")
}
if a.cfg.Token != "" {
// Eine stehen gebliebene Einladung desselben Servers ist harmlos;
// eine Einladung eines anderen Servers ist ein Fehler.
if tok, err := crypto.ParseEnrollmentToken(a.cfg.Token); err == nil && tok.Fingerprint != id.ServerFingerprint {
return fmt.Errorf("agent: bereits bei einem anderen Server registriert (Fingerprint %s) – für den neuen Server das Datenverzeichnis leeren und neu einladen", id.ServerFingerprint)
}
}
a.id = id
a.serverURL = id.Server
if a.cfg.ServerURL != "" {
a.serverURL = a.cfg.ServerURL
}
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if a.cfg.Token == "" {
return ErrNotEnrolled
}
if a.nodeIDErr != nil {
return a.nodeIDErr
}
tok, err := crypto.ParseEnrollmentToken(a.cfg.Token)
if err != nil {
return err
}
serverURL := a.cfg.ServerURL
if serverURL == "" {
serverURL = tok.ServerURL
}
if serverURL == "" {
return fmt.Errorf("%w: die Einladung enthält keine Server-Adresse – --server angeben", ErrNotEnrolled)
}
hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(tok.Fingerprint)}}
body, _ := json.Marshal(server.EnrollRequest{Token: tok.Secret, NodeID: a.cfg.NodeID, Hostname: a.cfg.Hostname})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, serverURL+"/api/v1/enroll", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if err := a.currentSigner().Sign(req); err != nil {
return err
}
resp, err := hc.Do(req)
if err != nil {
return fmt.Errorf("agent: Enrollment: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("agent: Enrollment abgelehnt (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg)))
}
var er server.EnrollResponse
if err := json.NewDecoder(resp.Body).Decode(&er); err != nil {
return fmt.Errorf("agent: Enrollment-Antwort: %w", err)
}
a.id = Identity{
Server: serverURL, ServerFingerprint: tok.Fingerprint,
NodeID: er.NodeID, TenantID: er.TenantID, MasterPublicKey: er.MasterPublicKey,
}
a.serverURL = serverURL
if err := writeJSON(a.path("node.json"), a.id); err != nil {
return err
}
a.log.Info("Registriert", "node", er.NodeID, "tenant", er.TenantID, "server", serverURL)
return nil
}
// serverCandidates liefert die Adressen in Versuchsreihenfolge: --server,
// zuletzt funktionierende Adresse, dann die Liste des Servers.
func (a *Agent) serverCandidates() []string {
var out []string
for _, u := range append([]string{a.cfg.ServerURL, a.id.Server}, a.id.ServerURLs...) {
if u != "" && !slices.Contains(out, u) {
out = append(out, u)
}
}
return out
}
func (a *Agent) currentServerURL() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.serverURL
}
// nextServerURL wechselt nach einem Verbindungsfehler zur nächsten Adresse.
func (a *Agent) nextServerURL() string {
a.mu.Lock()
defer a.mu.Unlock()
cands := a.serverCandidates()
next := cands[(slices.Index(cands, a.serverURL)+1)%len(cands)]
a.serverURL = next
return next
}
// updateServerURLs übernimmt die Adressliste des Servers (nur gültige).
func (a *Agent) updateServerURLs(urls []string) {
var valid []string
for _, u := range urls {
if v, err := crypto.ValidateServerURL(u); err == nil && !slices.Contains(valid, v) {
valid = append(valid, v)
} else if err != nil {
a.log.Warn("Ungültige Server-Adresse ignoriert", "url", u)
}
}
a.mu.Lock()
defer a.mu.Unlock()
if slices.Equal(valid, a.id.ServerURLs) {
return
}
updated := a.id
updated.ServerURLs = valid
if err := writeJSON(a.path("node.json"), updated); err != nil {
a.log.Warn("Server-Adressen nicht gespeichert", "err", err)
return
}
a.id = updated
a.log.Info("Server-Adressen übernommen", "server_urls", valid)
}
// rememberServerURL übernimmt eine abweichende, erfolgreich genutzte
// Adresse in node.json (der Pin bleibt unverändert).
func (a *Agent) rememberServerURL() {
a.mu.Lock()
defer a.mu.Unlock()
if a.id.Server == a.serverURL {
return
}
updated := a.id
updated.Server = a.serverURL
if err := writeJSON(a.path("node.json"), updated); err != nil {
a.log.Warn("Neue Server-Adresse nicht gespeichert", "err", err)
return
}
a.id = updated
a.log.Info("Neue Server-Adresse übernommen", "server", a.serverURL)
}
func loadOrCreateKey(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, err
}
}
return auth.ReadPrivateKey(path)
}
// writeJSON schreibt atomar (temporäre Datei + rename) mit Rechten 0600.
func writeJSON(path string, v any) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// loadIdentity lädt Identität und Schlüssel eines registrierten Agenten,
// ohne etwas anzulegen (für Befehle neben dem laufenden Agenten).
func (a *Agent) loadIdentity() error {
data, err := os.ReadFile(a.path("node.json"))
if errors.Is(err, os.ErrNotExist) {
return ErrNotEnrolled
}
if err != nil {
return err
}
if err := json.Unmarshal(data, &a.id); err != nil {
return fmt.Errorf("agent: node.json beschädigt: %w", err)
}
key, err := auth.ReadPrivateKey(a.path("node.key"))
if err != nil {
return err
}
a.setSigner(&auth.Signer{Key: key})
a.serverURL = a.id.Server
if a.cfg.ServerURL != "" {
a.serverURL = a.cfg.ServerURL
}
return nil
}
// SetMaintenance meldet beim Server Wartung für diesen Knoten an (on) oder
// ab. d = 0 bedeutet unbefristet; reason ist intern, publicReason erscheint
// auf der Wartungsseite.
func (a *Agent) SetMaintenance(ctx context.Context, on bool, d time.Duration, reason, publicReason string) error {
if err := a.loadIdentity(); err != nil {
return err
}
req := server.MaintenanceRequest{Enabled: on}
if on {
req.Duration, req.Reason, req.PublicReason = d.String(), reason, publicReason
}
body, _ := json.Marshal(req)
hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.serverURL+"/api/v1/maintenance", bytes.NewReader(body))
if err != nil {
return err
}
hreq.Header.Set("Content-Type", "application/json")
if err := a.currentSigner().Sign(hreq); err != nil {
return err
}
hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(a.id.ServerFingerprint)}}
resp, err := hc.Do(hreq)
if err != nil {
return fmt.Errorf("agent: Wartung melden: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("agent: Server lehnt Wartung ab (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg)))
}
return nil
}
+303
View File
@@ -0,0 +1,303 @@
package agent
import (
"bytes"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"unicode"
"unicode/utf8"
"gopkg.in/yaml.v3"
"sultan/pkg/license"
"sultan/pkg/registry"
)
// ServiceSpec ist ein Dienst aus /etc/sultan.d/*.yaml (Spec 4.1b) – für
// Bare-Metal-/LAMP-Hosts ohne Docker-Labels.
type ServiceSpec struct {
Name string `yaml:"name"`
URL string `yaml:"url"` // lokaler Dienst; sein Port gilt auch für den Ingress
Hosts []string `yaml:"hosts"`
HTTPS bool `yaml:"https"`
CertResolver string `yaml:"certresolver"`
EntryPoints []string `yaml:"entrypoints"` // Default websecure (https) bzw. web
RedirectHTTPToHTTPS bool `yaml:"redirect_http_to_https"`
RedirectWWW string `yaml:"redirect_www"` // none | to_www | to_non_www
Healthcheck *HealthSpec `yaml:"healthcheck"`
Maintenance bool `yaml:"maintenance"` // Dienst in Wartung (Wartungsseite)
MaintenancePublic string `yaml:"maintenance_public_reason"` // optional, ersetzt den Standardtext
Labels map[string]string `yaml:"labels"` // rohe traefik.*-Labels, überschreiben die erzeugten
}
// HealthSpec beschreibt den Healthcheck eines Dienstes.
type HealthSpec struct {
URL string `yaml:"url"` // Default: URL des Dienstes
Interval Duration `yaml:"interval"` // Default 30s
Timeout Duration `yaml:"timeout"` // Default 5s
Failures int `yaml:"failures"` // Fehlschläge in Folge bis "down", Default 2
ExpectStatus int `yaml:"expect_status"`
ExpectTitle string `yaml:"expect_title"`
ExpectContains *ContainsSpec `yaml:"expect_contains"`
}
// ContainsSpec: Ein Element <Tag> muss Text enthalten.
type ContainsSpec struct {
Tag string `yaml:"tag"`
Text string `yaml:"text"`
}
// Duration liest Go-Dauern wie "30s" aus YAML.
type Duration struct{ time.Duration }
func (d *Duration) UnmarshalYAML(n *yaml.Node) error {
var s string
if err := n.Decode(&s); err != nil {
return err
}
v, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("Dauer %q nicht lesbar (z. B. 30s, 1m)", s)
}
d.Duration = v
return nil
}
var serviceNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$`)
// ParseServices liest eine Datei; unbekannte Felder sind Fehler.
func ParseServices(data []byte) ([]ServiceSpec, error) {
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
var f struct {
Services []ServiceSpec `yaml:"services"`
}
if err := dec.Decode(&f); err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
seen := map[string]bool{}
for i := range f.Services {
s := &f.Services[i]
if err := s.normalize(); err != nil {
return nil, fmt.Errorf("Dienst %d (%q): %w", i+1, s.Name, err)
}
if seen[s.Name] {
return nil, fmt.Errorf("Dienst %q doppelt", s.Name)
}
seen[s.Name] = true
}
return f.Services, nil
}
func (s *ServiceSpec) normalize() error {
if !serviceNameRe.MatchString(s.Name) {
return errors.New("name fehlt oder ungültig (a–z, 0–9, - und _)")
}
u, err := url.Parse(s.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
return fmt.Errorf("url %q muss eine http(s)-URL sein", s.URL)
}
if len(s.Hosts) == 0 {
return errors.New("hosts fehlt")
}
for i, h := range s.Hosts {
n, err := license.NormalizeDomain(h)
if err != nil || strings.HasPrefix(n, "*.") {
return fmt.Errorf("Host %q ungültig (keine Wildcards)", h)
}
s.Hosts[i] = n
}
switch s.RedirectWWW {
case "", "none", "to_www", "to_non_www":
default:
return fmt.Errorf("redirect_www %q ungültig (none, to_www, to_non_www)", s.RedirectWWW)
}
if utf8.RuneCountInString(s.MaintenancePublic) > 300 || strings.ContainsFunc(s.MaintenancePublic, unicode.IsControl) {
return errors.New("maintenance_public_reason: einzeilig, höchstens 300 Zeichen")
}
if s.RedirectHTTPToHTTPS && !s.HTTPS {
return errors.New("redirect_http_to_https erfordert https: true")
}
if h := s.Healthcheck; h != nil {
if h.URL == "" {
h.URL = s.URL
}
if h.Interval.Duration <= 0 {
h.Interval.Duration = 30 * time.Second
}
if h.Timeout.Duration <= 0 {
h.Timeout.Duration = 5 * time.Second
}
if h.Failures <= 0 {
h.Failures = 2
}
if h.ExpectStatus == 0 {
h.ExpectStatus = 200
}
if h.ExpectStatus < 100 || h.ExpectStatus > 599 {
return fmt.Errorf("expect_status %d ungültig", h.ExpectStatus)
}
if c := h.ExpectContains; c != nil && (c.Tag == "" || c.Text == "") {
return errors.New("expect_contains braucht tag und text")
}
}
return nil
}
// LoadServiceDir liest alle *.yaml/*.yml eines Verzeichnisses (alphabetisch).
// Fehlerhafte Dateien und doppelte Dienstnamen werden mit Warnung
// übersprungen, damit eine kaputte Datei nicht alle Dienste abschaltet. Ein
// fehlendes Verzeichnis bedeutet: keine Dienste.
func LoadServiceDir(dir string) ([]ServiceSpec, []string) {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, []string{err.Error()}
}
var specs []ServiceSpec
var warnings []string
origin := map[string]string{}
for _, e := range entries {
ext := filepath.Ext(e.Name())
if e.IsDir() || (ext != ".yaml" && ext != ".yml") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
warnings = append(warnings, fmt.Sprintf("%s: %v", e.Name(), err))
continue
}
parsed, err := ParseServices(data)
if err != nil {
warnings = append(warnings, fmt.Sprintf("%s: %v", e.Name(), err))
continue
}
for _, s := range parsed {
if first, dup := origin[s.Name]; dup {
warnings = append(warnings, fmt.Sprintf("%s: Dienst %q schon in %s definiert – übersprungen", e.Name(), s.Name, first))
continue
}
origin[s.Name] = e.Name()
specs = append(specs, s)
}
}
return specs, warnings
}
// FileWorkloads übersetzt Dienste in Workloads mit Traefik-Labels; danach
// gelten dieselben Regeln (FromLabels, Policy) wie für Docker-Container.
func FileWorkloads(specs []ServiceSpec, host string) []registry.Workload {
var out []registry.Workload
for _, s := range specs {
out = append(out, registry.Workload{Name: "sultan.d/" + s.Name, Host: host, Labels: s.labels()})
}
return out
}
func (s ServiceSpec) labels() map[string]string {
u, _ := url.Parse(s.URL)
port := u.Port()
if port == "" {
port = map[string]string{"http": "80", "https": "443"}[u.Scheme]
}
scheme := "http"
if s.HTTPS {
scheme = "https"
}
hosts := slices.Clone(s.Hosts)
var bases []string
if s.RedirectWWW == "to_www" || s.RedirectWWW == "to_non_www" {
for _, h := range s.Hosts {
if b := strings.TrimPrefix(h, "www."); !slices.Contains(bases, b) {
bases = append(bases, b)
}
}
// Beide Varianten müssen beim Router ankommen, sonst greift die
// Umleitung nie.
for _, b := range bases {
for _, h := range []string{b, "www." + b} {
if !slices.Contains(hosts, h) {
hosts = append(hosts, h)
}
}
}
}
var rules []string
for _, h := range hosts {
rules = append(rules, "Host(`"+h+"`)")
}
rule := strings.Join(rules, " || ")
entryPoints := s.EntryPoints
if len(entryPoints) == 0 {
entryPoints = []string{"web"}
if s.HTTPS {
entryPoints = []string{"websecure"}
}
}
n := s.Name
router := "traefik.http.routers." + n + "."
l := map[string]string{
"traefik.enable": "true",
router + "rule": rule,
router + "entrypoints": strings.Join(entryPoints, ","),
router + "service": n,
"traefik.http.services." + n + ".loadbalancer.server.port": port,
}
if u.Scheme == "https" {
l["traefik.http.services."+n+".loadbalancer.server.scheme"] = "https"
}
if s.HTTPS {
l[router+"tls"] = "true"
if s.CertResolver != "" {
l[router+"tls.certresolver"] = s.CertResolver
}
}
if len(bases) > 0 {
alts := make([]string, len(bases))
for i, b := range bases {
alts[i] = regexp.QuoteMeta(b)
}
mw := "traefik.http.middlewares." + n + "-www.redirectregex."
if s.RedirectWWW == "to_non_www" {
l[mw+"regex"] = `^https?://www\.(` + strings.Join(alts, "|") + `)(.*)`
l[mw+"replacement"] = scheme + "://${1}${2}"
} else {
l[mw+"regex"] = `^https?://(` + strings.Join(alts, "|") + `)(.*)`
l[mw+"replacement"] = scheme + "://www.${1}${2}"
}
l[mw+"permanent"] = "true"
l[router+"middlewares"] = n + "-www"
}
if s.RedirectHTTPToHTTPS {
httpRouter := "traefik.http.routers." + n + "-http."
l[httpRouter+"rule"] = rule
l[httpRouter+"entrypoints"] = "web"
l[httpRouter+"service"] = n
l[httpRouter+"middlewares"] = n + "-https"
l["traefik.http.middlewares."+n+"-https.redirectscheme.scheme"] = "https"
l["traefik.http.middlewares."+n+"-https.redirectscheme.permanent"] = "true"
}
if s.Maintenance {
l[registry.LabelMaintenance] = "true"
if s.MaintenancePublic != "" {
l[registry.LabelMaintenancePublicReason] = s.MaintenancePublic
}
}
for k, v := range s.Labels {
l[k] = v
}
return l
}
+236
View File
@@ -0,0 +1,236 @@
package agent
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"sultan/pkg/traefik"
)
func parse(t *testing.T, yaml string) []ServiceSpec {
t.Helper()
specs, err := ParseServices([]byte(yaml))
if err != nil {
t.Fatalf("ParseServices: %v", err)
}
return specs
}
func TestFileWorkloads_Minimal(t *testing.T) {
specs := parse(t, `
services:
- name: shop
url: http://127.0.0.1:8080
hosts: [shop.kunde.de]
`)
ws := FileWorkloads(specs, "100.64.0.42")
want := map[string]string{
"traefik.enable": "true",
"traefik.http.routers.shop.rule": "Host(`shop.kunde.de`)",
"traefik.http.routers.shop.entrypoints": "web",
"traefik.http.routers.shop.service": "shop",
"traefik.http.services.shop.loadbalancer.server.port": "8080",
}
if len(ws) != 1 || ws[0].Name != "sultan.d/shop" || ws[0].Host != "100.64.0.42" || !reflect.DeepEqual(ws[0].Labels, want) {
t.Errorf("Workloads = %+v", ws)
}
}
func TestFileWorkloads_ComfortSwitches(t *testing.T) {
specs := parse(t, `
services:
- name: kunde-a
url: https://127.0.0.1:8443/
hosts: [Kunde-A.de]
https: true
certresolver: dns-resolver
redirect_http_to_https: true
redirect_www: to_non_www
labels:
traefik.http.middlewares.kunde-a-www.redirectregex.permanent: "false"
`)
rule := "Host(`kunde-a.de`) || Host(`www.kunde-a.de`)"
want := map[string]string{
"traefik.enable": "true",
"traefik.http.routers.kunde-a.rule": rule,
"traefik.http.routers.kunde-a.entrypoints": "websecure",
"traefik.http.routers.kunde-a.service": "kunde-a",
"traefik.http.routers.kunde-a.tls": "true",
"traefik.http.routers.kunde-a.tls.certresolver": "dns-resolver",
"traefik.http.routers.kunde-a.middlewares": "kunde-a-www",
"traefik.http.services.kunde-a.loadbalancer.server.port": "8443",
"traefik.http.services.kunde-a.loadbalancer.server.scheme": "https",
"traefik.http.routers.kunde-a-http.rule": rule,
"traefik.http.routers.kunde-a-http.entrypoints": "web",
"traefik.http.routers.kunde-a-http.service": "kunde-a",
"traefik.http.routers.kunde-a-http.middlewares": "kunde-a-https",
"traefik.http.middlewares.kunde-a-https.redirectscheme.scheme": "https",
"traefik.http.middlewares.kunde-a-https.redirectscheme.permanent": "true",
"traefik.http.middlewares.kunde-a-www.redirectregex.regex": `^https?://www\.(kunde-a\.de)(.*)`,
"traefik.http.middlewares.kunde-a-www.redirectregex.replacement": "https://${1}${2}",
"traefik.http.middlewares.kunde-a-www.redirectregex.permanent": "false", // rohes Label gewinnt
}
got := FileWorkloads(specs, "h")[0].Labels
if !reflect.DeepEqual(got, want) {
for k, v := range want {
if got[k] != v {
t.Errorf("%s = %q, erwartet %q", k, got[k], v)
}
}
for k := range got {
if _, ok := want[k]; !ok {
t.Errorf("unerwartetes Label %s = %q", k, got[k])
}
}
}
}
func TestFileWorkloads_RedirectToWWW(t *testing.T) {
specs := parse(t, `
services:
- name: web
url: http://127.0.0.1
hosts: [www.shop.de, kunde.de]
redirect_www: to_www
`)
l := FileWorkloads(specs, "h")[0].Labels
if l["traefik.http.routers.web.rule"] != "Host(`www.shop.de`) || Host(`kunde.de`) || Host(`shop.de`) || Host(`www.kunde.de`)" {
t.Errorf("Regel = %s", l["traefik.http.routers.web.rule"])
}
if l["traefik.http.middlewares.web-www.redirectregex.regex"] != `^https?://(shop\.de|kunde\.de)(.*)` ||
l["traefik.http.middlewares.web-www.redirectregex.replacement"] != "http://www.${1}${2}" {
t.Errorf("Middleware: %v", l)
}
if l["traefik.http.services.web.loadbalancer.server.port"] != "80" {
t.Errorf("Default-Port: %s", l["traefik.http.services.web.loadbalancer.server.port"])
}
}
// Die erzeugten Labels müssen dieselbe Übersetzung bestehen wie Docker-Labels.
func TestFileWorkloads_LabelsAreValidTraefik(t *testing.T) {
specs := parse(t, `
services:
- name: kunde-a
url: https://127.0.0.1:8443/
hosts: [kunde-a.de]
https: true
redirect_http_to_https: true
redirect_www: to_non_www
`)
w := FileWorkloads(specs, "100.64.0.42")[0]
cfg, err := traefik.FromLabels(w.Host, w.Labels)
if err != nil {
t.Fatalf("FromLabels: %v", err)
}
if len(cfg.HTTP.Routers) != 2 || cfg.HTTP.Services["kunde-a"].LoadBalancer.Servers[0].URL != "https://100.64.0.42:8443" {
t.Errorf("Konfiguration: %+v", cfg.HTTP)
}
}
func TestParseServices_Healthcheck(t *testing.T) {
specs := parse(t, `
services:
- name: shop
url: http://127.0.0.1:8080/start
hosts: [shop.kunde.de]
healthcheck:
interval: 10s
expect_title: "Shop - Willkommen"
expect_contains: { tag: h2, text: Impressum }
- name: blog
url: http://127.0.0.1:8081
hosts: [blog.kunde.de]
healthcheck: {}
`)
h := specs[0].Healthcheck
if h == nil || h.Interval.Duration != 10*time.Second || h.Timeout.Duration != 5*time.Second || h.Failures != 2 ||
h.ExpectStatus != 200 || h.URL != "http://127.0.0.1:8080/start" || h.ExpectTitle != "Shop - Willkommen" ||
h.ExpectContains == nil || h.ExpectContains.Tag != "h2" || h.ExpectContains.Text != "Impressum" {
t.Errorf("Healthcheck: %+v", h)
}
if d := specs[1].Healthcheck; d == nil || d.Interval.Duration != 30*time.Second || d.ExpectStatus != 200 {
t.Errorf("Healthcheck-Defaults: %+v", d)
}
}
func TestParseServices_Errors(t *testing.T) {
tests := map[string]string{
"kein YAML": "services: [",
"unbekanntes Feld": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n farbe: blau\n",
"Name fehlt": "services:\n - url: http://x\n hosts: [a.de]\n",
"Name ungültig": "services:\n - name: a.b\n url: http://x\n hosts: [a.de]\n",
"URL fehlt": "services:\n - name: a\n hosts: [a.de]\n",
"URL kein http": "services:\n - name: a\n url: ftp://x\n hosts: [a.de]\n",
"Hosts fehlen": "services:\n - name: a\n url: http://x\n",
"Host mit Wildcard": "services:\n - name: a\n url: http://x\n hosts: ['*.a.de']\n",
"redirect_www ungültig": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n redirect_www: manchmal\n",
"Redirect ohne https": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n redirect_http_to_https: true\n",
"Intervall unlesbar": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n healthcheck: { interval: oft }\n",
"Status ungültig": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n healthcheck: { expect_status: 42 }\n",
"contains ohne Tag": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n healthcheck: { expect_contains: { text: x } }\n",
"doppelter Name": "services:\n - name: a\n url: http://x\n hosts: [a.de]\n - name: a\n url: http://y\n hosts: [b.de]\n",
}
for name, yaml := range tests {
if _, err := ParseServices([]byte(yaml)); err == nil {
t.Errorf("%s: erwartet Fehler", name)
}
}
}
func TestLoadServiceDir(t *testing.T) {
dir := t.TempDir()
write := func(name, content string) {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
write("b.yaml", "services:\n - name: shop\n url: http://127.0.0.1:8080\n hosts: [shop.kunde.de]\n")
write("a.yml", "services:\n - name: blog\n url: http://127.0.0.1:8081\n hosts: [blog.kunde.de]\n")
write("c.yaml", "services: [") // kaputt
write("d.yaml", "services:\n - name: shop\n url: http://127.0.0.1:9\n hosts: [x.kunde.de]\n") // doppelt
write("notizen.txt", "services:\n - name: txt\n url: http://127.0.0.1:1\n hosts: [t.kunde.de]\n") // ignoriert
specs, warnings := LoadServiceDir(dir)
var names []string
for _, s := range specs {
names = append(names, s.Name)
}
if !reflect.DeepEqual(names, []string{"blog", "shop"}) {
t.Errorf("Dienste = %v (Dateien alphabetisch, Duplikat und .txt ignoriert)", names)
}
if len(warnings) != 2 || !strings.Contains(strings.Join(warnings, "\n"), "c.yaml") || !strings.Contains(strings.Join(warnings, "\n"), "d.yaml") {
t.Errorf("Warnungen = %v", warnings)
}
if specs, warnings := LoadServiceDir(filepath.Join(dir, "fehlt")); len(specs) != 0 || len(warnings) != 0 {
t.Errorf("fehlendes Verzeichnis: %v %v", specs, warnings)
}
}
func TestFileWorkloads_Maintenance(t *testing.T) {
specs := parse(t, `
services:
- name: shop
url: http://127.0.0.1:8080
hosts: [shop.kunde.de]
maintenance: true
maintenance_public_reason: "Inventur bis Montag"
- name: blog
url: http://127.0.0.1:8081
hosts: [blog.kunde.de]
`)
ws := FileWorkloads(specs, "h")
if ws[0].Labels["sultan.maintenance"] != "true" || ws[0].Labels["sultan.maintenance.public_reason"] != "Inventur bis Montag" {
t.Errorf("Wartungs-Labels fehlen: %v", ws[0].Labels)
}
if _, ok := ws[1].Labels["sultan.maintenance"]; ok {
t.Error("Dienst ohne Wartung hat Wartungs-Label")
}
if _, err := ParseServices([]byte("services:\n - name: a\n url: http://x\n hosts: [a.de]\n maintenance_public_reason: \"a\\nb\"\n")); err == nil {
t.Error("mehrzeiliger Grund akzeptiert")
}
}
+147
View File
@@ -0,0 +1,147 @@
package agent
import (
"fmt"
"maps"
"net"
"regexp"
"slices"
"strconv"
"strings"
"sultan/pkg/docker"
"sultan/pkg/registry"
)
const servicesPrefix = "traefik.http.services."
var unsafeName = regexp.MustCompile(`[^A-Za-z0-9_-]`)
// Workloads übersetzt laufende Container in Workloads für service_state.
//
// Berücksichtigt werden nur Container mit traefik.enable=true. Weil der
// Ingress auf einem anderen Host sitzt, wird loadbalancer.server.port (Port
// im Container) auf den veröffentlichten Host-Port umgerechnet. Fehlt das
// Port-Label, gilt wie bei Traefiks Docker-Provider der einzige
// veröffentlichte Port; ohne Service-Labels entsteht ein Service mit dem
// Containernamen. Container, deren Ports von außen nicht erreichbar sind
// (nicht veröffentlicht, nur Loopback, nur UDP), werden mit Warnung
// weggelassen.
func Workloads(containers []docker.Container, host string) ([]registry.Workload, []string) {
var out []registry.Workload
var warnings []string
for _, c := range containers {
if !enabled(c.Labels) {
continue
}
labels, warning := translatePorts(c)
if warning != "" {
warnings = append(warnings, warning)
continue
}
out = append(out, registry.Workload{Name: c.Name(), Host: host, Labels: labels})
}
slices.SortFunc(out, func(a, b registry.Workload) int { return strings.Compare(a.Name, b.Name) })
return out, warnings
}
func enabled(labels map[string]string) bool {
for k, v := range labels {
if strings.EqualFold(k, "traefik.enable") {
on, err := strconv.ParseBool(strings.TrimSpace(v))
return err == nil && on
}
}
return false
}
// reachablePorts: Container-Port → von außen erreichbarer Host-Port (TCP).
func reachablePorts(ports []docker.Port) map[uint16]uint16 {
out := map[uint16]uint16{}
for _, p := range ports {
if p.Type != "tcp" || p.PublicPort == 0 {
continue
}
if ip := net.ParseIP(p.IP); ip != nil && ip.IsLoopback() {
continue
}
out[p.PrivatePort] = p.PublicPort
}
return out
}
type serviceLabels struct {
name string // Schreibweise aus dem Label
portKey string // Original-Schlüssel des Port-Labels, "" wenn keins
}
func translatePorts(c docker.Container) (map[string]string, string) {
labels := maps.Clone(c.Labels)
reachable := reachablePorts(c.Ports)
single := func() (uint16, bool) {
for _, pub := range reachable {
return pub, len(reachable) == 1
}
return 0, false
}
services := map[string]*serviceLabels{}
hasRouters := false
for k := range labels {
lk := strings.ToLower(k)
if strings.HasPrefix(lk, "traefik.http.routers.") {
hasRouters = true
}
rest, ok := strings.CutPrefix(lk, servicesPrefix)
if !ok {
continue
}
svc, attr, ok := strings.Cut(rest, ".")
if !ok {
continue
}
s := services[svc]
if s == nil {
name, _, _ := strings.Cut(k[len(servicesPrefix):], ".")
s = &serviceLabels{name: name}
services[svc] = s
}
if attr == "loadbalancer.server.port" {
s.portKey = k
}
}
if len(services) == 0 {
if !hasRouters {
return labels, ""
}
pub, ok := single()
if !ok {
return nil, fmt.Sprintf("Container %s: %d erreichbare Ports – Label loadbalancer.server.port angeben", c.Name(), len(reachable))
}
svc := unsafeName.ReplaceAllString(c.Name(), "-")
labels[servicesPrefix+svc+".loadbalancer.server.port"] = strconv.Itoa(int(pub))
return labels, ""
}
for _, s := range services {
if s.portKey == "" {
pub, ok := single()
if !ok {
return nil, fmt.Sprintf("Container %s, Service %s: %d erreichbare Ports – Label loadbalancer.server.port angeben", c.Name(), s.name, len(reachable))
}
labels[servicesPrefix+s.name+".loadbalancer.server.port"] = strconv.Itoa(int(pub))
continue
}
private, err := strconv.Atoi(strings.TrimSpace(labels[s.portKey]))
if err != nil {
return nil, fmt.Sprintf("Container %s: Port %q nicht lesbar", c.Name(), labels[s.portKey])
}
pub, ok := reachable[uint16(private)]
if !ok {
return nil, fmt.Sprintf("Container %s: Port %d ist von außen nicht erreichbar (nicht veröffentlicht oder nur 127.0.0.1)", c.Name(), private)
}
labels[s.portKey] = strconv.Itoa(int(pub))
}
return labels, ""
}
+150
View File
@@ -0,0 +1,150 @@
package agent
import (
"reflect"
"strings"
"testing"
"sultan/pkg/docker"
"sultan/pkg/registry"
)
func ctr(name string, labels map[string]string, ports ...docker.Port) docker.Container {
return docker.Container{ID: name + "-id", Names: []string{"/" + name}, State: "running", Labels: labels, Ports: ports}
}
func pub(private, public uint16) docker.Port {
return docker.Port{IP: "0.0.0.0", PrivatePort: private, PublicPort: public, Type: "tcp"}
}
func wpLabels() map[string]string {
return map[string]string{
"traefik.enable": "true",
"traefik.http.routers.wp.rule": "Host(`kunde.de`)",
"traefik.http.services.wp.loadbalancer.server.port": "80",
}
}
func TestWorkloads_TranslatesPublishedPort(t *testing.T) {
in := ctr("wordpress", wpLabels(), pub(80, 8081), pub(443, 8443))
got, warnings := Workloads([]docker.Container{in}, "100.64.0.42")
if len(warnings) != 0 {
t.Errorf("Warnungen: %v", warnings)
}
want := []registry.Workload{{
Name: "wordpress", Host: "100.64.0.42",
Labels: map[string]string{
"traefik.enable": "true",
"traefik.http.routers.wp.rule": "Host(`kunde.de`)",
"traefik.http.services.wp.loadbalancer.server.port": "8081",
},
}}
if !reflect.DeepEqual(got, want) {
t.Errorf("Workloads =\n%+v\nerwartet\n%+v", got, want)
}
if in.Labels["traefik.http.services.wp.loadbalancer.server.port"] != "80" {
t.Error("Eingabe-Labels wurden verändert")
}
}
func TestWorkloads_SkipsDisabled(t *testing.T) {
off := wpLabels()
off["traefik.enable"] = "false"
none := wpLabels()
delete(none, "traefik.enable")
got, warnings := Workloads([]docker.Container{
ctr("aus", off, pub(80, 8081)),
ctr("ohne", none, pub(80, 8082)),
ctr("db", map[string]string{}, pub(5432, 5432)),
}, "h")
if len(got) != 0 || len(warnings) != 0 {
t.Errorf("got=%v warnings=%v", got, warnings)
}
}
func TestWorkloads_UnreachablePorts(t *testing.T) {
tests := map[string][]docker.Port{
"nicht veröffentlicht": {{PrivatePort: 80, Type: "tcp"}},
"nur IPv4-Loopback": {{IP: "127.0.0.1", PrivatePort: 80, PublicPort: 8081, Type: "tcp"}},
"nur IPv6-Loopback": {{IP: "::1", PrivatePort: 80, PublicPort: 8081, Type: "tcp"}},
"nur UDP": {{IP: "0.0.0.0", PrivatePort: 80, PublicPort: 8081, Type: "udp"}},
"anderer Port": {pub(8080, 8081)},
}
for name, ports := range tests {
got, warnings := Workloads([]docker.Container{ctr("wordpress", wpLabels(), ports...)}, "h")
if len(got) != 0 {
t.Errorf("%s: Workload trotz unerreichbarem Port: %+v", name, got)
}
if len(warnings) != 1 || !strings.Contains(warnings[0], "wordpress") || !strings.Contains(warnings[0], "80") {
t.Errorf("%s: Warnungen = %v", name, warnings)
}
}
}
func TestWorkloads_IPv6AnyIsReachable(t *testing.T) {
got, _ := Workloads([]docker.Container{ctr("wordpress", wpLabels(),
docker.Port{IP: "::", PrivatePort: 80, PublicPort: 8081, Type: "tcp"})}, "h")
if len(got) != 1 || got[0].Labels["traefik.http.services.wp.loadbalancer.server.port"] != "8081" {
t.Errorf("got = %+v", got)
}
}
// Wie Traefiks Docker-Provider: ohne Service-Labels gibt es einen Service
// mit dem Containernamen und dem einzigen veröffentlichten Port.
func TestWorkloads_DefaultService(t *testing.T) {
labels := map[string]string{"traefik.enable": "true", "traefik.http.routers.app.rule": "Host(`app.kunde.de`)"}
got, warnings := Workloads([]docker.Container{ctr("my.app", labels, pub(3000, 13000))}, "h")
if len(warnings) != 0 || len(got) != 1 {
t.Fatalf("got=%+v warnings=%v", got, warnings)
}
if got[0].Labels["traefik.http.services.my-app.loadbalancer.server.port"] != "13000" {
t.Errorf("Default-Service fehlt: %v", got[0].Labels)
}
_, warnings = Workloads([]docker.Container{ctr("multi", labels, pub(3000, 13000), pub(3001, 13001))}, "h")
if len(warnings) != 1 || !strings.Contains(warnings[0], "multi") {
t.Errorf("mehrere Ports ohne Port-Label: Warnungen = %v", warnings)
}
}
func TestWorkloads_ServiceWithoutPortLabel(t *testing.T) {
labels := map[string]string{
"traefik.enable": "true",
"traefik.http.routers.wp.rule": "Host(`kunde.de`)",
"traefik.http.services.wp.loadbalancer.passhostheader": "true",
}
got, warnings := Workloads([]docker.Container{ctr("wordpress", labels, pub(80, 8081))}, "h")
if len(warnings) != 0 || len(got) != 1 || got[0].Labels["traefik.http.services.wp.loadbalancer.server.port"] != "8081" {
t.Errorf("got=%+v warnings=%v", got, warnings)
}
}
func TestWorkloads_MultipleServicesAndCaseInsensitiveKeys(t *testing.T) {
labels := map[string]string{
"Traefik.Enable": "TRUE",
"traefik.http.routers.web.rule": "Host(`kunde.de`)",
"traefik.http.routers.web.service": "web",
"traefik.http.routers.api.rule": "Host(`api.kunde.de`)",
"traefik.http.routers.api.service": "api",
"traefik.http.services.web.loadbalancer.server.port": "80",
"Traefik.HTTP.Services.Api.LoadBalancer.Server.Port": "9000",
}
got, warnings := Workloads([]docker.Container{ctr("app", labels, pub(80, 8080), pub(9000, 19000))}, "h")
if len(warnings) != 0 || len(got) != 1 {
t.Fatalf("got=%+v warnings=%v", got, warnings)
}
l := got[0].Labels
if l["traefik.http.services.web.loadbalancer.server.port"] != "8080" || l["Traefik.HTTP.Services.Api.LoadBalancer.Server.Port"] != "19000" {
t.Errorf("Labels = %v", l)
}
}
func TestWorkloads_SortedByName(t *testing.T) {
got, _ := Workloads([]docker.Container{
ctr("b", wpLabels(), pub(80, 8082)),
ctr("a", wpLabels(), pub(80, 8081)),
}, "h")
if len(got) != 2 || got[0].Name != "a" || got[1].Name != "b" {
t.Errorf("Reihenfolge: %+v", got)
}
}
+6 -10
View File
@@ -212,7 +212,7 @@ func TestIntegration_TokenWithPinnedTLS(t *testing.T) {
srv, sc, _ := pinnedServer(t, db, hub)
// Server-Seite (sultan token): Token erzeugen, nur das Secret speichern.
tok, err := crypto.NewEnrollmentToken(sc.Fingerprint)
tok, err := crypto.NewEnrollmentToken(sc.Fingerprint, "")
if err != nil {
t.Fatal(err)
}
@@ -225,9 +225,7 @@ func TestIntegration_TokenWithPinnedTLS(t *testing.T) {
if err != nil {
t.Fatal(err)
}
addr := srv.Listener.Addr().String()
ks := &crypto.KnownServers{Path: filepath.Join(t.TempDir(), "known_servers")}
firstContact := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.ClientTLSConfig(addr, ks, parsed.Fingerprint)}}
firstContact := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(parsed.Fingerprint)}}
priv, _ := GenerateKey()
signer := &Signer{Key: priv}
@@ -240,8 +238,8 @@ func TestIntegration_TokenWithPinnedTLS(t *testing.T) {
t.Fatalf("Enrollment: Status %d", resp.StatusCode)
}
// Ab jetzt nur noch über den Pin, ohne Token-Fingerprint.
pinned := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.ClientTLSConfig(addr, ks, "")}}
// Ab jetzt über den gespeicherten Pin (beim Agent: node.json).
pinned := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(parsed.Fingerprint)}}
client := &stream.Client{URL: srv.URL + "/api/v1/stream", HTTPClient: pinned, Prepare: signer.Sign}
got := make(chan stream.Event, 1)
runCtx, cancel := context.WithCancel(ctx)
@@ -275,12 +273,10 @@ func TestIntegration_MITMNeverSeesToken(t *testing.T) {
_, realCert, _ := pinnedServer(t, db, hub)
mitm, _, mitmHits := pinnedServer(t, homelabStore(t), &stream.Hub{})
tok, _ := crypto.NewEnrollmentToken(realCert.Fingerprint)
tok, _ := crypto.NewEnrollmentToken(realCert.Fingerprint, "")
db.AddEnrollmentToken(ctx, tok.Secret, "homelab", time.Now().Add(time.Hour))
ks := &crypto.KnownServers{Path: filepath.Join(t.TempDir(), "known_servers")}
addr := mitm.Listener.Addr().String()
hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.ClientTLSConfig(addr, ks, tok.Fingerprint)}}
hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(tok.Fingerprint)}}
priv, _ := GenerateKey()
_, err := enroll(&Signer{Key: priv}, hc, mitm.URL, tok.Secret)
+21
View File
@@ -104,3 +104,24 @@ func DecodePublicKey(s string) (ed25519.PublicKey, error) {
}
return ed25519.PublicKey(b), nil
}
// rotationVersion trennt Rotationsnachweise von anderen Signaturen.
const rotationVersion = "SULTAN-ROTATE-V1\n"
func rotationMessage(nodeID string, pub ed25519.PublicKey) []byte {
return []byte(rotationVersion + nodeID + "\n" + EncodePublicKey(pub))
}
// RotationProof beweist beim Schlüsselwechsel (rotate_key) den Besitz des
// neuen Schlüssels; der Nachweis ist an die Knoten-ID gebunden.
func RotationProof(nodeID string, newKey ed25519.PrivateKey) string {
pub := newKey.Public().(ed25519.PublicKey)
return base64.StdEncoding.EncodeToString(ed25519.Sign(newKey, rotationMessage(nodeID, pub)))
}
// VerifyRotationProof prüft einen RotationProof.
func VerifyRotationProof(nodeID string, pub ed25519.PublicKey, proof string) bool {
sig, err := base64.StdEncoding.DecodeString(proof)
return err == nil && len(sig) == ed25519.SignatureSize && len(pub) == ed25519.PublicKeySize &&
ed25519.Verify(pub, rotationMessage(nodeID, pub), sig)
}
+200
View File
@@ -0,0 +1,200 @@
// Package checkmk erzeugt die Ausgabe für Checkmk im Local-Check-Format
// (Spec 6.1): `<status> "<service>" <metriken> <text>` mit 0 OK, 1 WARN,
// 2 CRIT. Der Text ist ASCII (Umlaute umschrieben), einzeilig.
package checkmk
import (
"fmt"
"math"
"slices"
"strings"
"time"
)
// Schwellen (Spec 6.2 / Entscheidungen 2026-10-02).
const (
ContractWarnDays = 30
ContractCritDays = 14
OfflineCritAfter = 15 * time.Minute
)
const (
statusOK = 0
statusW = 1
statusCrit = 2
)
// Tenant ist ein Mandant aus Sicht des Monitorings.
type Tenant struct {
ID string
Commercial bool // Community: keine Vertragsfristen, kein Mahnwesen
Status string
ValidUntil time.Time
DunningLevel int
OverdueSince time.Time
}
// Node ist ein Knoten aus Sicht des Monitorings.
type Node struct {
ID string
Status string // online, offline, drained
OfflineSince time.Time
MaintenanceActive bool
MaintenanceUntil time.Time // Nullwert = unbefristet
MaintenanceReason string // intern – Monitoring ist intern
LeaseUntil time.Time // Nullwert = keine Lease
}
// State ist alles, was ausgegeben wird.
type State struct {
Tenants []Tenant
Nodes []Node
Violations24h int
// OfflineCrit: ab wann ein getrennter Knoten CRIT ist (Default 15 min).
OfflineCrit time.Duration
// Location: Zeitzone für angezeigte Zeitpunkte (Default UTC).
Location *time.Location
}
var label = map[int]string{statusOK: "OK", statusW: "WARN", statusCrit: "CRIT"}
const day = 24 * time.Hour
// Render liefert die Ausgabe, sortiert nach Mandant bzw. Knoten.
func Render(s State, now time.Time) string {
var b strings.Builder
line := func(status int, service, metrics, text string) {
fmt.Fprintf(&b, "%d %q %s %s - %s\n", status, service, metrics, label[status], ascii(text))
}
tenants := slices.Clone(s.Tenants)
slices.SortFunc(tenants, func(a, b Tenant) int { return strings.Compare(a.ID, b.ID) })
for _, t := range tenants {
if !t.Commercial {
continue
}
renderContract(line, t, now)
renderPayment(line, t, now)
}
nodes := slices.Clone(s.Nodes)
slices.SortFunc(nodes, func(a, b Node) int { return strings.Compare(a.ID, b.ID) })
crit := s.OfflineCrit
if crit <= 0 {
crit = OfflineCritAfter
}
loc := s.Location
if loc == nil {
loc = time.UTC
}
for _, n := range nodes {
renderNode(line, n, now, crit, loc)
}
if s.Violations24h == 0 {
line(statusOK, "Sultan_Policy_Violations", "violations_24h=0;1", "keine Policy-Verstoesse in 24 h")
} else {
line(statusW, "Sultan_Policy_Violations", fmt.Sprintf("violations_24h=%d;1", s.Violations24h),
fmt.Sprintf("%d Policy-Verstoesse in 24 h (siehe Server-Log)", s.Violations24h))
}
return b.String()
}
func renderContract(line func(int, string, string, string), t Tenant, now time.Time) {
svc := "Sultan_Tenant_" + t.ID
days := int(math.Floor(t.ValidUntil.Sub(now).Hours() / 24))
metrics := func(d int) string {
return fmt.Sprintf("days_left=%d;%d;%d", max(d, 0), ContractWarnDays, ContractCritDays)
}
switch {
case t.Status == "expired" || !now.Before(t.ValidUntil):
since := int(math.Ceil(now.Sub(t.ValidUntil).Hours() / 24))
line(statusCrit, svc, metrics(0), fmt.Sprintf("Abo seit %d Tagen abgelaufen! Status: %s", since, t.Status))
case t.Status == "quarantined":
line(statusCrit, svc, metrics(days), "Mandant in Quarantaene")
case days <= ContractCritDays:
line(statusCrit, svc, metrics(days), fmt.Sprintf("Abo laeuft in %d Tagen ab! Verlaengerung erforderlich.", days))
case days <= ContractWarnDays:
line(statusW, svc, metrics(days), fmt.Sprintf("Abo laeuft in %d Tagen ab! Verlaengerung erforderlich.", days))
default:
line(statusOK, svc, metrics(days), fmt.Sprintf("Abo aktiv bis %s (%d Tage verbleibend)", t.ValidUntil.UTC().Format(time.DateOnly), days))
}
}
func renderPayment(line func(int, string, string, string), t Tenant, now time.Time) {
svc := "Sultan_Payment_" + t.ID
metrics := fmt.Sprintf("dunning_level=%d;1;2", t.DunningLevel)
if t.DunningLevel == 0 {
line(statusOK, svc, metrics, "keine offene Zahlung")
return
}
text := fmt.Sprintf("Mahnstufe %d (Zahlung seit %d Tagen offen)", t.DunningLevel, int(now.Sub(t.OverdueSince)/day))
if t.DunningLevel >= 3 {
text += " - Kunde offline"
}
status := statusW
if t.DunningLevel >= 2 {
status = statusCrit
}
line(status, svc, metrics, text)
}
func renderNode(line func(int, string, string, string), n Node, now time.Time, offlineCrit time.Duration, loc *time.Location) {
svc := "Sultan_Node_" + n.ID
leaseHours := 0
if n.LeaseUntil.After(now) {
leaseHours = int(n.LeaseUntil.Sub(now) / time.Hour)
}
metrics := fmt.Sprintf("lease_hours_left=%d", leaseHours)
switch {
case n.Status == "drained":
line(statusOK, svc, metrics, "drained")
case n.MaintenanceActive:
text := "Wartung unbefristet"
if !n.MaintenanceUntil.IsZero() {
text = "Wartung bis " + n.MaintenanceUntil.In(loc).Format("2006-01-02 15:04 MST")
}
if n.MaintenanceReason != "" {
text += " (" + n.MaintenanceReason + ")"
}
line(statusW, svc, metrics, text)
case n.Status == "offline":
since := now.Sub(n.OfflineSince)
status := statusW
if since >= offlineCrit {
status = statusCrit
}
line(status, svc, metrics, "offline seit "+shortDuration(since))
default:
line(statusOK, svc, metrics, "online")
}
}
// shortDuration: unter einer Minute in Sekunden, sonst in Minuten.
func shortDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%d s", int(d/time.Second))
}
return fmt.Sprintf("%d min", int(d/time.Minute))
}
var umlauts = strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "Ä", "Ae", "Ö", "Oe", "Ü", "Ue", "ß", "ss")
// ascii macht Text Checkmk-tauglich: einzeilig, Umlaute umschrieben,
// übrige Nicht-ASCII-Zeichen ersetzt.
func ascii(s string) string {
s = umlauts.Replace(s)
var b strings.Builder
for _, r := range s {
switch {
case r == '\n' || r == '\r' || r == '\t':
b.WriteByte(' ')
case r < 32 || r == 127:
case r > 127:
b.WriteByte('?')
default:
b.WriteRune(r)
}
}
return b.String()
}
+153
View File
@@ -0,0 +1,153 @@
package checkmk
import (
"strings"
"testing"
"time"
)
var now = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
func lines(out string) map[string]string {
m := map[string]string{}
for _, l := range strings.Split(strings.TrimSpace(out), "\n") {
start := strings.Index(l, `"`)
end := strings.Index(l[start+1:], `"`)
m[l[start+1:start+1+end]] = l
}
return m
}
func TestTenants(t *testing.T) {
out := Render(State{Tenants: []Tenant{
{ID: "kunde_schmidt", Commercial: true, Status: "active", ValidUntil: now.AddDate(0, 0, 74)},
{ID: "meier_gmbh", Commercial: true, Status: "active", ValidUntil: now.AddDate(0, 0, 25)},
{ID: "bald", Commercial: true, Status: "active", ValidUntil: now.AddDate(0, 0, 12)},
{ID: "alt_ag", Commercial: true, Status: "expired", ValidUntil: now.AddDate(0, 0, -1)},
{ID: "homelab", Commercial: false, Status: "active", ValidUntil: now.AddDate(0, 0, -100)},
}}, now)
l := lines(out)
want := map[string]string{
"Sultan_Tenant_kunde_schmidt": `0 "Sultan_Tenant_kunde_schmidt" days_left=74;30;14 OK - Abo aktiv bis 2026-12-15 (74 Tage verbleibend)`,
"Sultan_Tenant_meier_gmbh": `1 "Sultan_Tenant_meier_gmbh" days_left=25;30;14 WARN - Abo laeuft in 25 Tagen ab! Verlaengerung erforderlich.`,
"Sultan_Tenant_bald": `2 "Sultan_Tenant_bald" days_left=12;30;14 CRIT - Abo laeuft in 12 Tagen ab! Verlaengerung erforderlich.`,
"Sultan_Tenant_alt_ag": `2 "Sultan_Tenant_alt_ag" days_left=0;30;14 CRIT - Abo seit 1 Tagen abgelaufen! Status: expired`,
}
for svc, w := range want {
if l[svc] != w {
t.Errorf("%s:\n got: %s\nwant: %s", svc, l[svc], w)
}
}
if _, ok := l["Sultan_Tenant_homelab"]; ok {
t.Error("Community-Mandant mit Vertragsfrist (5c: keine)")
}
}
func TestPayment(t *testing.T) {
out := Render(State{Tenants: []Tenant{
{ID: "a", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 0},
{ID: "b", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 1, OverdueSince: now.AddDate(0, 0, -9)},
{ID: "c", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 2, OverdueSince: now.AddDate(0, 0, -26)},
{ID: "d", Commercial: true, Status: "suspended_payment", ValidUntil: now.AddDate(1, 0, 0), DunningLevel: 3, OverdueSince: now.AddDate(0, 0, -40)},
{ID: "h", Commercial: false, Status: "active", ValidUntil: now},
}}, now)
l := lines(out)
for svc, prefix := range map[string]string{
"Sultan_Payment_a": `0 "Sultan_Payment_a" dunning_level=0;1;2 OK - keine offene Zahlung`,
"Sultan_Payment_b": `1 "Sultan_Payment_b" dunning_level=1;1;2 WARN - Mahnstufe 1 (Zahlung seit 9 Tagen offen)`,
"Sultan_Payment_c": `2 "Sultan_Payment_c" dunning_level=2;1;2 CRIT - Mahnstufe 2 (Zahlung seit 26 Tagen offen)`,
"Sultan_Payment_d": `2 "Sultan_Payment_d" dunning_level=3;1;2 CRIT - Mahnstufe 3 (Zahlung seit 40 Tagen offen) - Kunde offline`,
} {
if l[svc] != prefix {
t.Errorf("%s:\n got: %s\nwant: %s", svc, l[svc], prefix)
}
}
if _, ok := l["Sultan_Payment_h"]; ok {
t.Error("Community-Mandant mit Mahnwesen")
}
}
func TestNodes(t *testing.T) {
out := Render(State{Nodes: []Node{
{ID: "online", Status: "online", LeaseUntil: now.Add(48 * time.Hour)},
{ID: "wartung", Status: "online", MaintenanceActive: true, MaintenanceUntil: now.Add(90 * time.Minute), MaintenanceReason: "Kernel 6.12", LeaseUntil: now.Add(time.Hour)},
{ID: "kurz-weg", Status: "offline", OfflineSince: now.Add(-5 * time.Minute), LeaseUntil: now.Add(time.Hour)},
{ID: "lange-weg", Status: "offline", OfflineSince: now.Add(-20 * time.Minute)},
{ID: "abgezogen", Status: "drained"},
}}, now)
l := lines(out)
want := map[string]string{
"Sultan_Node_online": `0 "Sultan_Node_online" lease_hours_left=48 OK - online`,
"Sultan_Node_wartung": `1 "Sultan_Node_wartung" lease_hours_left=1 WARN - Wartung bis 2026-10-02 13:30 UTC (Kernel 6.12)`,
"Sultan_Node_kurz-weg": `1 "Sultan_Node_kurz-weg" lease_hours_left=1 WARN - offline seit 5 min`,
"Sultan_Node_lange-weg": `2 "Sultan_Node_lange-weg" lease_hours_left=0 CRIT - offline seit 20 min`,
"Sultan_Node_abgezogen": `0 "Sultan_Node_abgezogen" lease_hours_left=0 OK - drained`,
}
for svc, w := range want {
if l[svc] != w {
t.Errorf("%s:\n got: %s\nwant: %s", svc, l[svc], w)
}
}
}
// Angemeldete Wartung: auch offline nur WARN (geplant), nicht CRIT.
func TestNodeOfflineDuringMaintenance(t *testing.T) {
out := Render(State{Nodes: []Node{{ID: "pi", Status: "offline", OfflineSince: now.Add(-time.Hour), MaintenanceActive: true}}}, now)
if !strings.HasPrefix(strings.TrimSpace(out), `1 "Sultan_Node_pi"`) || !strings.Contains(out, "Wartung unbefristet") {
t.Errorf("out = %s", out)
}
}
func TestViolations(t *testing.T) {
for n, want := range map[int]string{
0: `0 "Sultan_Policy_Violations" violations_24h=0;1 OK - keine Policy-Verstoesse in 24 h`,
3: `1 "Sultan_Policy_Violations" violations_24h=3;1 WARN - 3 Policy-Verstoesse in 24 h (siehe Server-Log)`,
} {
if got := strings.TrimSpace(Render(State{Violations24h: n}, now)); got != want {
t.Errorf("%d:\n got: %s\nwant: %s", n, got, want)
}
}
}
func TestOutputIsSortedAndASCII(t *testing.T) {
out := Render(State{
Tenants: []Tenant{{ID: "b", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0)}, {ID: "a", Commercial: true, Status: "active", ValidUntil: now.AddDate(1, 0, 0)}},
Nodes: []Node{{ID: "y", Status: "online"}, {ID: "x", Status: "online", MaintenanceActive: true, MaintenanceReason: "Grün & Ölwechsel\nzweite Zeile"}},
}, now)
if strings.Index(out, "Sultan_Tenant_a") > strings.Index(out, "Sultan_Tenant_b") || strings.Index(out, "Sultan_Node_x") > strings.Index(out, "Sultan_Node_y") {
t.Errorf("nicht sortiert:\n%s", out)
}
for _, r := range out {
if r > 127 {
t.Fatalf("Nicht-ASCII-Zeichen %q in:\n%s", r, out)
}
}
if strings.Count(out, "\n") != len(lines(out)) {
t.Errorf("Zeilenumbruch aus dem Grund in der Ausgabe:\n%s", out)
}
if !strings.Contains(out, "Gruen & Oelwechsel zweite Zeile") {
t.Errorf("Umlaute nicht umschrieben:\n%s", out)
}
}
func TestShortOfflineInSeconds(t *testing.T) {
out := Render(State{Nodes: []Node{{ID: "pi", Status: "offline", OfflineSince: now.Add(-45 * time.Second)}}}, now)
if !strings.Contains(out, "offline seit 45 s") {
t.Errorf("out = %s", out)
}
}
// Wartungsende in der Zeitzone des Servers (wie auf der Lander-Seite).
func TestMaintenanceUntilInLocation(t *testing.T) {
berlin, err := time.LoadLocation("Europe/Berlin")
if err != nil {
t.Skip("Zeitzonendaten fehlen")
}
out := Render(State{
Location: berlin,
Nodes: []Node{{ID: "pi", Status: "online", MaintenanceActive: true, MaintenanceUntil: now.Add(90 * time.Minute)}},
}, now)
if !strings.Contains(out, "Wartung bis 2026-10-02 15:30 CEST") {
t.Errorf("out = %s", out)
}
}
+115
View File
@@ -0,0 +1,115 @@
package cli
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"sultan/pkg/agent"
)
func runAgent(ctx context.Context, args []string, stderr io.Writer) int {
if len(args) > 0 && args[0] == "maintenance" {
return runAgentMaintenance(ctx, args[1:], stderr)
}
cfg, err := parseAgentFlags(args, stderr, os.Getenv)
if errors.Is(err, flag.ErrHelp) {
return 0
}
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
cfg.Logger = slog.New(slog.NewTextHandler(stderr, nil))
a, err := agent.New(cfg)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
if err := a.Run(ctx); err != nil {
fmt.Fprintln(stderr, "Agent beendet mit Fehler:", err)
return 1
}
return 0
}
// parseAgentFlags liest die Flags von `sultan agent`. SULTAN_TOKEN und
// SULTAN_SERVER dienen als Defaults (curl | sh, Compose; Argumente wären über
// ps für andere Benutzer sichtbar) – Flags gewinnen.
func parseAgentFlags(args []string, output io.Writer, getenv func(string) string) (agent.Config, error) {
fs := flag.NewFlagSet("sultan agent", flag.ContinueOnError)
fs.SetOutput(output)
var cfg agent.Config
fs.StringVar(&cfg.ServerURL, "server", "", "Server-Adresse, z. B. https://ingress.example.com:8443 (Override; sonst aus der Einladung)")
fs.StringVar(&cfg.Token, "token", "", "Einladung von `sultan token invite` (nur beim ersten Start)")
fs.StringVar(&cfg.DataDir, "data-dir", "./suldata", "Datenverzeichnis (node.key, node.json)")
fs.StringVar(&cfg.NodeID, "node-id", "", "Knoten-ID (Default: aus dem Hostnamen)")
fs.StringVar(&cfg.DockerSocket, "docker-socket", "/var/run/docker.sock", `Docker-Socket ("none" = ohne Docker)`)
fs.StringVar(&cfg.BackendHost, "backend-host", "", "Adresse, unter der der Ingress die Dienste erreicht (Default: automatisch)")
fs.StringVar(&cfg.BackendInterface, "backend-interface", "", "alternativ: IP dieses Interfaces verwenden (z. B. wt0 für NetBird)")
fs.StringVar(&cfg.ConfigDir, "config-dir", "/etc/sultan.d", `Dienste als YAML (*.yaml) für Hosts ohne Docker-Labels ("none" = aus)`)
annotateEnv(fs)
if err := fs.Parse(args); err != nil {
return agent.Config{}, err
}
if err := applyEnv(fs, getenv); err != nil {
return agent.Config{}, err
}
if fs.NArg() > 0 {
return agent.Config{}, fmt.Errorf("unerwartetes Argument %q", fs.Arg(0))
}
return cfg, nil
}
// runAgentMaintenance: `sultan agent maintenance on|off [--for] [--reason]
// [--public-reason]` – meldet Wartung dieses Knotens beim Server an oder ab,
// z. B. aus einem Patch- oder Reboot-Skript.
func runAgentMaintenance(ctx context.Context, args []string, stderr io.Writer) int {
if len(args) == 0 || (args[0] != "on" && args[0] != "off") {
fmt.Fprintln(stderr, "Aufruf: sultan agent maintenance on|off [--for 4h|0] [--reason TEXT] [--public-reason TEXT] [--data-dir DIR]")
return 2
}
on := args[0] == "on"
fs := flag.NewFlagSet("sultan agent maintenance", flag.ContinueOnError)
fs.SetOutput(stderr)
dataDir := fs.String("data-dir", "./suldata", "Datenverzeichnis des Agenten")
server := fs.String("server", "", "Server-Adresse (Override)")
forRaw := fs.String("for", "4h", "Dauer (z. B. 30m, 4h, 2d; 0 = unbefristet)")
reason := fs.String("reason", "", "interner Grund (Log, Monitoring)")
publicReason := fs.String("public-reason", "", "öffentlicher Grund – ersetzt den Standardtext der Wartungsseite")
annotateEnv(fs)
if err := fs.Parse(args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if err := applyEnv(fs, os.Getenv); err != nil {
fmt.Fprintln(stderr, err)
return 2
}
d, err := parseMaintenanceFor(*forRaw)
if err != nil {
fmt.Fprintln(stderr, "--for:", err)
return 2
}
a, err := agent.New(agent.Config{DataDir: *dataDir, ServerURL: *server, Logger: slog.New(slog.NewTextHandler(stderr, nil))})
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
if err := a.SetMaintenance(ctx, on, d, *reason, *publicReason); err != nil {
fmt.Fprintln(stderr, "Fehler:", err)
return 1
}
if on {
fmt.Fprintln(stderr, "Wartung angemeldet – Besucher sehen jetzt die Wartungsseite.")
} else {
fmt.Fprintln(stderr, "Wartung beendet.")
}
return 0
}
+97
View File
@@ -0,0 +1,97 @@
package cli
import (
"bytes"
"context"
"path/filepath"
"strings"
"testing"
)
func TestAgentFlags(t *testing.T) {
cfg, err := parseAgentFlags(nil, &bytes.Buffer{}, noEnv)
if err != nil {
t.Fatal(err)
}
if cfg.DataDir != "./suldata" || cfg.DockerSocket != "/var/run/docker.sock" || cfg.ServerURL != "" || cfg.Token != "" ||
cfg.ConfigDir != "/etc/sultan.d" {
t.Errorf("Defaults: %+v", cfg)
}
cfg, err = parseAgentFlags([]string{
"--server=https://ingress:8443", "--token", "sltn_enroll_x.y", "--data-dir=/srv/sul",
"--node-id=pi", "--docker-socket=none", "--backend-host=100.64.0.42", "--backend-interface=wt0",
"--config-dir=/srv/sultan.d",
}, &bytes.Buffer{}, noEnv)
if err != nil {
t.Fatal(err)
}
if cfg.ServerURL != "https://ingress:8443" || cfg.Token != "sltn_enroll_x.y" || cfg.DataDir != "/srv/sul" ||
cfg.NodeID != "pi" || cfg.DockerSocket != "none" || cfg.BackendHost != "100.64.0.42" || cfg.BackendInterface != "wt0" ||
cfg.ConfigDir != "/srv/sultan.d" {
t.Errorf("Overrides: %+v", cfg)
}
if _, err := parseAgentFlags([]string{"--gibts-nicht"}, &bytes.Buffer{}, noEnv); err == nil {
t.Error("unbekanntes Flag akzeptiert")
}
}
func TestRun_AgentNotEnrolled(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
code, _, stderr := run(context.Background(), "agent", "--data-dir="+dir, "--docker-socket=none")
if code != 1 || !strings.Contains(stderr, "--token") {
t.Errorf("code=%d stderr=%q", code, stderr)
}
}
func TestRun_AgentBadConfig(t *testing.T) {
code, _, stderr := run(context.Background(), "agent", "--server=http://ingress:8443", "--token=x")
if code != 2 || !strings.Contains(stderr, "https") {
t.Errorf("http-URL: code=%d stderr=%q", code, stderr)
}
if code, _, _ := run(context.Background(), "agent", "--help"); code != 0 {
t.Errorf("--help: code=%d", code)
}
}
func noEnv(string) string { return "" }
// Token und Server auch per Umgebung (curl | sh, Compose) – Flags gewinnen.
func TestAgentFlags_Environment(t *testing.T) {
env := map[string]string{"SULTAN_TOKEN": "sltn_enroll_env", "SULTAN_SERVER": "https://env:8443"}
getenv := func(k string) string { return env[k] }
cfg, err := parseAgentFlags(nil, &bytes.Buffer{}, getenv)
if err != nil || cfg.Token != "sltn_enroll_env" || cfg.ServerURL != "https://env:8443" {
t.Errorf("aus Umgebung: %+v %v", cfg, err)
}
cfg, _ = parseAgentFlags([]string{"--token=flag", "--server=https://flag:8443"}, &bytes.Buffer{}, getenv)
if cfg.Token != "flag" || cfg.ServerURL != "https://flag:8443" {
t.Errorf("Flags müssen gewinnen: %+v", cfg)
}
}
func TestRun_AgentMaintenanceCommand(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
code, _, stderr := run(context.Background(), "agent", "maintenance", "on", "--data-dir="+dir)
if code != 1 || !strings.Contains(stderr, "registriert") {
t.Errorf("nicht registriert: code=%d stderr=%q", code, stderr)
}
for _, args := range [][]string{
{"agent", "maintenance"},
{"agent", "maintenance", "vielleicht"},
{"agent", "maintenance", "on", "--for=bald"},
} {
if code, _, _ := run(context.Background(), args...); code != 2 {
t.Errorf("%v: code=%d", args, code)
}
}
}
func TestAgentFlags_EnvironmentAll(t *testing.T) {
cfg, err := parseAgentFlags(nil, &bytes.Buffer{}, envMap(map[string]string{
"SULTAN_DATA_DIR": "/srv/agent", "SULTAN_NODE_ID": "pi", "SULTAN_CONFIG_DIR": "none", "SULTAN_BACKEND_INTERFACE": "wt0",
}))
if err != nil || cfg.DataDir != "/srv/agent" || cfg.NodeID != "pi" || cfg.ConfigDir != "none" || cfg.BackendInterface != "wt0" {
t.Errorf("aus Umgebung: %+v %v", cfg, err)
}
}
+38 -11
View File
@@ -1,8 +1,8 @@
// Package cli implementiert die Befehlszeile von sultan:
//
// sultan server Registry, Stream-Hub, Policy-Engine, Traefik-Provider
// sultan agent (noch nicht implementiert)
// sultan token (noch nicht implementiert)
// sultan agent Agent: Enrollment, Stream, Docker-Watcher
// sultan token Mandanten, Einladungen, Knoten, Lizenzen verwalten
// sultan version
//
// Run ist ohne Prozessgrenzen testbar; main ruft nur Run auf.
@@ -15,6 +15,8 @@ import (
"fmt"
"io"
"log/slog"
"os"
"strings"
"time"
"sultan/pkg/server"
@@ -27,8 +29,9 @@ const usage = `sultan – verteiltes Ingress-Routing mit ZeroTrust-Service-Disco
Befehle:
sultan server Server starten (Registry, Agent-Port, Traefik-Provider)
sultan agent Agent starten (noch nicht implementiert)
sultan token Mandanten, Einladungen, Lizenzen verwalten (noch nicht implementiert)
sultan agent Agent starten (Docker-Host oder Bare-Metal)
sultan token Mandanten, Einladungen, Knoten und Lizenzen verwalten
sultan dump-landing eingebaute Lander-Seiten als Vorlagen ausgeben
sultan version Version ausgeben
sultan help diese Hilfe
@@ -51,9 +54,12 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return 0
case "server":
return runServer(ctx, args[1:], stdout, stderr)
case "agent", "token":
fmt.Fprintf(stderr, "sultan %s: noch nicht implementiert (siehe Roadmap)\n", args[0])
return 1
case "token":
return runToken(ctx, args[1:], stdout, stderr)
case "agent":
return runAgent(ctx, args[1:], stderr)
case "dump-landing":
return runDump(args[1:], stdout, stderr)
default:
fmt.Fprintf(stderr, "unbekannter Befehl %q\n\n%s", args[0], usage)
return 2
@@ -61,7 +67,7 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
}
func runServer(ctx context.Context, args []string, stdout, stderr io.Writer) int {
cfg, err := parseServerFlags(args, stderr)
cfg, err := parseServerFlags(args, stderr, os.Getenv)
if errors.Is(err, flag.ErrHelp) {
return 0
}
@@ -83,8 +89,8 @@ func runServer(ctx context.Context, args []string, stdout, stderr io.Writer) int
}
// parseServerFlags liest die Flags von `sultan server`; Defaults wie in
// server.Config.
func parseServerFlags(args []string, output io.Writer) (server.Config, error) {
// server.Config. Jedes Flag ist auch als SULTAN_<FLAG> setzbar (Flags gewinnen).
func parseServerFlags(args []string, output io.Writer, getenv func(string) string) (server.Config, error) {
fs := flag.NewFlagSet("sultan server", flag.ContinueOnError)
fs.SetOutput(output)
var cfg server.Config
@@ -92,13 +98,34 @@ func parseServerFlags(args []string, output io.Writer) (server.Config, error) {
fs.StringVar(&cfg.AgentAddr, "agent-addr", ":8443", "Adresse des Agent-Ports (nur TLS)")
fs.StringVar(&cfg.ProviderAddr, "provider-addr", "127.0.0.1:8080",
"Adresse für Traefik (/v1/traefik-config, Klartext – im Container :8080 und nur auf 127.0.0.1 veröffentlichen)")
fs.StringVar(&cfg.InterceptorURL, "interceptor-url", "http://127.0.0.1:8081", "Adresse des Sultan-Interceptors/Landers")
fs.Var((*stringList)(&cfg.PublicURLs), "public-url", "Adresse, unter der Agents den Server erreichen (z. B. https://ingress.example.com:8443); mehrfach angebbar (öffentlich, NetBird …); wird gemerkt, die erste steckt in Einladungen")
fs.StringVar(&cfg.LanderAddr, "lander-addr", "127.0.0.1:8081", `Adresse des eingebauten Landers (Hinweis-/Wartungsseiten); "none" = externer Lander`)
fs.StringVar(&cfg.InterceptorURL, "interceptor-url", "", "URL, unter der Traefik den Lander erreicht (Default: --lander-addr)")
fs.StringVar(&cfg.HosterName, "hoster-name", "", "Name des Hosting-Anbieters (Einladungsseiten)")
fs.StringVar(&cfg.HosterURL, "hoster-url", "", "Angebotsseite des Hosting-Anbieters")
fs.StringVar(&cfg.ProURL, "pro-url", "", "Pro-Angebot, verlinkt auf Community-Wartungsseiten")
fs.DurationVar(&cfg.RefreshInterval, "refresh", time.Minute, "Intervall der Neubewertung aller Knoten")
fs.Var((*stringList)(&cfg.SlackWebhooks), "notify-slack", "Slack-kompatibler Webhook für Benachrichtigungen (mehrfach angebbar)")
fs.Var((*stringList)(&cfg.NtfyURLs), "notify-ntfy", "ntfy-Topic-URL für Benachrichtigungen, z. B. https://ntfy.sh/sultan (mehrfach angebbar)")
fs.StringVar(&cfg.NtfyToken, "notify-ntfy-token", "", "Access-Token für ntfy")
fs.DurationVar(&cfg.OfflineAlertAfter, "offline-alert-after", 15*time.Minute, "Alarm, wenn ein Knoten so lange getrennt ist (nicht bei Wartung)")
fs.IntVar(&cfg.EnrollLimit, "enroll-limit", 10, "Enrollment-Versuche pro Minute und Client-IP (-1 = unbegrenzt)")
fs.DurationVar(&cfg.LeaseDuration, "lease-duration", 0, "feste Lease-Laufzeit für alle Knoten (0 = je Tier: Community 14 Tage, kommerziell 7 Tage)")
annotateEnv(fs)
if err := fs.Parse(args); err != nil {
return server.Config{}, err
}
if err := applyEnv(fs, getenv); err != nil {
return server.Config{}, err
}
if fs.NArg() > 0 {
return server.Config{}, fmt.Errorf("unerwartetes Argument %q", fs.Arg(0))
}
return cfg, nil
}
// stringList ist ein mehrfach angebbares String-Flag.
type stringList []string
func (l *stringList) String() string { return strings.Join(*l, ",") }
func (l *stringList) Set(v string) error { *l = append(*l, v); return nil }
+99 -17
View File
@@ -38,41 +38,42 @@ func TestRun_Version(t *testing.T) {
}
}
func TestRun_NotYetImplemented(t *testing.T) {
for _, cmd := range []string{"agent", "token"} {
code, _, stderr := run(context.Background(), cmd)
if code != 1 || !strings.Contains(stderr, "noch nicht implementiert") {
t.Errorf("%s: code=%d stderr=%q", cmd, code, stderr)
}
}
}
func TestServerFlags(t *testing.T) {
cfg, err := parseServerFlags(nil, &bytes.Buffer{})
cfg, err := parseServerFlags(nil, &bytes.Buffer{}, noEnv)
if err != nil {
t.Fatal(err)
}
if cfg.DataDir != "./suldata" || cfg.AgentAddr != ":8443" || cfg.ProviderAddr != "127.0.0.1:8080" ||
cfg.InterceptorURL != "http://127.0.0.1:8081" || cfg.RefreshInterval != time.Minute {
cfg.LanderAddr != "127.0.0.1:8081" || cfg.InterceptorURL != "" || cfg.RefreshInterval != time.Minute {
t.Errorf("Defaults: %+v", cfg)
}
cfg, err = parseServerFlags([]string{
"--data-dir=/srv/sul", "--agent-addr", ":9443", "--provider-addr=:8080",
"--interceptor-url=http://lander:8081", "--refresh=30s",
}, &bytes.Buffer{})
"--interceptor-url=http://lander:8081", "--refresh=30s", "--lander-addr=none",
"--hoster-name=Beispiel-Hosting", "--hoster-url=https://hoster.example", "--pro-url=https://hoster.example/pro",
"--public-url=https://ingress.example:8443", "--public-url=https://100.64.0.1:8443",
"--lease-duration=1h",
"--notify-slack=https://hooks.example/a", "--notify-slack=https://hooks.example/b",
"--notify-ntfy=https://ntfy.example/sultan", "--notify-ntfy-token=tk_x", "--offline-alert-after=10m",
}, &bytes.Buffer{}, noEnv)
if err != nil {
t.Fatal(err)
}
if cfg.DataDir != "/srv/sul" || cfg.AgentAddr != ":9443" || cfg.ProviderAddr != ":8080" ||
cfg.InterceptorURL != "http://lander:8081" || cfg.RefreshInterval != 30*time.Second {
cfg.InterceptorURL != "http://lander:8081" || cfg.RefreshInterval != 30*time.Second || cfg.LanderAddr != "none" ||
cfg.HosterName != "Beispiel-Hosting" || cfg.HosterURL != "https://hoster.example" || cfg.ProURL != "https://hoster.example/pro" ||
strings.Join(cfg.PublicURLs, ",") != "https://ingress.example:8443,https://100.64.0.1:8443" || cfg.LeaseDuration != time.Hour ||
strings.Join(cfg.SlackWebhooks, ",") != "https://hooks.example/a,https://hooks.example/b" ||
strings.Join(cfg.NtfyURLs, ",") != "https://ntfy.example/sultan" || cfg.NtfyToken != "tk_x" ||
cfg.OfflineAlertAfter != 10*time.Minute {
t.Errorf("Overrides: %+v", cfg)
}
if _, err := parseServerFlags([]string{"--gibts-nicht"}, &bytes.Buffer{}); err == nil {
if _, err := parseServerFlags([]string{"--gibts-nicht"}, &bytes.Buffer{}, noEnv); err == nil {
t.Error("unbekanntes Flag akzeptiert")
}
if _, err := parseServerFlags([]string{"überzählig"}, &bytes.Buffer{}); err == nil {
if _, err := parseServerFlags([]string{"überzählig"}, &bytes.Buffer{}, noEnv); err == nil {
t.Error("überzähliges Argument akzeptiert")
}
}
@@ -99,7 +100,7 @@ func TestRun_ServerStartsAndStops(t *testing.T) {
}
done := make(chan result, 1)
go func() {
code, _, stderr := run(ctx, "server", "--data-dir="+dir, "--agent-addr=127.0.0.1:0", "--provider-addr=127.0.0.1:0")
code, _, stderr := run(ctx, "server", "--data-dir="+dir, "--agent-addr=127.0.0.1:0", "--provider-addr=127.0.0.1:0", "--lander-addr=127.0.0.1:0")
done <- result{code, stderr}
}()
@@ -137,3 +138,84 @@ func TestRun_ServerStartError(t *testing.T) {
t.Errorf("unbrauchbares Datenverzeichnis: code=%d stderr=%q", code, stderr)
}
}
func TestRun_DumpLanding(t *testing.T) {
dir := filepath.Join(t.TempDir(), "landing")
code, stdout, stderr := run(context.Background(), "dump-landing", "--out="+dir)
if code != 0 {
t.Fatalf("code=%d stderr=%s", code, stderr)
}
if !strings.Contains(stdout, "maintenance.html") {
t.Errorf("Ausgabe ohne Dateiliste: %q", stdout)
}
if _, err := os.Stat(filepath.Join(dir, "README.txt")); err != nil {
t.Error("README.txt fehlt")
}
if code, _, stderr := run(context.Background(), "dump-landing", "--out="+dir); code != 1 || !strings.Contains(stderr, "--force") {
t.Errorf("ohne --force: code=%d stderr=%q", code, stderr)
}
if code, _, _ := run(context.Background(), "dump-landing", "--out="+dir, "--force"); code != 0 {
t.Errorf("mit --force: code=%d", code)
}
if code, _, _ := run(context.Background(), "dump-landing", "überzählig"); code != 2 {
t.Errorf("überzähliges Argument: code=%d", code)
}
}
func TestDumpLandingDefaultDir(t *testing.T) {
out, err := parseDumpFlags(nil, &bytes.Buffer{})
if err != nil || out.dir != "./suldata/landing" || out.force {
t.Errorf("Defaults: %+v %v", out, err)
}
}
func envMap(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
// Jedes Flag ist auch als SULTAN_<FLAG> setzbar; Flags gewinnen, Listen
// sind kommagetrennt und werden durch ein Flag ersetzt, nicht ergänzt.
func TestServerFlags_Environment(t *testing.T) {
env := envMap(map[string]string{
"SULTAN_DATA_DIR": "/srv/sul",
"SULTAN_PUBLIC_URL": "https://a.example:8443, https://100.64.0.1:8443",
"SULTAN_NOTIFY_NTFY_TOKEN": "tk_geheim",
"SULTAN_REFRESH": "30s",
"SULTAN_ENROLL_LIMIT": "5",
})
cfg, err := parseServerFlags(nil, &bytes.Buffer{}, env)
if err != nil {
t.Fatal(err)
}
if cfg.DataDir != "/srv/sul" || strings.Join(cfg.PublicURLs, ",") != "https://a.example:8443,https://100.64.0.1:8443" ||
cfg.NtfyToken != "tk_geheim" || cfg.RefreshInterval != 30*time.Second || cfg.EnrollLimit != 5 {
t.Errorf("aus Umgebung: %+v", cfg)
}
cfg, err = parseServerFlags([]string{"--public-url=https://c.example:8443", "--data-dir=/anders"}, &bytes.Buffer{}, env)
if err != nil {
t.Fatal(err)
}
if strings.Join(cfg.PublicURLs, ",") != "https://c.example:8443" || cfg.DataDir != "/anders" {
t.Errorf("Flags müssen gewinnen (Listen ersetzen): %v %q", cfg.PublicURLs, cfg.DataDir)
}
if _, err := parseServerFlags(nil, &bytes.Buffer{}, envMap(map[string]string{"SULTAN_REFRESH": "bald"})); err == nil ||
!strings.Contains(err.Error(), "SULTAN_REFRESH") {
t.Errorf("fehlerhafte Variable nicht benannt: %v", err)
}
var help bytes.Buffer
parseServerFlags([]string{"--help"}, &help, noEnv)
if !strings.Contains(help.String(), "SULTAN_PUBLIC_URL") || !strings.Contains(help.String(), "SULTAN_NOTIFY_NTFY_TOKEN") {
t.Errorf("Hilfe ohne Variablennamen:\n%s", help.String())
}
}
func TestServerFlags_EnrollLimit(t *testing.T) {
cfg, _ := parseServerFlags(nil, &bytes.Buffer{}, noEnv)
if cfg.EnrollLimit != 10 {
t.Errorf("Default %d", cfg.EnrollLimit)
}
cfg, _ = parseServerFlags([]string{"--enroll-limit=-1"}, &bytes.Buffer{}, noEnv) // -1 = unbegrenzt
if cfg.EnrollLimit != -1 {
t.Errorf("--enroll-limit=-1: %d", cfg.EnrollLimit)
}
}
+52
View File
@@ -0,0 +1,52 @@
package cli
import (
"errors"
"flag"
"fmt"
"io"
"sultan/pkg/lander"
)
type dumpOptions struct {
dir string
force bool
}
func parseDumpFlags(args []string, output io.Writer) (dumpOptions, error) {
fs := flag.NewFlagSet("sultan dump-landing", flag.ContinueOnError)
fs.SetOutput(output)
var o dumpOptions
fs.StringVar(&o.dir, "out", "./suldata/landing", "Zielverzeichnis (der Server liest <data-dir>/landing)")
fs.BoolVar(&o.force, "force", false, "vorhandene Dateien überschreiben")
if err := fs.Parse(args); err != nil {
return o, err
}
if fs.NArg() > 0 {
return o, fmt.Errorf("unerwartetes Argument %q", fs.Arg(0))
}
return o, nil
}
// runDump schreibt die eingebauten Lander-Seiten als Vorlagen zum Anpassen.
func runDump(args []string, stdout, stderr io.Writer) int {
o, err := parseDumpFlags(args, stderr)
if errors.Is(err, flag.ErrHelp) {
return 0
}
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
files, err := lander.Dump(o.dir, o.force)
if err != nil {
fmt.Fprintln(stderr, "Fehler:", err)
return 1
}
for _, f := range files {
fmt.Fprintln(stdout, f)
}
fmt.Fprintln(stderr, "Vorlagen geschrieben – Änderungen wirken sofort, ohne Neustart (siehe README.txt).")
return 0
}
+51
View File
@@ -0,0 +1,51 @@
package cli
import (
"flag"
"fmt"
"strings"
)
// envName bildet einen Flag-Namen auf seine Umgebungsvariable ab:
// public-url → SULTAN_PUBLIC_URL.
func envName(flagName string) string {
return "SULTAN_" + strings.ToUpper(strings.ReplaceAll(flagName, "-", "_"))
}
// annotateEnv ergänzt die Hilfe jedes Flags um den Namen der Variable.
func annotateEnv(fs *flag.FlagSet) {
fs.VisitAll(func(f *flag.Flag) { f.Usage += " [" + envName(f.Name) + "]" })
}
// applyEnv setzt nach fs.Parse alle Flags, die nicht auf der Befehlszeile
// standen, aus SULTAN_<FLAG>. Flags gewinnen also; Listen-Flags werden aus
// kommagetrennten Werten gefüllt (und von einem Flag ersetzt, nicht ergänzt).
func applyEnv(fs *flag.FlagSet, getenv func(string) string) error {
explicit := map[string]bool{}
fs.Visit(func(f *flag.Flag) { explicit[f.Name] = true })
var err error
fs.VisitAll(func(f *flag.Flag) {
if err != nil || explicit[f.Name] {
return
}
name := envName(f.Name)
v := strings.TrimSpace(getenv(name))
if v == "" {
return
}
values := []string{v}
if _, isList := f.Value.(*stringList); isList {
values = strings.Split(v, ",")
}
for _, item := range values {
if item = strings.TrimSpace(item); item == "" {
continue
}
if e := f.Value.Set(item); e != nil {
err = fmt.Errorf("%s=%q: %w", name, v, e)
return
}
}
})
return err
}
+639
View File
@@ -0,0 +1,639 @@
package cli
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"text/tabwriter"
"time"
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/license"
"sultan/pkg/policy"
"sultan/pkg/server"
"sultan/pkg/store"
"sultan/pkg/stream"
)
const tokenUsage = `sultan token – Mandanten, Einladungen, Knoten und Lizenzen verwalten.
Arbeitet direkt auf dem Datenverzeichnis des Servers (auch während er läuft).
sultan token tenant add --id ID --name NAME --email MAIL --domains a.de,*.b.de
[--tier community|pro|enterprise] [--max-routes 5] [--valid-until JJJJ-MM-TT]
[--features custom_middleware,websockets,tcp_stream]
sultan token tenant list
sultan token tenant set-status --id ID --status active|suspended_payment|quarantined|expired
sultan token tenant delete --id ID (löscht auch Knoten und Einladungen!)
sultan token tenant payment --id ID --overdue-since JJJJ-MM-TT | --paid
(offene Zahlung → Mahnstufen nach 8/25/32 Tagen, nur kommerziell)
sultan token invite --tenant ID [--ttl 24h|7d] [--server-url https://…]
sultan token node list [--tenant ID]
sultan token node maintenance --id ID [--for 4h|0] [--reason TEXT] [--public-reason TEXT]
(Routen bleiben, Besucher sehen die Wartungsseite)
sultan token node drain --id ID (Routen werden entfernt, bis resume)
sultan token node resume --id ID (beendet Wartung und drain)
sultan token node delete --id ID
sultan token node command --id ID --cmd resync|rotate_key [--reason TEXT]
(wird beim nächsten Refresh bzw. Verbinden zugestellt)
sultan token license issue --tenant ID
sultan token purge (verbrauchte/abgelaufene Einladungen löschen)
Alle Befehle: --data-dir DIR (Default ./suldata)
`
// usageError: Bedienfehler (Exit-Code 2).
type usageError struct{ error }
type tokenEnv struct {
ctx context.Context
stdout, stderr io.Writer
}
type tokenCmd func(env tokenEnv, args []string) error
var tokenCmds = map[string]tokenCmd{
"tenant add": tenantAdd,
"tenant list": tenantList,
"tenant set-status": tenantSetStatus,
"tenant delete": tenantDelete,
"tenant payment": tenantPayment,
"invite": invite,
"node list": nodeList,
"node drain": nodeSetStatus(store.NodeDrained),
"node maintenance": nodeMaintenance,
"node resume": nodeResume,
"node command": nodeCommand,
"node delete": nodeDelete,
"license issue": licenseIssue,
"purge": purge,
}
func runToken(ctx context.Context, args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprint(stderr, tokenUsage)
return 2
}
if args[0] == "help" || args[0] == "-h" || args[0] == "--help" {
fmt.Fprint(stdout, tokenUsage)
return 0
}
name, rest := args[0], args[1:]
if (name == "tenant" || name == "node" || name == "license") && len(rest) > 0 {
name, rest = name+" "+rest[0], rest[1:]
}
cmd, ok := tokenCmds[name]
if !ok {
fmt.Fprintf(stderr, "unbekannter Befehl \"token %s\"\n\n%s", name, tokenUsage)
return 2
}
err := cmd(tokenEnv{ctx: ctx, stdout: stdout, stderr: stderr}, rest)
var ue usageError
switch {
case err == nil:
return 0
case errors.Is(err, flag.ErrHelp):
return 0
case errors.As(err, &ue):
fmt.Fprintln(stderr, err)
return 2
default:
fmt.Fprintln(stderr, "Fehler:", err)
return 1
}
}
// flags legt ein FlagSet mit --data-dir an.
func flags(name string, env tokenEnv) (*flag.FlagSet, *string) {
fs := flag.NewFlagSet("sultan token "+name, flag.ContinueOnError)
fs.SetOutput(env.stderr)
dir := fs.String("data-dir", "./suldata", "Datenverzeichnis des Servers")
return fs, dir
}
func parse(fs *flag.FlagSet, args []string, required ...string) error {
annotateEnv(fs)
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return err
}
return usageError{err}
}
if err := applyEnv(fs, os.Getenv); err != nil {
return usageError{err}
}
if fs.NArg() > 0 {
return usageError{fmt.Errorf("unerwartetes Argument %q", fs.Arg(0))}
}
for _, name := range required {
if fs.Lookup(name).Value.String() == "" {
return usageError{fmt.Errorf("--%s fehlt", name)}
}
}
return nil
}
// openDataDir öffnet die DB eines bereits initialisierten Datenverzeichnisses.
// Ein nicht initialisiertes wird nie angelegt: Ein Tippfehler bei --data-dir
// würde sonst eine neue Server-Identität (Zertifikat, Master-Key) erzeugen.
func openDataDir(env tokenEnv, dir string) (*store.Store, error) {
for _, f := range []string{"sultan.db", "server.crt"} {
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
return nil, fmt.Errorf("Datenverzeichnis %s nicht initialisiert (%s fehlt) – zuerst `sultan server` starten oder --data-dir prüfen", dir, f)
}
}
return store.Open(env.ctx, filepath.Join(dir, "sultan.db"))
}
func tenantAdd(env tokenEnv, args []string) error {
fs, dir := flags("tenant add", env)
id := fs.String("id", "", "Mandanten-ID (a–z, 0–9, - und _)")
name := fs.String("name", "", "Name")
email := fs.String("email", "", "Kontakt-E-Mail")
domains := fs.String("domains", "", "erlaubte Domains, kommagetrennt (z. B. kunde.de,*.kunde.de)")
tier := fs.String("tier", string(store.TierCommunity), "community, pro oder enterprise")
maxRoutes := fs.Int("max-routes", 5, "maximale Anzahl Routen (-1 = unbegrenzt)")
validUntil := fs.String("valid-until", "", "Laufzeitende JJJJ-MM-TT (Default: in einem Jahr)")
featuresRaw := fs.String("features", "", "Features, kommagetrennt (custom_middleware, websockets, tcp_stream)")
if err := parse(fs, args, "id", "name", "email", "domains"); err != nil {
return err
}
until := time.Now().AddDate(1, 0, 0)
if *validUntil != "" {
t, err := parseDate(*validUntil)
if err != nil {
return usageError{err}
}
until = t
}
if !policy.ValidID(*id) || *id == "sultan" {
return fmt.Errorf("Mandanten-ID %q ungültig (a–z, 0–9, - und _, nicht am Rand, kein \"__\", nicht \"sultan\")", *id)
}
var normalized []string
for _, d := range strings.Split(*domains, ",") {
n, err := license.NormalizeDomain(d)
if err != nil {
return fmt.Errorf("Domain %q ungültig (Beispiele: kunde.de, *.kunde.de; keine Wildcard auf TLD)", strings.TrimSpace(d))
}
normalized = append(normalized, n)
}
var features []string
for _, f := range strings.Split(*featuresRaw, ",") {
if f = strings.TrimSpace(f); f == "" {
continue
}
switch license.Feature(f) {
case license.FeatureCustomMiddleware, license.FeatureWebsockets, license.FeatureTCPStream:
features = append(features, f)
default:
return fmt.Errorf("unbekanntes Feature %q (custom_middleware, websockets, tcp_stream)", f)
}
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
t, err := st.CreateTenant(env.ctx, store.Tenant{
ID: *id, Name: *name, Email: *email, Tier: store.Tier(*tier), MaxRoutes: *maxRoutes,
AllowedDomains: normalized, ValidUntil: until, Features: features,
})
if err != nil {
return err
}
fmt.Fprintf(env.stdout, "Mandant %s angelegt (%s, gültig bis %s)\n", t.ID, t.Tier, t.ValidUntil.Format(time.DateOnly))
return nil
}
func parseDate(s string) (time.Time, error) {
if t, err := time.Parse(time.DateOnly, s); err == nil {
return t, nil
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
return time.Time{}, fmt.Errorf("Datum %q nicht lesbar (JJJJ-MM-TT)", s)
}
func tenantList(env tokenEnv, args []string) error {
fs, dir := flags("tenant list", env)
if err := parse(fs, args); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
tenants, err := st.ListTenants(env.ctx)
if err != nil {
return err
}
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tTIER\tSTATUS\tMAX_ROUTES\tVALID_UNTIL\tPAYMENT\tDOMAINS")
for _, t := range tenants {
payment := "-"
if !t.PaymentOverdueSince.IsZero() {
payment = "offen seit " + t.PaymentOverdueSince.UTC().Format(time.DateOnly)
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\n", t.ID, t.Name, t.Tier, t.Status, t.MaxRoutes,
t.ValidUntil.Format(time.DateOnly), payment, strings.Join(t.AllowedDomains, ","))
}
return tw.Flush()
}
func tenantSetStatus(env tokenEnv, args []string) error {
fs, dir := flags("tenant set-status", env)
id := fs.String("id", "", "Mandanten-ID")
status := fs.String("status", "", "active, suspended_payment, quarantined oder expired")
if err := parse(fs, args, "id", "status"); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if err := st.SetTenantStatus(env.ctx, *id, store.TenantStatus(*status)); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Mandant %s: Status %s (wirkt beim nächsten Refresh des Servers)\n", *id, *status)
return nil
}
// tenantPayment setzt bzw. löscht die offene Zahlung (bis zur Admin-API).
func tenantPayment(env tokenEnv, args []string) error {
fs, dir := flags("tenant payment", env)
id := fs.String("id", "", "Mandanten-ID")
since := fs.String("overdue-since", "", "Fälligkeitsdatum der offenen Zahlung (JJJJ-MM-TT)")
paid := fs.Bool("paid", false, "Zahlung eingegangen – Mahnstufe zurücksetzen")
if err := parse(fs, args, "id"); err != nil {
return err
}
if (*since == "") == !*paid {
return usageError{errors.New("genau eines von --overdue-since oder --paid angeben")}
}
var due time.Time
if *since != "" {
t, err := parseDate(*since)
if err != nil {
return usageError{err}
}
due = t
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if err := st.SetTenantPayment(env.ctx, *id, due); err != nil {
return err
}
if *paid {
fmt.Fprintf(env.stdout, "Mandant %s: Zahlung eingegangen – Mahnstufe zurückgesetzt\n", *id)
} else {
fmt.Fprintf(env.stdout, "Mandant %s: Zahlung offen seit %s (Mahnstufen ab 8/25/32 Tagen)\n", *id, due.Format(time.DateOnly))
}
return nil
}
func tenantDelete(env tokenEnv, args []string) error {
fs, dir := flags("tenant delete", env)
id := fs.String("id", "", "Mandanten-ID")
if err := parse(fs, args, "id"); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if err := st.DeleteTenant(env.ctx, *id); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Mandant %s samt Knoten und Einladungen gelöscht\n", *id)
return nil
}
func invite(env tokenEnv, args []string) error {
fs, dir := flags("invite", env)
tenant := fs.String("tenant", "", "Mandanten-ID")
ttlRaw := fs.String("ttl", "24h", "Gültigkeit der Einladung (z. B. 2h, 24h, 7d)")
serverURL := fs.String("server-url", "", "Server-Adresse für diese Einladung (Default: sultan server --public-url)")
if err := parse(fs, args, "tenant"); err != nil {
return err
}
ttl, err := parseTTL(*ttlRaw)
if err != nil {
return usageError{err}
}
if *serverURL != "" {
u, err := crypto.ValidateServerURL(*serverURL)
if err != nil {
return usageError{err}
}
*serverURL = u
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if *serverURL == "" {
urls, err := server.LoadPublicURLs(env.ctx, st)
if err != nil {
return err
}
if len(urls) > 0 {
*serverURL = urls[0] // die übrigen bekommt der Agent per config-Befehl
}
}
cert, err := crypto.LoadOrCreateServerCert(*dir)
if err != nil {
return err
}
tok, err := crypto.NewEnrollmentToken(cert.Fingerprint, *serverURL)
if err != nil {
return err
}
expires := time.Now().Add(ttl)
if err := st.AddEnrollmentToken(env.ctx, tok.Secret, *tenant, expires); err != nil {
return err
}
fmt.Fprintln(env.stdout, tok.String())
fmt.Fprintf(env.stderr, "Einladung für Mandant %s, einmalig gültig bis %s.\n", *tenant, expires.Format(time.DateTime))
if tok.ServerURL != "" {
fmt.Fprintf(env.stderr, "Server-Adresse %s ist enthalten. Auf dem Agent-Host:\n SULTAN_TOKEN=<token> sultan agent\n", tok.ServerURL)
} else {
fmt.Fprint(env.stderr, "Auf dem Agent-Host: sultan agent --server=https://<ingress>:8443 --token=<token>\n"+
"Tipp: Mit `sultan server --public-url=https://…` (oder invite --server-url) steckt die Adresse in der Einladung.\n")
}
return nil
}
// parseTTL versteht Go-Dauern (2h, 90m) und zusätzlich Tage (7d).
func parseTTL(s string) (time.Duration, error) {
var d time.Duration
if days, ok := strings.CutSuffix(s, "d"); ok {
n, err := strconv.Atoi(days)
if err != nil {
return 0, fmt.Errorf("--ttl %q nicht lesbar", s)
}
d = time.Duration(n) * 24 * time.Hour
} else {
var err error
if d, err = time.ParseDuration(s); err != nil {
return 0, fmt.Errorf("--ttl %q nicht lesbar (z. B. 2h, 24h, 7d)", s)
}
}
if d <= 0 {
return 0, fmt.Errorf("--ttl muss positiv sein")
}
return d, nil
}
func nodeList(env tokenEnv, args []string) error {
fs, dir := flags("node list", env)
tenant := fs.String("tenant", "", "nur Knoten dieses Mandanten")
if err := parse(fs, args); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
nodes, err := st.ListNodes(env.ctx, *tenant)
if err != nil {
return err
}
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tTENANT\tHOSTNAME\tSTATUS\tMAINTENANCE\tLEASE\tIP\tLAST_SEEN")
for _, n := range nodes {
lease := "-"
if l, err := st.GetLease(env.ctx, n.ID); err == nil {
lease = "bis " + l.ValidUntil.Local().Format("2006-01-02 15:04")
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", n.ID, n.TenantID, n.Hostname, n.Status,
maintenanceLabel(n.Maintenance), lease, n.IPAddress, n.LastSeen.Local().Format(time.DateTime))
}
return tw.Flush()
}
func nodeSetStatus(status store.NodeStatus) tokenCmd {
return func(env tokenEnv, args []string) error {
fs, dir := flags("node", env)
id := fs.String("id", "", "Knoten-ID")
if err := parse(fs, args, "id"); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if err := st.SetNodeStatus(env.ctx, *id, status); err != nil {
return err
}
notifyAgent(env, st, *id, stream.CmdDrain, "")
fmt.Fprintf(env.stdout, "Knoten %s: Status %s (wirkt beim nächsten Refresh des Servers)\n", *id, status)
return nil
}
}
func maintenanceLabel(m *store.Maintenance) string {
switch {
case m == nil:
return "-"
case !m.Active(time.Now()):
return "abgelaufen"
case m.Until.IsZero():
return "unbefristet"
default:
return "bis " + m.Until.Local().Format("2006-01-02 15:04")
}
}
// parseMaintenanceFor versteht wie --ttl Go-Dauern und Tage; 0 = unbefristet.
func parseMaintenanceFor(s string) (time.Duration, error) {
if s == "0" {
return 0, nil
}
return parseTTL(s)
}
func nodeMaintenance(env tokenEnv, args []string) error {
fs, dir := flags("node maintenance", env)
id := fs.String("id", "", "Knoten-ID")
forRaw := fs.String("for", "4h", "Dauer (z. B. 30m, 4h, 2d; 0 = unbefristet)")
reason := fs.String("reason", "", "interner Grund (Log, Monitoring)")
publicReason := fs.String("public-reason", "", "öffentlicher Grund – ersetzt den Standardtext der Wartungsseite")
if err := parse(fs, args, "id"); err != nil {
return err
}
d, err := parseMaintenanceFor(*forRaw)
if err != nil {
return usageError{fmt.Errorf("--for: %w", err)}
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
m := store.Maintenance{Reason: *reason, PublicReason: *publicReason}
if d > 0 {
m.Until = time.Now().Add(d)
}
if err := st.SetNodeMaintenance(env.ctx, *id, m); err != nil {
return err
}
notifyAgent(env, st, *id, stream.CmdMaintenance, *reason)
fmt.Fprintf(env.stdout, "Knoten %s in Wartung (%s) – wirkt beim nächsten Refresh des Servers (≤ 1 min)\n",
*id, maintenanceLabel(&store.Maintenance{Since: time.Now(), Until: m.Until}))
return nil
}
// nodeResume beendet Wartung und drain.
func nodeResume(env tokenEnv, args []string) error {
fs, dir := flags("node resume", env)
id := fs.String("id", "", "Knoten-ID")
if err := parse(fs, args, "id"); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
n, err := st.GetNode(env.ctx, *id)
if err != nil {
return err
}
if n.Status == store.NodeDrained {
// online setzt erst der Stream.
if err := st.SetNodeStatus(env.ctx, *id, store.NodeOffline); err != nil {
return err
}
}
if n.Maintenance != nil {
if err := st.ClearNodeMaintenance(env.ctx, *id); err != nil {
return err
}
}
notifyAgent(env, st, *id, stream.CmdResume, "")
fmt.Fprintf(env.stdout, "Knoten %s: Wartung/drain beendet – wirkt beim nächsten Refresh des Servers\n", *id)
return nil
}
// notifyAgent reiht einen Hinweis für den Agenten ein (bester Versuch).
func notifyAgent(env tokenEnv, st *store.Store, nodeID, command, reason string) {
if err := st.EnqueueNodeCommand(env.ctx, nodeID, command, reason); err != nil {
fmt.Fprintln(env.stderr, "Hinweis an den Agenten nicht eingereiht:", err)
}
}
// nodeCommand reiht einen Befehl ein. Nur die aktiven Befehle sind erlaubt;
// drain/maintenance/resume erzeugen ihre Hinweise selbst.
func nodeCommand(env tokenEnv, args []string) error {
fs, dir := flags("node command", env)
id := fs.String("id", "", "Knoten-ID")
cmd := fs.String("cmd", "", "resync oder rotate_key")
reason := fs.String("reason", "", "Grund (Log)")
if err := parse(fs, args, "id", "cmd"); err != nil {
return err
}
if *cmd != stream.CmdResync && *cmd != stream.CmdRotateKey {
return usageError{fmt.Errorf("--cmd %q: erlaubt sind resync und rotate_key", *cmd)}
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if err := st.EnqueueNodeCommand(env.ctx, *id, *cmd, *reason); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Befehl %s für %s eingereiht – Zustellung beim nächsten Refresh bzw. Verbinden\n", *cmd, *id)
return nil
}
func nodeDelete(env tokenEnv, args []string) error {
fs, dir := flags("node delete", env)
id := fs.String("id", "", "Knoten-ID")
if err := parse(fs, args, "id"); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
if err := st.DeleteNode(env.ctx, *id); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Knoten %s gelöscht (Agent muss neu eingeladen werden)\n", *id)
return nil
}
func licenseIssue(env tokenEnv, args []string) error {
fs, dir := flags("license issue", env)
tenant := fs.String("tenant", "", "Mandanten-ID")
if err := parse(fs, args, "tenant"); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
t, err := st.GetTenant(env.ctx, *tenant)
if err != nil {
return err
}
master, err := auth.ReadPrivateKey(filepath.Join(*dir, "master.key"))
if err != nil {
return err
}
l, err := (&license.Issuer{Key: master}).Issue(license.License{
TenantID: t.ID,
Tier: license.Tier(t.Tier),
MaxRoutes: t.MaxRoutes,
AllowedDomains: t.AllowedDomains,
ValidUntil: t.ValidUntil,
})
if err != nil {
return err
}
out, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
fmt.Fprintln(env.stdout, string(out))
return nil
}
func purge(env tokenEnv, args []string) error {
fs, dir := flags("purge", env)
if err := parse(fs, args); err != nil {
return err
}
st, err := openDataDir(env, *dir)
if err != nil {
return err
}
defer st.Close()
n, err := st.PurgeEnrollmentTokens(env.ctx, 0)
if err != nil {
return err
}
fmt.Fprintf(env.stdout, "%d Einladung(en) gelöscht\n", n)
return nil
}
+506
View File
@@ -0,0 +1,506 @@
package cli
import (
"context"
"crypto/ed25519"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/license"
"sultan/pkg/server"
"sultan/pkg/store"
)
// initDataDir initialisiert ein Datenverzeichnis so, wie es der erste
// Serverstart tut.
func initDataDir(t *testing.T) string {
t.Helper()
dir := filepath.Join(t.TempDir(), "suldata")
s, err := server.New(context.Background(), server.Config{DataDir: dir, Logger: slog.New(slog.DiscardHandler)})
if err != nil {
t.Fatal(err)
}
s.Shutdown(context.Background())
return dir
}
func openStore(t *testing.T, dir string) *store.Store {
t.Helper()
st, err := store.Open(context.Background(), filepath.Join(dir, "sultan.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
// tok ruft `sultan token …` mit --data-dir auf.
func tok(t *testing.T, dir string, args ...string) (int, string, string) {
t.Helper()
full := append([]string{"token"}, args...)
// --data-dir hinter den Unterbefehl hängen (z. B. "tenant add --data-dir=…").
full = append(full, "--data-dir="+dir)
return run(context.Background(), full...)
}
func mustTok(t *testing.T, dir string, args ...string) string {
t.Helper()
code, stdout, stderr := tok(t, dir, args...)
if code != 0 {
t.Fatalf("sultan token %s: code=%d stderr=%s", strings.Join(args, " "), code, stderr)
}
return stdout
}
func addTenant(t *testing.T, dir, id string, extra ...string) {
t.Helper()
mustTok(t, dir, append([]string{"tenant", "add", "--id=" + id, "--name=Kunde " + id, "--email=" + id + "@example.com", "--domains=" + id + ".de,*." + id + ".de"}, extra...)...)
}
func TestToken_Usage(t *testing.T) {
code, _, stderr := run(context.Background(), "token")
if code != 2 {
t.Errorf("ohne Unterbefehl: code=%d", code)
}
for _, want := range []string{"tenant add", "invite", "node list", "node maintenance", "node resume", "license issue", "purge"} {
if !strings.Contains(stderr, want) {
t.Errorf("Hilfe ohne %q:\n%s", want, stderr)
}
}
if code, _, _ := run(context.Background(), "token", "zaubern"); code != 2 {
t.Errorf("unbekannter Unterbefehl: code=%d", code)
}
if code, _, _ := run(context.Background(), "token", "tenant"); code != 2 {
t.Errorf("tenant ohne Aktion: code=%d", code)
}
}
// Ein Tippfehler bei --data-dir darf keine neue Server-Identität erzeugen.
func TestToken_RequiresInitializedDataDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "vertippt")
code, _, stderr := tok(t, dir, "tenant", "list")
if code != 1 || !strings.Contains(stderr, "nicht initialisiert") {
t.Errorf("code=%d stderr=%q", code, stderr)
}
if _, err := os.Stat(dir); err == nil {
t.Error("Datenverzeichnis wurde angelegt")
}
}
func TestToken_TenantAddAndList(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde", "--tier=pro", "--max-routes=20", "--valid-until=2027-12-31")
st := openStore(t, dir)
tn, err := st.GetTenant(context.Background(), "kunde")
if err != nil {
t.Fatal(err)
}
if tn.Tier != store.TierPro || tn.MaxRoutes != 20 || tn.Status != store.TenantActive ||
!tn.ValidUntil.Equal(time.Date(2027, 12, 31, 0, 0, 0, 0, time.UTC)) ||
strings.Join(tn.AllowedDomains, ",") != "kunde.de,*.kunde.de" {
t.Errorf("gespeichert: %+v", tn)
}
out := mustTok(t, dir, "tenant", "list")
for _, want := range []string{"ID", "kunde", "pro", "active", "2027-12-31", "*.kunde.de"} {
if !strings.Contains(out, want) {
t.Errorf("Liste ohne %q:\n%s", want, out)
}
}
}
func TestToken_TenantAddDefaults(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "homelab")
tn, _ := openStore(t, dir).GetTenant(context.Background(), "homelab")
if tn.Tier != store.TierCommunity || tn.MaxRoutes != 5 {
t.Errorf("Defaults: tier=%s max_routes=%d", tn.Tier, tn.MaxRoutes)
}
if d := time.Until(tn.ValidUntil); d < 364*24*time.Hour || d > 366*24*time.Hour {
t.Errorf("Default-Laufzeit: %v", d)
}
}
func TestToken_TenantAddDomainsNormalized(t *testing.T) {
dir := initDataDir(t)
mustTok(t, dir, "tenant", "add", "--id=k", "--name=K", "--email=k@x.de", "--domains= Shop.DE. , *.Firma.de")
tn, _ := openStore(t, dir).GetTenant(context.Background(), "k")
if strings.Join(tn.AllowedDomains, ",") != "shop.de,*.firma.de" {
t.Errorf("Domains: %v", tn.AllowedDomains)
}
}
func TestToken_TenantAddErrors(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
tests := []struct {
name string
args []string
code int
}{
{"doppelt", []string{"tenant", "add", "--id=kunde", "--name=K", "--email=k@x.de", "--domains=k.de"}, 1},
{"ID fehlt", []string{"tenant", "add", "--name=K", "--email=k@x.de", "--domains=k.de"}, 2},
{"Domains fehlen", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de"}, 2},
{"Domain ungültig", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de", "--domains=*.de"}, 1},
{"ID ungültig", []string{"tenant", "add", "--id=a__b", "--name=K", "--email=k@x.de", "--domains=k.de"}, 1},
{"Tier ungültig", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de", "--domains=k.de", "--tier=gold"}, 1},
{"Datum ungültig", []string{"tenant", "add", "--id=x", "--name=K", "--email=k@x.de", "--domains=k.de", "--valid-until=morgen"}, 2},
}
for _, tt := range tests {
if code, _, stderr := tok(t, dir, tt.args...); code != tt.code {
t.Errorf("%s: code=%d (erwartet %d) stderr=%q", tt.name, code, tt.code, stderr)
}
}
}
func TestToken_TenantSetStatusAndDelete(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
mustTok(t, dir, "tenant", "set-status", "--id=kunde", "--status=suspended_payment")
tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde")
if tn.Status != store.TenantSuspendedPayment {
t.Errorf("Status = %s", tn.Status)
}
if code, _, _ := tok(t, dir, "tenant", "set-status", "--id=kunde", "--status=pausiert"); code != 1 {
t.Errorf("ungültiger Status: code=%d", code)
}
if code, _, _ := tok(t, dir, "tenant", "set-status", "--id=fehlt", "--status=active"); code != 1 {
t.Errorf("unbekannter Mandant: code=%d", code)
}
mustTok(t, dir, "tenant", "delete", "--id=kunde")
if _, err := openStore(t, dir).GetTenant(context.Background(), "kunde"); err == nil {
t.Error("Mandant nicht gelöscht")
}
if code, _, _ := tok(t, dir, "tenant", "delete", "--id=kunde"); code != 1 {
t.Errorf("zweites Löschen: code=%d", code)
}
}
// Die Einladung muss den Fingerprint des echten Server-Zertifikats tragen
// und beim Server einlösbar sein.
func TestToken_Invite(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
code, stdout, stderr := tok(t, dir, "invite", "--tenant=kunde")
if code != 0 {
t.Fatalf("invite: code=%d stderr=%s", code, stderr)
}
lines := strings.Split(strings.TrimSpace(stdout), "\n")
if len(lines) != 1 {
t.Fatalf("stdout muss genau den Token enthalten: %q", stdout)
}
inv, err := crypto.ParseEnrollmentToken(lines[0])
if err != nil {
t.Fatalf("Token nicht lesbar: %v", err)
}
cert, _ := crypto.LoadOrCreateServerCert(dir)
if inv.Fingerprint != cert.Fingerprint {
t.Error("Token trägt nicht den Fingerprint des Server-Zertifikats")
}
if !strings.Contains(stderr, "kunde") {
t.Errorf("Hinweis auf stderr fehlt: %q", stderr)
}
st := openStore(t, dir)
if _, err := st.Enroll(context.Background(), inv.Secret, store.Node{ID: "n1", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"}); err != nil {
t.Errorf("Token nicht einlösbar: %v", err)
}
}
func TestToken_InviteErrors(t *testing.T) {
dir := initDataDir(t)
if code, _, _ := tok(t, dir, "invite", "--tenant=fehlt"); code != 1 {
t.Errorf("unbekannter Mandant: code=%d", code)
}
if code, _, _ := tok(t, dir, "invite"); code != 2 {
t.Errorf("ohne --tenant: code=%d", code)
}
addTenant(t, dir, "kunde")
for _, ttl := range []string{"--ttl=0s", "--ttl=-1h", "--ttl=bald"} {
if code, _, _ := tok(t, dir, "invite", "--tenant=kunde", ttl); code != 2 {
t.Errorf("%s: code=%d", ttl, code)
}
}
if code, _, _ := tok(t, dir, "invite", "--tenant=kunde", "--ttl=7d"); code != 0 {
t.Errorf("--ttl=7d (Tage) nicht akzeptiert: code=%d", code)
}
}
func TestToken_Nodes(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
st := openStore(t, dir)
if _, err := st.CreateNode(context.Background(), store.Node{ID: "node-1", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "192.168.1.20"}); err != nil {
t.Fatal(err)
}
out := mustTok(t, dir, "node", "list")
for _, want := range []string{"ID", "node-1", "kunde", "pi", "online", "192.168.1.20"} {
if !strings.Contains(out, want) {
t.Errorf("Liste ohne %q:\n%s", want, out)
}
}
if out := mustTok(t, dir, "node", "list", "--tenant=andere"); strings.Contains(out, "node-1") {
t.Errorf("Filter nach Mandant greift nicht:\n%s", out)
}
mustTok(t, dir, "node", "drain", "--id=node-1")
if n, _ := st.GetNode(context.Background(), "node-1"); n.Status != store.NodeDrained {
t.Errorf("drain: %s", n.Status)
}
mustTok(t, dir, "node", "resume", "--id=node-1")
if n, _ := st.GetNode(context.Background(), "node-1"); n.Status != store.NodeOffline {
t.Errorf("resume nach drain: %s (erwartet offline; online setzt erst der Stream)", n.Status)
}
mustTok(t, dir, "node", "delete", "--id=node-1")
if _, err := st.GetNode(context.Background(), "node-1"); err == nil {
t.Error("Knoten nicht gelöscht")
}
if code, _, _ := tok(t, dir, "node", "drain", "--id=fehlt"); code != 1 {
t.Errorf("unbekannter Knoten: code=%d", code)
}
}
func TestToken_LicenseIssue(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde", "--tier=enterprise", "--max-routes=-1", "--valid-until=2027-06-30")
stdout := mustTok(t, dir, "license", "issue", "--tenant=kunde")
master, err := auth.ReadPrivateKey(filepath.Join(dir, "master.key"))
if err != nil {
t.Fatal(err)
}
l, err := license.Parse([]byte(stdout), master.Public().(ed25519.PublicKey))
if err != nil {
t.Fatalf("Lizenz ungültig: %v\n%s", err, stdout)
}
if l.TenantID != "kunde" || l.Tier != license.TierEnterprise || l.MaxRoutes != -1 || !l.AllowsDomain("www.kunde.de") {
t.Errorf("Lizenz: %+v", l)
}
if !json.Valid([]byte(stdout)) {
t.Error("stdout ist kein reines JSON")
}
if code, _, _ := tok(t, dir, "license", "issue", "--tenant=fehlt"); code != 1 {
t.Errorf("unbekannter Mandant: code=%d", code)
}
}
func TestToken_Purge(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
inv := strings.TrimSpace(mustTok(t, dir, "invite", "--tenant=kunde"))
parsed, _ := crypto.ParseEnrollmentToken(inv)
st := openStore(t, dir)
st.Enroll(context.Background(), parsed.Secret, store.Node{ID: "n1", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"})
out := mustTok(t, dir, "purge")
if !strings.Contains(out, "1") {
t.Errorf("purge-Ausgabe: %q", out)
}
}
// Funktioniert auch, während der Server läuft (gleiche SQLite-DB, WAL).
func TestToken_WhileServerRuns(t *testing.T) {
dir := initDataDir(t)
s, err := server.New(context.Background(), server.Config{
DataDir: dir, AgentAddr: "127.0.0.1:0", ProviderAddr: "127.0.0.1:0", LanderAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatal(err)
}
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Shutdown(context.Background())
addTenant(t, dir, "kunde")
inv := strings.TrimSpace(mustTok(t, dir, "invite", "--tenant=kunde"))
parsed, err := crypto.ParseEnrollmentToken(inv)
if err != nil || parsed.Fingerprint != s.Fingerprint() {
t.Errorf("Token passt nicht zum laufenden Server: %v", err)
}
}
func setPublicURL(t *testing.T, dir, url string) {
t.Helper()
s, err := server.New(context.Background(), server.Config{DataDir: dir, PublicURLs: []string{url, "https://100.64.0.1:8443"}, Logger: slog.New(slog.DiscardHandler)})
if err != nil {
t.Fatal(err)
}
s.Shutdown(context.Background())
}
func TestToken_InviteEmbedsPublicURL(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
// Ohne öffentliche Adresse: Token ohne URL, Hinweis auf --public-url.
code, stdout, stderr := tok(t, dir, "invite", "--tenant=kunde")
inv, _ := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout))
if code != 0 || inv.ServerURL != "" || !strings.Contains(stderr, "--public-url") {
t.Errorf("ohne Adresse: code=%d url=%q stderr=%q", code, inv.ServerURL, stderr)
}
setPublicURL(t, dir, "https://ingress.example:8443")
code, stdout, stderr = tok(t, dir, "invite", "--tenant=kunde")
inv, err := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout))
if code != 0 || err != nil || inv.ServerURL != "https://ingress.example:8443" {
t.Errorf("mit gemerkter Adresse: code=%d %+v %v", code, inv, err)
}
if !strings.Contains(stderr, "SULTAN_TOKEN=") {
t.Errorf("Hinweis ohne fertige Befehlszeile: %q", stderr)
}
// Pro Einladung überschreibbar, z. B. NetBird-Adresse für diesen Knoten.
_, stdout, _ = tok(t, dir, "invite", "--tenant=kunde", "--server-url=https://100.64.0.1:8443")
if inv, _ := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout)); inv.ServerURL != "https://100.64.0.1:8443" {
t.Errorf("--server-url: %+v", inv)
}
if code, _, _ := tok(t, dir, "invite", "--tenant=kunde", "--server-url=http://x:8443"); code != 2 {
t.Errorf("ungültige --server-url: code=%d", code)
}
}
func TestToken_NodeMaintenance(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
st := openStore(t, dir)
st.CreateNode(context.Background(), store.Node{ID: "pi", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "1.2.3.4"})
out := mustTok(t, dir, "node", "maintenance", "--id=pi", "--for=2h", "--reason=Kernel-Update", "--public-reason=Sicherheitsupdate")
if !strings.Contains(out, "pi") {
t.Errorf("Ausgabe: %q", out)
}
n, _ := st.GetNode(context.Background(), "pi")
if m := n.Maintenance; m == nil || m.Reason != "Kernel-Update" || m.PublicReason != "Sicherheitsupdate" ||
time.Until(m.Until) < 119*time.Minute || time.Until(m.Until) > 121*time.Minute {
t.Errorf("gespeichert: %+v", n.Maintenance)
}
if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "MAINTENANCE") || !strings.Contains(list, "bis ") {
t.Errorf("Liste ohne Wartung:\n%s", list)
}
mustTok(t, dir, "node", "maintenance", "--id=pi") // Default 4h
if n, _ := st.GetNode(context.Background(), "pi"); time.Until(n.Maintenance.Until) < 239*time.Minute {
t.Errorf("Default-Dauer: %v", time.Until(n.Maintenance.Until))
}
mustTok(t, dir, "node", "maintenance", "--id=pi", "--for=0")
if n, _ := st.GetNode(context.Background(), "pi"); !n.Maintenance.Until.IsZero() {
t.Error("--for=0 nicht unbefristet")
}
if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "unbefristet") {
t.Errorf("Liste:\n%s", list)
}
mustTok(t, dir, "node", "resume", "--id=pi")
if n, _ := st.GetNode(context.Background(), "pi"); n.Maintenance != nil {
t.Error("resume beendet die Wartung nicht")
}
for _, args := range [][]string{
{"node", "maintenance", "--id=pi", "--for=bald"},
{"node", "maintenance", "--id=pi", "--for=-1h"},
{"node", "maintenance"},
} {
if code, _, _ := tok(t, dir, args...); code != 2 {
t.Errorf("%v: code=%d", args, code)
}
}
if code, _, _ := tok(t, dir, "node", "maintenance", "--id=fehlt"); code != 1 {
t.Errorf("unbekannter Knoten: code=%d", code)
}
}
func TestToken_NodeCommand(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
st := openStore(t, dir)
st.CreateNode(context.Background(), store.Node{ID: "pi", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "1.2.3.4"})
mustTok(t, dir, "node", "command", "--id=pi", "--cmd=rotate_key", "--reason=Routine")
mustTok(t, dir, "node", "drain", "--id=pi") // erzeugt zusätzlich einen Hinweis an den Agent
cmds, _ := st.PendingNodeCommands(context.Background(), "pi")
if len(cmds) != 2 || cmds[0].Command != "rotate_key" || cmds[0].Reason != "Routine" || cmds[1].Command != "drain" {
t.Errorf("Warteschlange: %+v", cmds)
}
if code, _, _ := tok(t, dir, "node", "command", "--id=pi", "--cmd=rm -rf /"); code != 2 {
t.Errorf("beliebiger Befehl: code=%d", code)
}
if code, _, _ := tok(t, dir, "node", "command", "--id=fehlt", "--cmd=resync"); code != 1 {
t.Errorf("unbekannter Knoten: code=%d", code)
}
}
func TestToken_NodeListShowsLease(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde")
st := openStore(t, dir)
st.CreateNode(context.Background(), store.Node{ID: "pi", TenantID: "kunde", Hostname: "pi", PublicKey: "k", IPAddress: "1.2.3.4"})
if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "LEASE") {
t.Errorf("Spalte LEASE fehlt:\n%s", list)
}
st.PutLease(context.Background(), store.Lease{NodeID: "pi", IssuedAt: time.Now(), RenewAfter: time.Now().Add(time.Hour),
ValidUntil: time.Date(2030, 1, 2, 3, 4, 0, 0, time.Local), Document: "{}"})
if list := mustTok(t, dir, "node", "list"); !strings.Contains(list, "2030-01-02 03:04") {
t.Errorf("Lease-Ende fehlt:\n%s", list)
}
}
func TestToken_TenantAddFeatures(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde", "--features=custom_middleware,websockets")
tn, _ := openStore(t, dir).GetTenant(context.Background(), "kunde")
if strings.Join(tn.Features, ",") != "custom_middleware,websockets" {
t.Errorf("Features: %v", tn.Features)
}
if code, _, _ := tok(t, dir, "tenant", "add", "--id=x", "--name=x", "--email=x@x.de", "--domains=x.de", "--features=teleport"); code != 1 {
t.Errorf("unbekanntes Feature: code=%d", code)
}
}
func TestToken_TenantPayment(t *testing.T) {
dir := initDataDir(t)
addTenant(t, dir, "kunde", "--tier=pro")
st := openStore(t, dir)
mustTok(t, dir, "tenant", "payment", "--id=kunde", "--overdue-since=2026-09-01")
if tn, _ := st.GetTenant(context.Background(), "kunde"); !tn.PaymentOverdueSince.Equal(time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) {
t.Errorf("offen seit: %v", tn.PaymentOverdueSince)
}
if list := mustTok(t, dir, "tenant", "list"); !strings.Contains(list, "PAYMENT") || !strings.Contains(list, "offen seit 2026-09-01") {
t.Errorf("Liste ohne Zahlungsstatus:\n%s", list)
}
mustTok(t, dir, "tenant", "payment", "--id=kunde", "--paid")
if tn, _ := st.GetTenant(context.Background(), "kunde"); !tn.PaymentOverdueSince.IsZero() {
t.Error("--paid setzt nicht zurück")
}
for _, args := range [][]string{
{"tenant", "payment", "--id=kunde"},
{"tenant", "payment", "--id=kunde", "--paid", "--overdue-since=2026-09-01"},
{"tenant", "payment", "--id=kunde", "--overdue-since=gestern"},
} {
if code, _, _ := tok(t, dir, args...); code != 2 {
t.Errorf("%v: code=%d", args, code)
}
}
if code, _, _ := tok(t, dir, "tenant", "payment", "--id=fehlt", "--paid"); code != 1 {
t.Errorf("unbekannter Mandant: code=%d", code)
}
}
func TestToken_DataDirFromEnvironment(t *testing.T) {
dir := initDataDir(t)
t.Setenv("SULTAN_DATA_DIR", dir)
if code, _, stderr := run(context.Background(), "token", "tenant", "list"); code != 0 {
t.Errorf("code=%d stderr=%s", code, stderr)
}
}
+6 -6
View File
@@ -3,12 +3,12 @@
//
// - Server: selbstsigniertes ECDSA-P-256-Zertifikat, einmalig erzeugt und
// dauerhaft im Datenverzeichnis abgelegt; nur TLS 1.3.
// - Agent: Zertifikats-Pinning über den SHA-256-Fingerprint
// (known_servers). Einen blinden Erstkontakt gibt es nicht: Beim ersten
// Verbinden muss der Fingerprint aus dem Enrollment-Token passen.
// - Enrollment-Token: sltn_enroll_<secret>.<fingerprint>. Der Server
// speichert nur das Secret (gehasht), der Agent nutzt den Fingerprint
// für den Erstkontakt.
// - Agent: Zertifikats-Pinning über den SHA-256-Fingerprint, unabhängig
// von der Adresse. Einen blinden Erstkontakt gibt es nicht: Der
// Fingerprint kommt aus der Einladung, danach aus node.json.
// - Enrollment-Token: sltn_enroll_<secret>.<fingerprint>[.<server-url>].
// Der Server speichert nur das Secret (gehasht), der Agent nutzt
// Fingerprint und Adresse für den Erstkontakt.
package crypto
import (
+21 -118
View File
@@ -1,142 +1,45 @@
package crypto
import (
"bufio"
"bytes"
"crypto/tls"
"errors"
"fmt"
"os"
"strings"
"sync"
)
var (
// ErrUnknownServer: Kein Pin und kein erwarteter Fingerprint – ein
// ungeprüfter Erstkontakt wird verweigert.
ErrUnknownServer = errors.New("crypto: unbekannter Server – Fingerprint aus dem Enrollment-Token erforderlich")
// ErrFingerprintMismatch: Das Server-Zertifikat passt nicht zum Pin bzw.
// zum erwarteten Fingerprint (möglicher Man-in-the-Middle).
// ErrUnknownServer: Kein Fingerprint bekannt – ein ungeprüfter Kontakt
// wird verweigert (kein blindes TOFU).
ErrUnknownServer = errors.New("crypto: unbekannter Server – Fingerprint aus der Einladung erforderlich")
// ErrFingerprintMismatch: Das Server-Zertifikat passt nicht zum Pin
// (möglicher Man-in-the-Middle).
ErrFingerprintMismatch = errors.New("crypto: Server-Fingerprint stimmt nicht – möglicher Man-in-the-Middle")
// ErrCorruptKnownServers: Die known_servers-Datei ist nicht lesbar.
ErrCorruptKnownServers = errors.New("crypto: known_servers beschädigt")
)
// KnownServers ist die Pin-Datei des Agenten (analog zu SSH known_hosts).
// Format: eine Zeile "<server> <fingerprint>", # für Kommentare. Einträge
// werden nur angehängt, nie stillschweigend geändert.
type KnownServers struct {
Path string
mu sync.Mutex
}
// Lookup liefert den Pin für server. Eine fehlende Datei bedeutet: kein Pin.
func (k *KnownServers) Lookup(server string) (Fingerprint, bool, error) {
k.mu.Lock()
defer k.mu.Unlock()
return k.lookup(server)
}
func (k *KnownServers) lookup(server string) (Fingerprint, bool, error) {
data, err := os.ReadFile(k.Path)
if errors.Is(err, os.ErrNotExist) {
return "", false, nil
}
if err != nil {
return "", false, err
}
sc := bufio.NewScanner(bytes.NewReader(data))
for line := 1; sc.Scan(); line++ {
text := strings.TrimSpace(sc.Text())
if text == "" || strings.HasPrefix(text, "#") {
continue
}
fields := strings.Fields(text)
if len(fields) != 2 {
return "", false, fmt.Errorf("%w: %s Zeile %d", ErrCorruptKnownServers, k.Path, line)
}
fp, err := ParseFingerprint(fields[1])
if err != nil {
return "", false, fmt.Errorf("%w: %s Zeile %d: %w", ErrCorruptKnownServers, k.Path, line, err)
}
if fields[0] == server {
return fp, true, nil
}
}
return "", false, sc.Err()
}
// Add pinnt fp für server. Ist bereits ein anderer Fingerprint gepinnt,
// liefert Add ErrFingerprintMismatch und ändert nichts.
func (k *KnownServers) Add(server string, fp Fingerprint) error {
if len(strings.Fields(server)) != 1 || strings.TrimSpace(server) != server {
return fmt.Errorf("crypto: ungültiger Servername %q", server)
}
if _, err := ParseFingerprint(string(fp)); err != nil {
return err
}
k.mu.Lock()
defer k.mu.Unlock()
pinned, ok, err := k.lookup(server)
if err != nil {
return err
}
if ok {
if pinned != fp {
return fmt.Errorf("%w: %s ist auf %s gepinnt, nicht %s", ErrFingerprintMismatch, server, pinned, fp)
}
return nil
}
f, err := os.OpenFile(k.Path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
if err != nil {
return err
}
_, err = fmt.Fprintf(f, "%s %s\n", server, fp)
if cerr := f.Close(); err == nil {
err = cerr
}
return err
}
// ClientTLSConfig liefert die TLS-Konfiguration des Agenten für server
// (host:port aus der --server-URL). Statt einer CA prüft sie den
// Fingerprint:
// PinnedTLSConfig liefert die TLS-Konfiguration des Agenten. Statt einer CA
// wird der Fingerprint des Server-Zertifikats geprüft – unabhängig von der
// Adresse, über die der Server erreicht wird (DNS, interne IP, NetBird):
// Derselbe Server hat überall denselben Fingerprint.
//
// - Pin vorhanden: Das Zertifikat muss dazu passen (expected wird ignoriert).
// - Kein Pin, expected gesetzt (Fingerprint aus dem Enrollment-Token):
// Das Zertifikat muss dazu passen und wird dann gepinnt.
// - Kein Pin, kein expected: Verbindung wird verweigert (ErrUnknownServer).
//
// Die Prüfung läuft im TLS-Handshake, also bevor irgendein Request-Byte
// (Token, Signatur, Body) gesendet wird. Nur TLS 1.3.
func ClientTLSConfig(server string, ks *KnownServers, expected Fingerprint) *tls.Config {
// Beim Erstkontakt kommt want aus der Einladung, danach aus node.json. Die
// Prüfung läuft im TLS-Handshake, also bevor irgendein Request-Byte (Token,
// Signatur, Body) gesendet wird. Nur TLS 1.3.
func PinnedTLSConfig(want Fingerprint) *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS13,
// Die CA-Prüfung entfällt bewusst; VerifyConnection ersetzt sie
// durch die strengere Fingerprint-Prüfung.
// Die CA- und Hostnamen-Prüfung entfällt bewusst; VerifyConnection
// ersetzt sie durch die strengere Fingerprint-Prüfung.
InsecureSkipVerify: true,
VerifyConnection: func(cs tls.ConnectionState) error {
if want == "" {
return ErrUnknownServer
}
if len(cs.PeerCertificates) == 0 {
return fmt.Errorf("%w: kein Zertifikat", ErrFingerprintMismatch)
}
actual := FingerprintOf(cs.PeerCertificates[0])
pinned, ok, err := ks.Lookup(server)
if err != nil {
return err
if got := FingerprintOf(cs.PeerCertificates[0]); got != want {
return fmt.Errorf("%w: Server präsentiert %s, erwartet %s", ErrFingerprintMismatch, got, want)
}
if ok {
if actual != pinned {
return fmt.Errorf("%w: %s präsentiert %s, gepinnt ist %s", ErrFingerprintMismatch, server, actual, pinned)
}
return nil
}
if expected == "" {
return fmt.Errorf("%w (%s präsentiert %s)", ErrUnknownServer, server, actual)
}
if actual != expected {
return fmt.Errorf("%w: %s präsentiert %s, Token erwartet %s", ErrFingerprintMismatch, server, actual, expected)
}
return ks.Add(server, actual)
return nil
},
}
}
+33 -134
View File
@@ -7,78 +7,10 @@ import (
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
)
func newKnownServers(t *testing.T) *KnownServers {
t.Helper()
return &KnownServers{Path: filepath.Join(t.TempDir(), "known_servers")}
}
func TestKnownServers(t *testing.T) {
ks := newKnownServers(t)
sc, _ := LoadOrCreateServerCert(t.TempDir())
other, _ := LoadOrCreateServerCert(t.TempDir())
if _, ok, err := ks.Lookup("ingress:8443"); ok || err != nil {
t.Fatalf("leere Datei: ok=%v err=%v", ok, err)
}
if err := ks.Add("ingress:8443", sc.Fingerprint); err != nil {
t.Fatalf("Add: %v", err)
}
if fp, ok, err := ks.Lookup("ingress:8443"); !ok || err != nil || fp != sc.Fingerprint {
t.Errorf("Lookup = %q, %v, %v", fp, ok, err)
}
if err := ks.Add("ingress:8443", sc.Fingerprint); err != nil {
t.Errorf("gleichen Eintrag erneut hinzufügen: %v", err)
}
if err := ks.Add("ingress:8443", other.Fingerprint); !errors.Is(err, ErrFingerprintMismatch) {
t.Errorf("Pin überschreiben: erwartet ErrFingerprintMismatch, bekommen %v", err)
}
if err := ks.Add("anderer:8443", other.Fingerprint); err != nil {
t.Errorf("zweiter Server: %v", err)
}
data, _ := os.ReadFile(ks.Path)
if n := strings.Count(string(data), "ingress:8443"); n != 1 {
t.Errorf("ingress:8443 steht %d-mal in der Datei", n)
}
info, _ := os.Stat(ks.Path)
if info.Mode().Perm() != 0o600 {
t.Errorf("Dateirechte %o, erwartet 600", info.Mode().Perm())
}
}
func TestKnownServers_FileFormat(t *testing.T) {
ks := newKnownServers(t)
sc, _ := LoadOrCreateServerCert(t.TempDir())
content := "# Kommentar\n\n ingress:8443 " + string(sc.Fingerprint) + " \n"
os.WriteFile(ks.Path, []byte(content), 0o600)
if fp, ok, err := ks.Lookup("ingress:8443"); !ok || err != nil || fp != sc.Fingerprint {
t.Errorf("Lookup = %q, %v, %v", fp, ok, err)
}
for name, bad := range map[string]string{
"ein Feld": "ingress:8443\n",
"drei Felder": "ingress:8443 " + string(sc.Fingerprint) + " x\n",
"kaputter Fingerprint": "ingress:8443 abc\n",
} {
t.Run(name, func(t *testing.T) {
os.WriteFile(ks.Path, []byte(bad), 0o600)
if _, _, err := ks.Lookup("ingress:8443"); !errors.Is(err, ErrCorruptKnownServers) {
t.Errorf("erwartet ErrCorruptKnownServers, bekommen %v", err)
}
})
}
if err := ks.Add("mit leerzeichen", sc.Fingerprint); err == nil {
t.Error("Servername mit Leerzeichen akzeptiert")
}
}
// tlsServer startet einen Test-Server mit dem Zertifikat aus einem eigenen
// Datenverzeichnis und zählt, ob ein Request ihn erreicht.
func tlsServer(t *testing.T) (*httptest.Server, ServerCert, *atomic.Int32) {
@@ -98,96 +30,63 @@ func tlsServer(t *testing.T) (*httptest.Server, ServerCert, *atomic.Int32) {
return srv, sc, &hits
}
func get(t *testing.T, srv *httptest.Server, cfg *tls.Config) error {
func get(t *testing.T, url string, cfg *tls.Config) error {
t.Helper()
c := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}}
resp, err := c.Get(srv.URL)
resp, err := c.Get(url)
if err == nil {
resp.Body.Close()
}
return err
}
func TestClientTLS_FirstContactWithExpectedFingerprint(t *testing.T) {
func TestPinnedTLS_Matches(t *testing.T) {
srv, sc, hits := tlsServer(t)
ks := newKnownServers(t)
addr := srv.Listener.Addr().String()
if err := get(t, srv, ClientTLSConfig(addr, ks, sc.Fingerprint)); err != nil {
t.Fatalf("erster Kontakt mit korrektem Fingerprint: %v", err)
if err := get(t, srv.URL, PinnedTLSConfig(sc.Fingerprint)); err != nil {
t.Fatalf("passender Fingerprint: %v", err)
}
if hits.Load() != 1 {
t.Error("Request kam nicht an")
}
if fp, ok, _ := ks.Lookup(addr); !ok || fp != sc.Fingerprint {
t.Errorf("Pin nicht gespeichert: %q %v", fp, ok)
}
// Folgekontakt ohne Token-Fingerprint: der Pin genügt.
if err := get(t, srv, ClientTLSConfig(addr, ks, "")); err != nil {
t.Errorf("Folgekontakt mit Pin: %v", err)
}
// Der Pin hängt am Server, nicht an der Adresse: derselbe Server unter einer
// anderen Adresse (hier localhost statt 127.0.0.1) ist weiterhin vertrauenswürdig.
func TestPinnedTLS_AddressIndependent(t *testing.T) {
srv, sc, _ := tlsServer(t)
port := srv.Listener.Addr().(interface{ String() string }).String()
port = port[len("127.0.0.1"):]
if err := get(t, "https://localhost"+port, PinnedTLSConfig(sc.Fingerprint)); err != nil {
t.Errorf("andere Adresse, gleicher Server: %v", err)
}
}
// Kein blindes TOFU: Ohne Pin und ohne erwarteten Fingerprint wird nicht verbunden.
func TestClientTLS_NoBlindFirstContact(t *testing.T) {
srv, _, hits := tlsServer(t)
ks := newKnownServers(t)
addr := srv.Listener.Addr().String()
if err := get(t, srv, ClientTLSConfig(addr, ks, "")); !errors.Is(err, ErrUnknownServer) {
t.Errorf("erwartet ErrUnknownServer, bekommen %v", err)
}
if hits.Load() != 0 {
t.Error("Request hat den Server erreicht")
}
if _, ok, _ := ks.Lookup(addr); ok {
t.Error("unbekannter Server wurde gepinnt")
}
}
// Angreifer beim Erstkontakt: Er präsentiert sein eigenes Zertifikat, der
// Token enthält aber den Fingerprint des echten Servers.
func TestClientTLS_MITMOnFirstContact(t *testing.T) {
// Angreifer mit eigenem Zertifikat: Abbruch im Handshake, kein Request
// (also auch kein Token, keine Signatur) erreicht ihn.
func TestPinnedTLS_Mismatch(t *testing.T) {
mitm, _, hits := tlsServer(t)
real, _ := LoadOrCreateServerCert(t.TempDir())
ks := newKnownServers(t)
addr := mitm.Listener.Addr().String()
if err := get(t, mitm, ClientTLSConfig(addr, ks, real.Fingerprint)); !errors.Is(err, ErrFingerprintMismatch) {
if err := get(t, mitm.URL, PinnedTLSConfig(real.Fingerprint)); !errors.Is(err, ErrFingerprintMismatch) {
t.Errorf("erwartet ErrFingerprintMismatch, bekommen %v", err)
}
if hits.Load() != 0 {
t.Error("Request (inkl. Token) hat den Angreifer erreicht")
}
if _, ok, _ := ks.Lookup(addr); ok {
t.Error("Angreifer wurde gepinnt")
}
}
// Angreifer nach dem Erstkontakt: Der Pin gewinnt, auch wenn ein (neuer)
// Token-Fingerprint auf den Angreifer passen würde.
func TestClientTLS_PinWinsLater(t *testing.T) {
mitm, mitmCert, hits := tlsServer(t)
real, _ := LoadOrCreateServerCert(t.TempDir())
ks := newKnownServers(t)
addr := mitm.Listener.Addr().String()
if err := ks.Add(addr, real.Fingerprint); err != nil {
t.Fatal(err)
}
for _, expected := range []Fingerprint{"", mitmCert.Fingerprint} {
if err := get(t, mitm, ClientTLSConfig(addr, ks, expected)); !errors.Is(err, ErrFingerprintMismatch) {
t.Errorf("expected=%q: erwartet ErrFingerprintMismatch, bekommen %v", expected, err)
}
}
if hits.Load() != 0 {
t.Error("Request hat den Angreifer erreicht")
}
}
func TestClientTLSConfig_OnlyTLS13(t *testing.T) {
cfg := ClientTLSConfig("x:1", newKnownServers(t), "")
if cfg.MinVersion != tls.VersionTLS13 {
t.Errorf("MinVersion = %x, erwartet TLS 1.3", cfg.MinVersion)
// Kein blindes Vertrauen: ohne erwarteten Fingerprint keine Verbindung.
func TestPinnedTLS_NoFingerprint(t *testing.T) {
srv, _, hits := tlsServer(t)
if err := get(t, srv.URL, PinnedTLSConfig("")); !errors.Is(err, ErrUnknownServer) {
t.Errorf("erwartet ErrUnknownServer, bekommen %v", err)
}
if hits.Load() != 0 {
t.Error("Request erreichte den Server")
}
}
func TestPinnedTLSConfig_OnlyTLS13(t *testing.T) {
if cfg := PinnedTLSConfig("x"); cfg.MinVersion != tls.VersionTLS13 {
t.Errorf("MinVersion = %x", cfg.MinVersion)
}
}
+89 -12
View File
@@ -5,49 +5,80 @@ import (
"encoding/base64"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
)
// TokenPrefix kennzeichnet Enrollment-Tokens.
const TokenPrefix = "sltn_enroll_"
// ErrInvalidToken: Der Token-String hat nicht das Format
// sltn_enroll_<secret>.<fingerprint>.
var ErrInvalidToken = errors.New("crypto: ungültiger Enrollment-Token")
var (
// ErrInvalidToken: Der Token-String hat nicht das Format
// sltn_enroll_<secret>.<fingerprint>[.<server-url>].
ErrInvalidToken = errors.New("crypto: ungültiger Enrollment-Token")
// ErrInvalidServerURL: keine reine https://host[:port]-Adresse.
ErrInvalidServerURL = errors.New("crypto: ungültige Server-URL")
)
const maxServerURLLen = 255
// EnrollmentToken ist die Einladung für einen neuen Agenten.
//
// Secret (inkl. Präfix) ist der geheime Teil: Der Server speichert davon nur
// den Hash (store.AddEnrollmentToken), der Agent schickt es beim Enrollment.
// Fingerprint ist nicht geheim; er sagt dem Agenten, welches
// Server-Zertifikat er beim Erstkontakt erwarten muss.
// Server-Zertifikat er erwarten muss. ServerURL (optional, nicht geheim) ist
// die Adresse, unter der der Agent den Server erreicht – eine falsche
// Adresse führt dank Fingerprint-Prüfung höchstens zu einem
// Verbindungsfehler, nie zu einem falschen Server.
type EnrollmentToken struct {
Secret string
Fingerprint Fingerprint
ServerURL string
}
// String liefert die Form, die der Admin an den Agenten weitergibt.
func (t EnrollmentToken) String() string { return t.Secret + "." + string(t.Fingerprint) }
// String liefert die Form, die der Admin an den Agenten weitergibt. Alle
// Teile sind Base64url – der Token ist ohne Quoting shell-sicher.
func (t EnrollmentToken) String() string {
s := t.Secret + "." + string(t.Fingerprint)
if t.ServerURL != "" {
s += "." + base64.RawURLEncoding.EncodeToString([]byte(t.ServerURL))
}
return s
}
// NewEnrollmentToken erzeugt einen Token mit 256 Bit Zufall für den Server
// mit Fingerprint fp.
func NewEnrollmentToken(fp Fingerprint) (EnrollmentToken, error) {
// mit Fingerprint fp; serverURL ist optional ("" = nicht einbetten).
func NewEnrollmentToken(fp Fingerprint, serverURL string) (EnrollmentToken, error) {
if _, err := ParseFingerprint(string(fp)); err != nil {
return EnrollmentToken{}, err
}
if serverURL != "" {
u, err := ValidateServerURL(serverURL)
if err != nil {
return EnrollmentToken{}, err
}
serverURL = u
}
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return EnrollmentToken{}, err
}
return EnrollmentToken{Secret: TokenPrefix + base64.RawURLEncoding.EncodeToString(b[:]), Fingerprint: fp}, nil
return EnrollmentToken{
Secret: TokenPrefix + base64.RawURLEncoding.EncodeToString(b[:]),
Fingerprint: fp,
ServerURL: serverURL,
}, nil
}
// ParseEnrollmentToken zerlegt einen Token-String. Leerraum am Rand (beim
// Kopieren) wird ignoriert.
func ParseEnrollmentToken(s string) (EnrollmentToken, error) {
parts := strings.Split(strings.TrimSpace(s), ".")
if len(parts) != 2 {
return EnrollmentToken{}, fmt.Errorf("%w: erwartet %s<secret>.<fingerprint>", ErrInvalidToken, TokenPrefix)
if len(parts) != 2 && len(parts) != 3 {
return EnrollmentToken{}, fmt.Errorf("%w: erwartet %s<secret>.<fingerprint>[.<server-url>]", ErrInvalidToken, TokenPrefix)
}
secret, rawFP := parts[0], parts[1]
body, ok := strings.CutPrefix(secret, TokenPrefix)
@@ -61,5 +92,51 @@ func ParseEnrollmentToken(s string) (EnrollmentToken, error) {
if err != nil {
return EnrollmentToken{}, fmt.Errorf("%w: %w", ErrInvalidToken, err)
}
return EnrollmentToken{Secret: secret, Fingerprint: fp}, nil
tok := EnrollmentToken{Secret: secret, Fingerprint: fp}
if len(parts) == 3 {
raw, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || len(raw) == 0 {
return EnrollmentToken{}, fmt.Errorf("%w: Server-URL beschädigt", ErrInvalidToken)
}
if tok.ServerURL, err = ValidateServerURL(string(raw)); err != nil {
return EnrollmentToken{}, fmt.Errorf("%w: %w", ErrInvalidToken, err)
}
}
return tok, nil
}
// ValidateServerURL prüft eine Server-Adresse und normalisiert sie: nur
// https, nur Host und optional Port (kein Benutzer, Pfad, Query, Fragment),
// Host klein, ohne Schrägstrich am Ende.
func ValidateServerURL(raw string) (string, error) {
bad := func(why string) (string, error) {
return "", fmt.Errorf("%w: %q – %s (erwartet https://host[:port])", ErrInvalidServerURL, raw, why)
}
if len(raw) > maxServerURLLen {
return bad("zu lang")
}
u, err := url.Parse(strings.TrimSpace(raw))
switch {
case err != nil:
return bad("nicht lesbar")
case u.Scheme != "https":
return bad("nur https")
case u.Hostname() == "":
return bad("Host fehlt")
case u.User != nil:
return bad("keine Zugangsdaten in der URL")
case u.Opaque != "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.ForceQuery || u.Fragment != "":
return bad("kein Pfad, keine Query")
}
host := strings.ToLower(u.Hostname())
if port := u.Port(); port != "" {
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
return bad("ungültiger Port")
}
return "https://" + net.JoinHostPort(host, port), nil
}
if strings.Contains(host, ":") {
host = "[" + host + "]"
}
return "https://" + host, nil
}
+78 -4
View File
@@ -1,6 +1,7 @@
package crypto
import (
"encoding/base64"
"errors"
"strings"
"testing"
@@ -24,7 +25,7 @@ func TestFingerprint_ParseRoundTrip(t *testing.T) {
func TestEnrollmentToken_RoundTrip(t *testing.T) {
sc, _ := LoadOrCreateServerCert(t.TempDir())
tok, err := NewEnrollmentToken(sc.Fingerprint)
tok, err := NewEnrollmentToken(sc.Fingerprint, "")
if err != nil {
t.Fatalf("NewEnrollmentToken: %v", err)
}
@@ -52,7 +53,7 @@ func TestEnrollmentToken_Unique(t *testing.T) {
sc, _ := LoadOrCreateServerCert(t.TempDir())
seen := map[string]bool{}
for range 100 {
tok, err := NewEnrollmentToken(sc.Fingerprint)
tok, err := NewEnrollmentToken(sc.Fingerprint, "")
if err != nil {
t.Fatal(err)
}
@@ -65,7 +66,7 @@ func TestEnrollmentToken_Unique(t *testing.T) {
func TestEnrollmentToken_Errors(t *testing.T) {
sc, _ := LoadOrCreateServerCert(t.TempDir())
good, _ := NewEnrollmentToken(sc.Fingerprint)
good, _ := NewEnrollmentToken(sc.Fingerprint, "")
secretPart := strings.TrimPrefix(good.Secret, TokenPrefix)
fp := string(sc.Fingerprint)
@@ -86,7 +87,80 @@ func TestEnrollmentToken_Errors(t *testing.T) {
}
})
}
if _, err := NewEnrollmentToken("kaputt"); !errors.Is(err, ErrInvalidFingerprint) {
if _, err := NewEnrollmentToken("kaputt", ""); !errors.Is(err, ErrInvalidFingerprint) {
t.Errorf("NewEnrollmentToken mit kaputtem Fingerprint: %v", err)
}
}
func TestEnrollmentToken_WithServerURL(t *testing.T) {
sc, _ := LoadOrCreateServerCert(t.TempDir())
tok, err := NewEnrollmentToken(sc.Fingerprint, "https://Ingress.example.de:8443/")
if err != nil {
t.Fatal(err)
}
if tok.ServerURL != "https://ingress.example.de:8443" {
t.Errorf("URL nicht normalisiert: %q", tok.ServerURL)
}
s := tok.String()
if parts := strings.Split(s, "."); len(parts) != 3 {
t.Fatalf("erwartet drei Teile: %q", s)
}
if strings.ContainsAny(s, ":/?=+ ") {
t.Errorf("Token nicht shell-/kopiersicher: %q", s)
}
parsed, err := ParseEnrollmentToken(s)
if err != nil {
t.Fatalf("ParseEnrollmentToken: %v", err)
}
if parsed != tok {
t.Errorf("Round-Trip: %+v vs %+v", parsed, tok)
}
// Ohne URL: zweiteilig wie bisher.
plain, _ := NewEnrollmentToken(sc.Fingerprint, "")
if parsed, err := ParseEnrollmentToken(plain.String()); err != nil || parsed.ServerURL != "" {
t.Errorf("Token ohne URL: %+v %v", parsed, err)
}
if _, err := NewEnrollmentToken(sc.Fingerprint, "http://ingress:8443"); !errors.Is(err, ErrInvalidServerURL) {
t.Errorf("http-URL: %v", err)
}
}
func TestEnrollmentToken_InvalidServerURLPart(t *testing.T) {
sc, _ := LoadOrCreateServerCert(t.TempDir())
base, _ := NewEnrollmentToken(sc.Fingerprint, "")
enc := func(u string) string { return base.String() + "." + base64.RawURLEncoding.EncodeToString([]byte(u)) }
for name, s := range map[string]string{
"kein base64url": base.String() + ".!!!",
"leer": base.String() + ".",
"http": enc("http://ingress:8443"),
"mit Pfad": enc("https://ingress:8443/admin"),
"mit Zugangsdaten": enc("https://user:pw@ingress:8443"),
"zu lang": enc("https://" + strings.Repeat("a", 300) + ".de"),
} {
if _, err := ParseEnrollmentToken(s); !errors.Is(err, ErrInvalidToken) {
t.Errorf("%s: erwartet ErrInvalidToken, bekommen %v", name, err)
}
}
}
func TestValidateServerURL(t *testing.T) {
valid := map[string]string{
"https://ingress.x.de:8443": "https://ingress.x.de:8443",
"https://ingress.x.de:8443/": "https://ingress.x.de:8443",
"https://Ingress.X.de": "https://ingress.x.de",
"https://[fd00::1]:8443": "https://[fd00::1]:8443",
"https://100.64.0.1:8443": "https://100.64.0.1:8443",
}
for in, want := range valid {
if got, err := ValidateServerURL(in); err != nil || got != want {
t.Errorf("ValidateServerURL(%q) = %q, %v; erwartet %q", in, got, err, want)
}
}
for _, bad := range []string{"", "ingress:8443", "http://ingress:8443", "https://", "https://u:p@x:8443",
"https://x:8443/pfad", "https://x:8443?q=1", "https://x:8443#frag", "https://x:99999", "ftp://x"} {
if _, err := ValidateServerURL(bad); !errors.Is(err, ErrInvalidServerURL) {
t.Errorf("ValidateServerURL(%q): erwartet ErrInvalidServerURL, bekommen %v", bad, err)
}
}
}
+140
View File
@@ -0,0 +1,140 @@
// Package docker ist ein minimaler Client für die Docker Engine API über
// den Unix-Socket – genau das, was der Agent braucht: laufende Container
// mit Labels und Ports auflisten und Container-Events verfolgen. Bewusst
// ohne Docker-SDK (schwere Abhängigkeit für zwei Endpunkte).
package docker
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
)
// Container ist ein Eintrag aus GET /containers/json.
type Container struct {
ID string `json:"Id"`
Names []string `json:"Names"`
State string `json:"State"`
Labels map[string]string `json:"Labels"`
Ports []Port `json:"Ports"`
}
// Name liefert den Containernamen ohne führenden Schrägstrich.
func (c Container) Name() string {
if len(c.Names) == 0 {
return c.ID
}
return strings.TrimPrefix(c.Names[0], "/")
}
// Port ist eine Portzuordnung; PublicPort 0 = nicht veröffentlicht.
type Port struct {
IP string `json:"IP,omitempty"`
PrivatePort uint16 `json:"PrivatePort"`
PublicPort uint16 `json:"PublicPort,omitempty"`
Type string `json:"Type"`
}
// Event ist ein Eintrag aus GET /events.
type Event struct {
Type string `json:"Type"`
Action string `json:"Action"`
Actor Actor `json:"Actor"`
}
// Actor beschreibt das betroffene Objekt eines Events.
type Actor struct {
ID string `json:"ID"`
Attributes map[string]string `json:"Attributes,omitempty"`
}
// Client spricht die Engine API über Socket (z. B. /var/run/docker.sock).
type Client struct {
Socket string
}
func (c *Client) http() *http.Client {
return &http.Client{Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", c.Socket)
},
}}
}
func (c *Client) get(ctx context.Context, path string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://docker"+path, nil)
if err != nil {
return nil, err
}
resp, err := c.http().Do(req)
if err != nil {
return nil, fmt.Errorf("docker: %s: %w", path, err)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
resp.Body.Close()
return nil, fmt.Errorf("docker: %s: Status %d: %s", path, resp.StatusCode, strings.TrimSpace(string(body)))
}
return resp, nil
}
// List liefert die laufenden Container.
func (c *Client) List(ctx context.Context) ([]Container, error) {
resp, err := c.get(ctx, "/containers/json")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out []Container
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("docker: Containerliste: %w", err)
}
return out, nil
}
// Events verfolgt Container-Events, bis ctx endet oder der Stream abbricht.
// Der Event-Kanal wird danach geschlossen; errc liefert genau einen Wert
// (ctx.Err() bei Abbruch, sonst den Grund).
func (c *Client) Events(ctx context.Context) (<-chan Event, <-chan error) {
events := make(chan Event)
errc := make(chan error, 1)
go func() {
defer close(events)
filters := url.QueryEscape(`{"type":["container"]}`)
resp, err := c.get(ctx, "/events?filters="+filters)
if err != nil {
errc <- err
return
}
defer resp.Body.Close()
dec := json.NewDecoder(resp.Body)
for {
var ev Event
if err := dec.Decode(&ev); err != nil {
switch {
case ctx.Err() != nil:
errc <- ctx.Err()
case errors.Is(err, io.EOF):
errc <- errors.New("docker: Event-Stream beendet")
default:
errc <- fmt.Errorf("docker: Event-Stream: %w", err)
}
return
}
select {
case events <- ev:
case <-ctx.Done():
errc <- ctx.Err()
return
}
}
}()
return events, errc
}
+154
View File
@@ -0,0 +1,154 @@
package docker
import (
"context"
"encoding/json"
"net"
"net/http"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
// fakeEngine bedient die Docker-API über einen Unix-Socket.
type fakeEngine struct {
path string
events chan Event // was der Server über /events streamt
lastQ chan string
}
func newFakeEngine(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) *fakeEngine {
t.Helper()
// Unix-Socket-Pfade sind kurz begrenzt; TempDir kann zu lang sein.
dir, err := os.MkdirTemp("", "dk")
if err != nil {
t.Fatal(err)
}
f := &fakeEngine{path: filepath.Join(dir, "d.sock"), events: make(chan Event, 8), lastQ: make(chan string, 8)}
ln, err := net.Listen("unix", f.path)
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("/containers/json", handler)
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
f.lastQ <- r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
enc := json.NewEncoder(w)
for {
select {
case <-r.Context().Done():
return
case ev, ok := <-f.events:
if !ok {
return
}
enc.Encode(ev)
w.(http.Flusher).Flush()
}
}
})
srv := &http.Server{Handler: mux}
go srv.Serve(ln)
t.Cleanup(func() { srv.Close(); os.RemoveAll(dir) })
return f
}
const containersJSON = `[
{"Id":"abc","Names":["/wordpress"],"State":"running",
"Labels":{"traefik.enable":"true","traefik.http.routers.wp.rule":"Host(` + "`kunde.de`" + `)"},
"Ports":[{"IP":"0.0.0.0","PrivatePort":80,"PublicPort":8081,"Type":"tcp"},{"PrivatePort":443,"Type":"tcp"}]},
{"Id":"def","Names":["/db"],"State":"running","Labels":{},"Ports":[]}
]`
func TestList(t *testing.T) {
f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(containersJSON))
})
got, err := (&Client{Socket: f.path}).List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
want := []Container{
{
ID: "abc", Names: []string{"/wordpress"}, State: "running",
Labels: map[string]string{"traefik.enable": "true", "traefik.http.routers.wp.rule": "Host(`kunde.de`)"},
Ports: []Port{{IP: "0.0.0.0", PrivatePort: 80, PublicPort: 8081, Type: "tcp"}, {PrivatePort: 443, Type: "tcp"}},
},
{ID: "def", Names: []string{"/db"}, State: "running", Labels: map[string]string{}, Ports: []Port{}},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("List =\n%+v\nerwartet\n%+v", got, want)
}
if got[0].Name() != "wordpress" {
t.Errorf("Name() = %q", got[0].Name())
}
}
func TestList_Errors(t *testing.T) {
f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"message":"kaputt"}`, http.StatusInternalServerError)
})
if _, err := (&Client{Socket: f.path}).List(context.Background()); err == nil || !strings.Contains(err.Error(), "500") {
t.Errorf("Status 500: %v", err)
}
if _, err := (&Client{Socket: "/gibt/es/nicht.sock"}).List(context.Background()); err == nil {
t.Error("fehlender Socket: erwartet Fehler")
}
}
func TestEvents(t *testing.T) {
f := newFakeEngine(t, func(http.ResponseWriter, *http.Request) {})
ctx, cancel := context.WithCancel(context.Background())
events, errc := (&Client{Socket: f.path}).Events(ctx)
q := <-f.lastQ
if !strings.Contains(q, "filters=") || !strings.Contains(q, "container") {
t.Errorf("Events ohne Container-Filter: %q", q)
}
f.events <- Event{Type: "container", Action: "start", Actor: Actor{ID: "abc", Attributes: map[string]string{"name": "wordpress"}}}
f.events <- Event{Type: "container", Action: "die", Actor: Actor{ID: "abc"}}
for _, want := range []string{"start", "die"} {
select {
case ev := <-events:
if ev.Action != want || ev.Actor.ID != "abc" {
t.Errorf("Event = %+v, erwartet %s", ev, want)
}
case <-time.After(2 * time.Second):
t.Fatalf("Event %s nicht empfangen", want)
}
}
cancel()
select {
case _, ok := <-events:
if ok {
t.Error("Kanal nach Abbruch nicht geschlossen")
}
case <-time.After(2 * time.Second):
t.Fatal("Events endet nicht nach Abbruch")
}
if err := <-errc; err != nil && err != context.Canceled {
t.Errorf("Fehler nach Abbruch: %v", err)
}
}
// Endet der Event-Stream serverseitig, meldet Events das über errc.
func TestEvents_StreamEnds(t *testing.T) {
f := newFakeEngine(t, func(http.ResponseWriter, *http.Request) {})
events, errc := (&Client{Socket: f.path}).Events(context.Background())
<-f.lastQ
close(f.events)
for range events {
}
if err := <-errc; err == nil {
t.Error("erwartet Fehler bei beendetem Stream")
}
}
+332
View File
@@ -0,0 +1,332 @@
// Package lander ist der Sultan-Interceptor (Spec 5.2): Traefik leitet
// Router, deren Mandant bzw. Dienst nicht "normal" ist, hierher um und
// teilt die Stufe per Header mit (X-Sultan-Stage, -Tenant, -Tier).
//
// Je Stufe gibt es eine kleine HTML-Seite (Deutsch und Englisch), eingebaut
// oder als eigene html/template-Vorlage aus Dir. CSS und Grafik einer
// eigenen Vorlage werden eingebettet: Bei watermark/firemark ist das Cookie
// sultan_ack schon gesetzt, ein separater Request für Assets ginge über den
// Durchlass-Router ans Backend des Kunden.
package lander
import (
"bytes"
"embed"
"encoding/base64"
"fmt"
"html/template"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
//go:embed templates
var builtinFS embed.FS
// Stages sind die Stufen mit eigener Seite.
var Stages = []string{"watermark", "firemark", "paywall", "landing", "maintenance"}
// MaxAssetSize begrenzt CSS und Grafik einer eigenen Vorlage.
const MaxAssetSize = 512 << 10
// AckCookie muss zu policy.AckCookie passen (Durchlass-Router).
const AckCookie = "sultan_ack"
var builtin = template.Must(template.ParseFS(builtinFS, "templates/*.html"))
// Lander liefert die Seiten aus. Der Nullwert funktioniert (eingebaute
// Seiten, Default-Zeiten); Felder nach der ersten Verwendung nicht ändern.
type Lander struct {
Dir string // eigene Vorlagen (z. B. ./suldata/landing); leer = nur eingebaut
HosterName string // für Einladung (paywall/landing)
HosterURL string // Angebotsseite des Hosters
ProURL string // Pro-Hinweis auf Community-Wartungsseiten
WatermarkDelay time.Duration // Default 5s
FiremarkDelay time.Duration // Default 10s
AckTTL time.Duration // Default 60min: Gültigkeit des Cookies sultan_ack
Location *time.Location // Zeitzone für das angezeigte Wartungsende (Default: lokal)
Logger *slog.Logger
now func() time.Time
mu sync.Mutex
cache map[string]*customPage
}
// PageData sind die Werte, die Vorlagen nutzen können (siehe README.txt).
type PageData struct {
Stage string
Tenant string
Tier string
Community bool
Target string
RefreshSeconds int
HosterName string
HosterURL string
ProURL string
PublicReason string // Wartung: ersetzt den Standardtext
UntilDE string // Wartung: voraussichtliches Ende, z. B. 02.10.2026 15:30
UntilEN string // dasselbe, z. B. 2026-10-02 15:30
CSS template.CSS
Image template.URL
}
type stageInfo struct {
status int
delay func(*Lander) time.Duration // nil = keine Weiterleitung, kein Cookie
headers map[string]string
}
var stages = map[string]stageInfo{
"watermark": {http.StatusOK, func(l *Lander) time.Duration { return or(l.WatermarkDelay, 5*time.Second) }, nil},
"firemark": {http.StatusOK, func(l *Lander) time.Duration { return or(l.FiremarkDelay, 10*time.Second) }, nil},
"paywall": {http.StatusPaymentRequired, nil, nil},
"landing": {http.StatusPaymentRequired, nil, nil},
"maintenance": {http.StatusServiceUnavailable, nil, map[string]string{"Retry-After": "60"}},
}
func or(d, def time.Duration) time.Duration {
if d > 0 {
return d
}
return def
}
func (l *Lander) log() *slog.Logger {
if l.Logger != nil {
return l.Logger
}
return slog.Default()
}
func (l *Lander) ServeHTTP(w http.ResponseWriter, r *http.Request) {
stage := r.Header.Get("X-Sultan-Stage")
info, ok := stages[stage]
if !ok {
http.Error(w, "unbekannte Stufe", http.StatusBadRequest)
return
}
tier := r.Header.Get("X-Sultan-Tier")
data := PageData{
Stage: stage,
Tenant: r.Header.Get("X-Sultan-Tenant"),
Tier: tier,
Community: tier == "community",
Target: localTarget(r.URL),
HosterName: l.HosterName,
HosterURL: l.HosterURL,
ProURL: l.ProURL,
}
if info.delay != nil {
data.RefreshSeconds = int(info.delay(l) / time.Second)
}
retryAfter := ""
if stage == "maintenance" {
retryAfter = l.maintenanceDetails(r, &data)
}
body := l.render(stage, data)
h := w.Header()
h.Set("Content-Type", "text/html; charset=utf-8")
h.Set("Cache-Control", "no-store")
for k, v := range info.headers {
h.Set(k, v)
}
if retryAfter != "" {
h.Set("Retry-After", retryAfter)
}
if info.delay != nil {
http.SetCookie(w, &http.Cookie{
Name: AckCookie,
Value: stage,
Path: "/",
MaxAge: int(or(l.AckTTL, time.Hour) / time.Second),
HttpOnly: true,
Secure: r.Header.Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteLaxMode,
})
}
w.WriteHeader(info.status)
w.Write(body)
}
const maxPublicReason = 300
// maintenanceDetails übernimmt öffentlichen Grund und Ende aus den Headern
// der Policy und liefert Retry-After (Sekunden bis zum Ende, 60 s bis 1 Tag).
func (l *Lander) maintenanceDetails(r *http.Request, data *PageData) string {
if raw := r.Header.Get("X-Sultan-Public-Reason"); raw != "" {
reason, err := url.QueryUnescape(raw)
if err != nil {
reason = raw
}
reason = strings.TrimSpace(reason)
if runes := []rune(reason); len(runes) > maxPublicReason {
reason = string(runes[:maxPublicReason]) + "…"
}
data.PublicReason = reason
}
now := time.Now()
if l.now != nil {
now = l.now()
}
until, err := time.Parse(time.RFC3339, r.Header.Get("X-Sultan-Until"))
if err != nil || !until.After(now) {
return ""
}
loc := l.Location
if loc == nil {
loc = time.Local
}
data.UntilDE = until.In(loc).Format("02.01.2006 15:04")
data.UntilEN = until.In(loc).Format("2006-01-02 15:04")
secs := int(until.Sub(now) / time.Second)
return strconv.Itoa(min(max(secs, 60), 86400))
}
// localTarget liefert den Pfad (+Query) des ursprünglichen Requests als
// Weiterleitungsziel – nur lokal: kein Host, kein Schema, kein "//" oder
// "\" am Anfang (sonst offene Weiterleitung auf fremde Seiten).
func localTarget(u *url.URL) string {
if u == nil {
return "/"
}
p := u.EscapedPath()
if !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") || strings.Contains(u.Path, `\`) {
return "/"
}
if u.RawQuery != "" {
return p + "?" + u.RawQuery
}
return p
}
// render nutzt die eigene Vorlage der Stufe, sonst (oder bei jedem Fehler)
// die eingebaute.
func (l *Lander) render(stage string, data PageData) []byte {
if page := l.customPage(stage); page != nil {
data.CSS, data.Image = page.css, page.image
var buf bytes.Buffer
err := page.tmpl.Execute(&buf, data)
if err == nil {
return buf.Bytes()
}
l.log().Error("Lander-Vorlage fehlerhaft – eingebaute Seite verwendet", "stage", stage, "err", err)
data.CSS, data.Image = "", ""
}
var buf bytes.Buffer
if err := builtin.ExecuteTemplate(&buf, stage+".html", data); err != nil {
l.log().Error("eingebaute Lander-Seite", "stage", stage, "err", err)
return []byte("<!doctype html><title>" + stage + "</title>")
}
return buf.Bytes()
}
type customPage struct {
signature string
tmpl *template.Template // nil: keine (gültige) eigene Vorlage
css template.CSS
image template.URL
}
// customPage lädt die eigene Vorlage einer Stufe und lädt neu, sobald sich
// eine der Dateien ändert (Name, Größe, Zeitstempel).
func (l *Lander) customPage(stage string) *customPage {
if l.Dir == "" {
return nil
}
files := []string{stage + ".html", stage + ".css", stage + ".svg", stage + ".webp"}
var sig strings.Builder
for _, f := range files {
if info, err := os.Stat(filepath.Join(l.Dir, f)); err == nil {
fmt.Fprintf(&sig, "%s:%d:%d;", f, info.Size(), info.ModTime().UnixNano())
}
}
l.mu.Lock()
defer l.mu.Unlock()
if c := l.cache[stage]; c != nil && c.signature == sig.String() {
if c.tmpl == nil {
return nil
}
return c
}
page := &customPage{signature: sig.String()}
if l.cache == nil {
l.cache = map[string]*customPage{}
}
l.cache[stage] = page
htmlPath := filepath.Join(l.Dir, stage+".html")
src, err := os.ReadFile(htmlPath)
if err != nil {
return nil // keine eigene Vorlage
}
tmpl, err := template.New(stage).Parse(string(src))
if err != nil {
l.log().Error("Lander-Vorlage fehlerhaft – eingebaute Seite verwendet", "file", htmlPath, "err", err)
return nil
}
page.tmpl = tmpl
if css, ok := l.readAsset(filepath.Join(l.Dir, stage+".css")); ok {
page.css = template.CSS(css)
}
for ext, mime := range map[string]string{".svg": "image/svg+xml", ".webp": "image/webp"} {
if img, ok := l.readAsset(filepath.Join(l.Dir, stage+ext)); ok {
page.image = template.URL("data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(img))
}
}
return page
}
func (l *Lander) readAsset(path string) ([]byte, bool) {
info, err := os.Stat(path)
if err != nil {
return nil, false
}
if info.Size() > MaxAssetSize {
l.log().Warn("Lander-Asset zu groß – ignoriert", "file", path, "max_bytes", MaxAssetSize)
return nil, false
}
data, err := os.ReadFile(path)
return data, err == nil
}
// Dump schreibt die eingebauten Seiten und eine README.txt als Vorlagen nach
// dir. Bestehende Dateien werden nur mit force überschrieben (sonst
// os.ErrExist, ohne etwas zu schreiben).
func Dump(dir string, force bool) ([]string, error) {
names := []string{"README.txt"}
for _, s := range Stages {
names = append(names, s+".html")
}
if !force {
for _, n := range names {
if _, err := os.Stat(filepath.Join(dir, n)); err == nil {
return nil, fmt.Errorf("%s existiert bereits (überschreiben mit --force): %w", filepath.Join(dir, n), os.ErrExist)
}
}
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
var written []string
for _, n := range names {
data, err := builtinFS.ReadFile("templates/" + n)
if err != nil {
return written, err
}
path := filepath.Join(dir, n)
if err := os.WriteFile(path, data, 0o644); err != nil {
return written, err
}
written = append(written, path)
}
return written, nil
}
+374
View File
@@ -0,0 +1,374 @@
package lander
import (
"bytes"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func request(t *testing.T, l *Lander, stage, tier, target string, header ...string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(http.MethodGet, target, nil)
r.Host = "kunde.de"
if stage != "" {
r.Header.Set("X-Sultan-Stage", stage)
}
r.Header.Set("X-Sultan-Tenant", "kunde")
if tier != "" {
r.Header.Set("X-Sultan-Tier", tier)
}
for i := 0; i+1 < len(header); i += 2 {
r.Header.Set(header[i], header[i+1])
}
rec := httptest.NewRecorder()
l.ServeHTTP(rec, r)
return rec
}
func newLander(t *testing.T) *Lander {
return &Lander{Logger: slog.New(slog.DiscardHandler), HosterName: "Beispiel-Hosting", HosterURL: "https://hoster.example", ProURL: "https://hoster.example/pro"}
}
func TestStages(t *testing.T) {
tests := []struct {
stage string
status int
refresh string // erwarteter meta-refresh-Inhalt, leer = keiner
cookie bool
}{
{"watermark", 200, "5;url=/shop?x=1", true},
{"firemark", 200, "10;url=/shop?x=1", true},
{"paywall", 402, "", false},
{"landing", 402, "", false},
{"maintenance", 503, "", false},
}
for _, tt := range tests {
t.Run(tt.stage, func(t *testing.T) {
rec := request(t, newLander(t), tt.stage, "pro", "/shop?x=1")
if rec.Code != tt.status {
t.Errorf("Status %d, erwartet %d", rec.Code, tt.status)
}
if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" {
t.Errorf("Content-Type %q", ct)
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Error("Seite darf nicht gecacht werden")
}
body := rec.Body.String()
if tt.refresh != "" && !strings.Contains(body, `http-equiv="refresh" content="`+tt.refresh+`"`) {
t.Errorf("meta refresh %q fehlt:\n%s", tt.refresh, body)
}
if tt.refresh == "" && strings.Contains(body, "refresh") {
t.Error("Stufe ohne Durchlass darf nicht weiterleiten")
}
cookie := rec.Header().Get("Set-Cookie")
if tt.cookie {
for _, want := range []string{"sultan_ack=" + tt.stage, "Max-Age=3600", "HttpOnly", "SameSite=Lax", "Path=/"} {
if !strings.Contains(cookie, want) {
t.Errorf("Cookie %q ohne %q", cookie, want)
}
}
} else if cookie != "" {
t.Errorf("unerwartetes Cookie %q", cookie)
}
if !strings.Contains(body, `lang="de"`) || !strings.Contains(body, `lang="en"`) {
t.Error("Text muss auf Deutsch und Englisch vorliegen")
}
})
}
}
func TestMaintenanceRetryAfterAndProHint(t *testing.T) {
l := newLander(t)
rec := request(t, l, "maintenance", "community", "/")
if rec.Header().Get("Retry-After") != "60" {
t.Errorf("Retry-After %q", rec.Header().Get("Retry-After"))
}
if !strings.Contains(rec.Body.String(), "https://hoster.example/pro") {
t.Error("Community-Wartungsseite ohne Pro-Hinweis")
}
if strings.Contains(request(t, l, "maintenance", "pro", "/").Body.String(), "https://hoster.example/pro") {
t.Error("Pro-Kunde bekommt Pro-Werbung")
}
}
func TestPaywallInvitesToHoster(t *testing.T) {
body := request(t, newLander(t), "paywall", "pro", "/").Body.String()
if !strings.Contains(body, "Beispiel-Hosting") || !strings.Contains(body, "https://hoster.example") {
t.Errorf("Einladung des Hosters fehlt:\n%s", body)
}
}
func TestSecureCookieBehindHTTPS(t *testing.T) {
rec := request(t, newLander(t), "watermark", "pro", "/", "X-Forwarded-Proto", "https")
if !strings.Contains(rec.Header().Get("Set-Cookie"), "Secure") {
t.Errorf("Cookie ohne Secure: %q", rec.Header().Get("Set-Cookie"))
}
}
func TestConfigurableDelays(t *testing.T) {
l := newLander(t)
l.WatermarkDelay, l.FiremarkDelay, l.AckTTL = 2*time.Second, 7*time.Second, 15*time.Minute
if body := request(t, l, "watermark", "pro", "/").Body.String(); !strings.Contains(body, `content="2;url=/"`) {
t.Errorf("Wartezeit Stufe 1:\n%s", body)
}
rec := request(t, l, "firemark", "pro", "/")
if !strings.Contains(rec.Body.String(), `content="7;url=/"`) || !strings.Contains(rec.Header().Get("Set-Cookie"), "Max-Age=900") {
t.Errorf("Stufe 2: %q / %s", rec.Header().Get("Set-Cookie"), rec.Body.String())
}
}
func TestUnknownOrMissingStage(t *testing.T) {
for _, stage := range []string{"", "normal", "kaputt"} {
if rec := request(t, newLander(t), stage, "pro", "/"); rec.Code != http.StatusBadRequest {
t.Errorf("Stufe %q: Status %d", stage, rec.Code)
}
}
}
// Werte aus Headern und URL werden nie roh ausgegeben.
func TestEscaping(t *testing.T) {
rec := request(t, newLander(t), "watermark", "pro", `/a"><script>alert(1)</script>`,
"X-Sultan-Tenant", "<script>böse</script>")
body := rec.Body.String()
if strings.Contains(body, "<script>") {
t.Errorf("ungefilterte Ausgabe:\n%s", body)
}
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestCustomTemplateWithInlinedAssets(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "maintenance.html",
`<html><head><style>{{.CSS}}</style></head><body><img src="{{.Image}}"><p>Wartung bei {{.Tenant}} ({{.Tier}})</p></body></html>`)
writeFile(t, dir, "maintenance.css", "body { color: red; }")
writeFile(t, dir, "maintenance.svg", `<svg xmlns="http://www.w3.org/2000/svg"/>`)
l := newLander(t)
l.Dir = dir
rec := request(t, l, "maintenance", "pro", "/")
body := rec.Body.String()
if rec.Code != 503 || !strings.Contains(body, "Wartung bei kunde (pro)") {
t.Errorf("eigene Vorlage nicht verwendet: %d\n%s", rec.Code, body)
}
if !strings.Contains(body, "body { color: red; }") {
t.Error("CSS nicht eingebettet")
}
// html/template schreibt "+" im Attribut als &#43; – der Browser liest es als "+".
if !strings.Contains(body, `src="data:image/svg&#43;xml;base64,`) {
t.Errorf("Grafik nicht als data-URI eingebettet:\n%s", body)
}
// Andere Stufen ohne eigene Vorlage: eingebaut.
if body := request(t, l, "paywall", "pro", "/").Body.String(); !strings.Contains(body, "Beispiel-Hosting") {
t.Error("eingebaute Seite für paywall fehlt")
}
}
func TestCustomTemplateWebP(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "landing.html", `<img src="{{.Image}}">`)
writeFile(t, dir, "landing.webp", "RIFF0000WEBP")
l := newLander(t)
l.Dir = dir
if body := request(t, l, "landing", "pro", "/").Body.String(); !strings.Contains(body, `src="data:image/webp;base64,`) {
t.Errorf("WebP nicht eingebettet: %s", body)
}
}
// Eine kaputte Vorlage darf die Hinweisseite nie ausfallen lassen.
func TestBrokenTemplateFallsBackToBuiltin(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "maintenance.html", `{{.GibtsNicht`)
var logs bytes.Buffer
l := newLander(t)
l.Dir, l.Logger = dir, slog.New(slog.NewTextHandler(&logs, nil))
rec := request(t, l, "maintenance", "pro", "/")
if rec.Code != 503 || !strings.Contains(rec.Body.String(), `lang="de"`) {
t.Errorf("keine eingebaute Seite: %d %s", rec.Code, rec.Body.String())
}
if !strings.Contains(logs.String(), "maintenance.html") {
t.Errorf("Fehler nicht protokolliert: %s", logs.String())
}
// Ausführungsfehler (unbekanntes Feld) ebenfalls.
writeFile(t, dir, "maintenance.html", `{{.GibtsNicht}}`)
rec = request(t, l, "maintenance", "pro", "/")
if rec.Code != 503 || !strings.Contains(rec.Body.String(), `lang="de"`) {
t.Errorf("Ausführungsfehler: %d %s", rec.Code, rec.Body.String())
}
}
func TestOversizedAssetIgnored(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "maintenance.html", `<img src="{{.Image}}">ok`)
writeFile(t, dir, "maintenance.svg", strings.Repeat("x", MaxAssetSize+1))
l := newLander(t)
l.Dir = dir
body := request(t, l, "maintenance", "pro", "/").Body.String()
if strings.Contains(body, "base64") || !strings.Contains(body, "ok") {
t.Errorf("zu große Grafik eingebettet: %.200s", body)
}
}
func TestTemplatesReloadedOnChange(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "maintenance.html", `Version 1`)
l := newLander(t)
l.Dir = dir
if body := request(t, l, "maintenance", "pro", "/").Body.String(); body != "Version 1" {
t.Fatalf("body = %q", body)
}
writeFile(t, dir, "maintenance.html", `Version 2 – länger`)
if body := request(t, l, "maintenance", "pro", "/").Body.String(); body != "Version 2 – länger" {
t.Errorf("Änderung nicht übernommen: %q", body)
}
}
func TestDump(t *testing.T) {
dir := filepath.Join(t.TempDir(), "landing")
files, err := Dump(dir, false)
if err != nil {
t.Fatalf("Dump: %v", err)
}
for _, stage := range Stages {
if _, err := os.Stat(filepath.Join(dir, stage+".html")); err != nil {
t.Errorf("%s.html fehlt", stage)
}
}
if _, err := os.Stat(filepath.Join(dir, "README.txt")); err != nil {
t.Error("README.txt fehlt")
}
if len(files) != len(Stages)+1 {
t.Errorf("geschriebene Dateien: %v", files)
}
// Ausgegebene Vorlagen rendern genauso wie die eingebauten.
builtin := newLander(t)
custom := newLander(t)
custom.Dir = dir
for _, stage := range Stages {
a := request(t, builtin, stage, "community", "/x").Body.String()
b := request(t, custom, stage, "community", "/x").Body.String()
if a != b {
t.Errorf("%s: Ausgabe weicht ab", stage)
}
}
if _, err := Dump(dir, false); !errors.Is(err, os.ErrExist) {
t.Errorf("Überschreiben ohne force: %v", err)
}
writeFile(t, dir, "maintenance.html", "eigen")
if _, err := Dump(dir, true); err != nil {
t.Fatalf("Dump mit force: %v", err)
}
if b, _ := os.ReadFile(filepath.Join(dir, "maintenance.html")); string(b) == "eigen" {
t.Error("force hat nicht überschrieben")
}
}
func TestAnyMethodGetsPage(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/formular", strings.NewReader("a=b"))
r.Header.Set("X-Sultan-Stage", "maintenance")
rec := httptest.NewRecorder()
newLander(t).ServeHTTP(rec, r)
if rec.Code != 503 {
t.Errorf("POST: %d", rec.Code)
}
}
// Das Weiterleitungsziel ist immer ein lokaler Pfad: keine fremden Hosts
// (//evil), keine Schemata (javascript:), keine Backslash-Tricks.
func TestRefreshTargetIsLocal(t *testing.T) {
tests := map[string]string{
"/shop?x=1": "/shop?x=1",
"//evil.example/x": "/",
"/\\evil.example": "/",
"/a%2F%2Fb": "/a%2F%2Fb",
"/pfad mit leer": "/pfad%20mit%20leer",
}
for target, want := range tests {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.URL.Path, r.URL.RawPath, r.URL.RawQuery = "", "", ""
u, _ := r.URL.Parse(target)
if strings.HasPrefix(target, "//") {
u.Host, u.Path = "", target // so wie es bei einem Request "GET //evil.example/x" ankommt
}
r.URL = u
r.Header.Set("X-Sultan-Stage", "watermark")
rec := httptest.NewRecorder()
newLander(t).ServeHTTP(rec, r)
if !strings.Contains(rec.Body.String(), `content="5;url=`+want+`"`) {
t.Errorf("%q: erwartet Ziel %q in\n%s", target, want, rec.Body.String())
}
}
if got := localTarget(nil); got != "/" {
t.Errorf("localTarget(nil) = %q", got)
}
}
func TestMaintenancePublicReasonAndUntil(t *testing.T) {
berlin, _ := time.LoadLocation("Europe/Berlin")
l := newLander(t)
l.Location = berlin
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
l.now = func() time.Time { return now }
until := now.Add(90 * time.Minute) // 15:30 in Berlin (MESZ)
rec := request(t, l, "maintenance", "pro", "/",
"X-Sultan-Public-Reason", url.QueryEscape("Sicherheitsupdate (CVE-1234) – Sicherheit nehmen wir ernst."),
"X-Sultan-Until", until.Format(time.RFC3339))
body := rec.Body.String()
if !strings.Contains(body, "Sicherheitsupdate (CVE-1234) – Sicherheit nehmen wir ernst.") {
t.Errorf("öffentlicher Grund fehlt:\n%s", body)
}
if strings.Contains(body, "Bitte versuchen Sie es in einer Minute") {
t.Error("Standardtext trotz öffentlichem Grund")
}
if !strings.Contains(body, "02.10.2026 15:30") || !strings.Contains(body, "2026-10-02 15:30") {
t.Errorf("voraussichtliches Ende fehlt:\n%s", body)
}
if rec.Header().Get("Retry-After") != "5400" {
t.Errorf("Retry-After %q, erwartet Sekunden bis zum Ende", rec.Header().Get("Retry-After"))
}
}
func TestMaintenanceHeaderHandling(t *testing.T) {
l := newLander(t)
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
l.now = func() time.Time { return now }
tests := []struct {
name, until, retry string
}{
{"kein Ende", "", "60"},
{"unlesbar", "morgen", "60"},
{"vergangen", now.Add(-time.Hour).Format(time.RFC3339), "60"},
{"sehr fern", now.Add(30 * 24 * time.Hour).Format(time.RFC3339), "86400"},
}
for _, tt := range tests {
rec := request(t, l, "maintenance", "pro", "/", "X-Sultan-Until", tt.until)
if got := rec.Header().Get("Retry-After"); got != tt.retry {
t.Errorf("%s: Retry-After %q, erwartet %q", tt.name, got, tt.retry)
}
}
// Eingeschleustes HTML im Grund wird maskiert, überlange Gründe gekürzt.
rec := request(t, l, "maintenance", "pro", "/", "X-Sultan-Public-Reason", url.QueryEscape("<script>x</script>"+strings.Repeat("a", 400)))
body := rec.Body.String()
if strings.Contains(body, "<script>x") || strings.Contains(body, strings.Repeat("a", 301)) {
t.Errorf("Grund nicht maskiert/gekürzt: %.300s", body)
}
}
+28
View File
@@ -0,0 +1,28 @@
Sultan – eigene Lander-Seiten
=============================
Pro Stufe eine Vorlage <stufe>.html (Go html/template), optional <stufe>.css
und eine Grafik <stufe>.svg oder <stufe>.webp (je höchstens 512 KiB).
CSS und Grafik werden in die Seite eingebettet – es gibt keine weiteren URLs.
Stufen: watermark, firemark, paywall, landing, maintenance
Fehlt eine Vorlage oder ist sie fehlerhaft, gilt die eingebaute Seite.
Änderungen wirken sofort (kein Neustart nötig).
Verfügbare Werte:
{{.Stage}} Stufe
{{.Tenant}} Mandanten-ID
{{.Tier}} community | pro | enterprise
{{.Community}} true bei Tier community
{{.Target}} lokaler Pfad der ursprünglichen Seite (für Weiterleitung/Link)
{{.RefreshSeconds}} Wartezeit der Weiterleitung (watermark/firemark)
{{.HosterName}} Name des Hosting-Anbieters (sultan server --hoster-name)
{{.HosterURL}} Angebotsseite des Anbieters (--hoster-url)
{{.ProURL}} Pro-Angebot für Community (--pro-url)
{{.PublicReason}} öffentlicher Wartungsgrund (maintenance, falls angegeben)
{{.UntilDE}} voraussichtliches Ende, z. B. 02.10.2026 15:30 (maintenance)
{{.UntilEN}} dasselbe, z. B. 2026-10-02 15:30
{{.CSS}} Inhalt von <stufe>.css – in <style>{{.CSS}}</style>
{{.Image}} Grafik als data:-URI – in <img src="{{.Image}}">
Syntax: https://pkg.go.dev/html/template (z. B. {{if .ProURL}}…{{end}})
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<meta http-equiv="refresh" content="{{.RefreshSeconds}};url={{.Target}}">
<title>Wichtiger Hinweis / Important notice</title>
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
</head>
<body>
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
<section lang="de">
<h1>Wichtiger Hinweis</h1>
<p>Für diese Website ist beim Hosting-Anbieter noch etwas offen. Wenn der Betreiber es nicht bald klärt,
wird die Website abgeschaltet. Bis dahin ist sie erreichbar – Sie werden in {{.RefreshSeconds}} Sekunden weitergeleitet.</p>
<p><a href="{{.Target}}">Weiter zur Website</a></p>
</section>
<section lang="en">
<h1>Important notice</h1>
<p>Something is still open with the hosting provider of this website. If the owner does not sort it out soon,
the website will be switched off. Until then it is available – you will be redirected in {{.RefreshSeconds}} seconds.</p>
<p><a href="{{.Target}}">Continue to the website</a></p>
</section>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Abgelaufen / Expired</title>
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
</head>
<body>
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
<section lang="de">
<h1>Das Hosting dieser Website ist abgelaufen</h1>
<p>Die Website ist deshalb gerade nicht erreichbar. Sobald der Betreiber verlängert, ist sie wieder da.
{{if .HosterName}}{{.HosterName}}{{else}}Der Hosting-Anbieter{{end}} freut sich, wenn Sie sich in der Zwischenzeit umsehen.</p>
{{if .HosterURL}}<p><a href="{{.HosterURL}}">Zu den Angeboten</a></p>{{end}}
</section>
<section lang="en">
<h1>The hosting of this website has expired</h1>
<p>That is why the website is not available right now. As soon as the owner renews, it will be back.
{{if .HosterName}}{{.HosterName}}{{else}}The hosting provider{{end}} would be happy if you have a look around in the meantime.</p>
{{if .HosterURL}}<p><a href="{{.HosterURL}}">See the offers</a></p>{{end}}
</section>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Gleich wieder da / Back soon</title>
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
</head>
<body>
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
<section lang="de">
<h1>Gleich wieder da</h1>
{{if .PublicReason}}<p>{{.PublicReason}}</p>{{else}}<p>Diese Website ist gerade nicht erreichbar – vermutlich wird sie gewartet oder neu gestartet.
Bitte versuchen Sie es in einer Minute noch einmal.</p>{{end}}
{{if .UntilDE}}<p>Voraussichtlich wieder da: {{.UntilDE}}</p>{{end}}
{{if and .Community .ProURL}}<p>Sie möchten so etwas gar nicht erst erleben? <a href="{{.ProURL}}">Hier gibt es den Pro-Service</a>.</p>{{end}}
</section>
<section lang="en">
<h1>Back in a moment</h1>
{{if .PublicReason}}<p>{{.PublicReason}}</p>{{else}}<p>This website is not available right now – it is probably being maintained or restarted.
Please try again in a minute.</p>{{end}}
{{if .UntilEN}}<p>Expected back: {{.UntilEN}}</p>{{end}}
{{if and .Community .ProURL}}<p>Want to avoid this altogether? <a href="{{.ProURL}}">Get the pro service here</a>.</p>{{end}}
</section>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Derzeit offline / Currently offline</title>
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
</head>
<body>
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
<section lang="de">
<h1>Diese Website ist derzeit offline</h1>
<p>Der Betreiber hat sein Hosting nicht verlängert. Schade – aber vielleicht passt ja etwas für Sie:
{{if .HosterName}}{{.HosterName}}{{else}}Der Hosting-Anbieter{{end}} lädt Sie herzlich ein, sich umzusehen, was hier alles möglich ist.</p>
{{if .HosterURL}}<p><a href="{{.HosterURL}}">Zu den Angeboten</a></p>{{end}}
</section>
<section lang="en">
<h1>This website is currently offline</h1>
<p>The owner has not renewed their hosting. A pity – but maybe there is something for you:
{{if .HosterName}}{{.HosterName}}{{else}}The hosting provider{{end}} warmly invites you to have a look at what is possible here.</p>
{{if .HosterURL}}<p><a href="{{.HosterURL}}">See the offers</a></p>{{end}}
</section>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<meta http-equiv="refresh" content="{{.RefreshSeconds}};url={{.Target}}">
<title>Hinweis / Notice</title>
{{if .CSS}}<style>{{.CSS}}</style>{{end}}
</head>
<body>
{{if .Image}}<img src="{{.Image}}" alt="">{{end}}
<section lang="de">
<h1>Kurzer Hinweis</h1>
<p>Der Betreiber dieser Website hat eine Nachricht von seinem Hosting-Anbieter erhalten, um die er sich kümmern sollte.
Die Website ist ganz normal erreichbar – Sie werden in {{.RefreshSeconds}} Sekunden automatisch weitergeleitet.</p>
<p><a href="{{.Target}}">Gleich weiter zur Website</a></p>
</section>
<section lang="en">
<h1>Just a short notice</h1>
<p>The owner of this website has received a message from their hosting provider that needs attention.
The website works as usual – you will be redirected automatically in {{.RefreshSeconds}} seconds.</p>
<p><a href="{{.Target}}">Continue to the website now</a></p>
</section>
</body>
</html>
+4
View File
@@ -66,3 +66,7 @@ func (l License) AllowsDomain(host string) bool {
}
return false
}
// NormalizeDomain prüft und normalisiert einen Eintrag für allowed_domains
// (klein, ohne Punkt am Ende; Wildcard nur vorne, nicht auf einer TLD).
func NormalizeDomain(p string) (string, error) { return normalizePattern(p) }
+9
View File
@@ -55,3 +55,12 @@ func strings63() string {
}
return string(b)
}
func TestNormalizeDomain_Exported(t *testing.T) {
if got, err := NormalizeDomain(" Shop.DE. "); err != nil || got != "shop.de" {
t.Errorf("NormalizeDomain = %q, %v", got, err)
}
if _, err := NormalizeDomain("*.de"); err == nil {
t.Error("*.de akzeptiert")
}
}
+153
View File
@@ -0,0 +1,153 @@
package license
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"math/rand/v2"
"strings"
"time"
)
// Laufzeiten der Leases (Spec 4.1a).
const (
CommunityLease = 14 * 24 * time.Hour
CommunityRenewAfter = 7 * 24 * time.Hour
CommunityRenewJitter = 12 * time.Hour
CommercialLease = 7 * 24 * time.Hour
CommercialMaintenance = 10 * 24 * time.Hour
)
// LeaseTerms sind Erneuerungs- und Ablaufzeitpunkt einer Lease.
type LeaseTerms struct {
RenewAfter time.Time
ValidUntil time.Time
}
// TermsFor berechnet die Lease-Zeiten. Kommerziell: 7 Tage (in Wartung 10),
// Erneuerung nach 2/3, höchstens bis zum Vertragsende; nach Vertragsende
// gibt es keine Lease (ok=false). Community: 14 Tage, Erneuerung nach
// 7 Tagen ± Streuung (jitter liefert einen Wert in [-max, +max]; nil =
// zufällig), Vertragsende ohne Bedeutung. override > 0 setzt eine feste
// Laufzeit (Tests, Betrieb), Erneuerung dann nach 2/3.
func TermsFor(tier Tier, maintenance bool, licenseUntil, now time.Time, override time.Duration,
jitter func(max time.Duration) time.Duration) (LeaseTerms, bool) {
commercial := tier != TierCommunity
if commercial && !now.Before(licenseUntil) {
return LeaseTerms{}, false
}
var t LeaseTerms
switch {
case override > 0:
t.ValidUntil = now.Add(override)
case !commercial:
if jitter == nil {
jitter = randomJitter
}
return LeaseTerms{
ValidUntil: now.Add(CommunityLease),
RenewAfter: now.Add(CommunityRenewAfter + jitter(CommunityRenewJitter)),
}, true
case maintenance:
t.ValidUntil = now.Add(CommercialMaintenance)
default:
t.ValidUntil = now.Add(CommercialLease)
}
if commercial && t.ValidUntil.After(licenseUntil) {
t.ValidUntil = licenseUntil
}
t.RenewAfter = now.Add(t.ValidUntil.Sub(now) * 2 / 3)
return t, true
}
func randomJitter(max time.Duration) time.Duration {
return time.Duration(rand.Int64N(int64(2*max)+1)) - max
}
// Lease ist die signierte, kurzlaufende Kopie der Lizenz für einen Knoten.
type Lease struct {
NodeID string `json:"node_id"`
TenantID string `json:"tenant_id"`
Tier Tier `json:"tier"`
MaxRoutes int `json:"max_routes"`
AllowedDomains []string `json:"allowed_domains"`
Features []Feature `json:"features"`
IssuedAt time.Time `json:"issued_at"`
RenewAfter time.Time `json:"renew_after"`
ValidUntil time.Time `json:"valid_until"`
Signature string `json:"signature"`
}
// Eigenes Präfix: Eine Lease-Signatur gilt nie als Lizenz-Signatur und umgekehrt.
const leaseSignatureVersion = "SULTAN-LEASE-V1\n"
func (l Lease) message() []byte {
c := l
c.Signature = ""
c.IssuedAt, c.RenewAfter, c.ValidUntil = c.IssuedAt.UTC(), c.RenewAfter.UTC(), c.ValidUntil.UTC()
b, _ := json.Marshal(c)
return append([]byte(leaseSignatureVersion), b...)
}
// IssueLease stellt eine Lease für nodeID aus den Werten von l aus.
func (i *Issuer) IssueLease(l License, nodeID string, terms LeaseTerms, now time.Time) (Lease, error) {
if len(i.Key) != ed25519.PrivateKeySize {
return Lease{}, fmt.Errorf("license: Issuer ohne gültigen Master-Key")
}
if strings.TrimSpace(nodeID) == "" {
return Lease{}, fmt.Errorf("%w: node_id fehlt", ErrInvalid)
}
utc := func(t time.Time) time.Time { return t.Round(0).UTC() }
lease := Lease{
NodeID: nodeID,
TenantID: l.TenantID,
Tier: l.Tier,
MaxRoutes: l.MaxRoutes,
AllowedDomains: append([]string{}, l.AllowedDomains...),
Features: append([]Feature{}, l.Features...),
IssuedAt: utc(now),
RenewAfter: utc(terms.RenewAfter),
ValidUntil: utc(terms.ValidUntil),
}
lease.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(i.Key, lease.message()))
return lease, nil
}
// Verify prüft die Signatur mit dem Public Key des Master-Keys.
func (l Lease) Verify(pub ed25519.PublicKey) error {
sig, err := base64.StdEncoding.DecodeString(l.Signature)
if err != nil || len(sig) != ed25519.SignatureSize || len(pub) != ed25519.PublicKeySize {
return ErrInvalidSignature
}
if !ed25519.Verify(pub, l.message(), sig) {
return ErrInvalidSignature
}
return nil
}
// Expired meldet, ob die Lease zum Zeitpunkt now abgelaufen ist.
func (l Lease) Expired(now time.Time) bool { return !now.Before(l.ValidUntil) }
// ParseLease liest und prüft eine Lease (unbekannte Felder sind Fehler).
func ParseLease(data []byte, pub ed25519.PublicKey) (Lease, error) {
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
var l Lease
if err := dec.Decode(&l); err != nil {
return Lease{}, fmt.Errorf("%w: %v", ErrInvalid, err)
}
if l.NodeID == "" || l.ValidUntil.IsZero() {
return Lease{}, fmt.Errorf("%w: node_id oder valid_until fehlt", ErrInvalid)
}
if l.AllowedDomains == nil {
l.AllowedDomains = []string{}
}
if l.Features == nil {
l.Features = []Feature{}
}
if err := l.Verify(pub); err != nil {
return Lease{}, err
}
return l, nil
}
+132
View File
@@ -0,0 +1,132 @@
package license
import (
"encoding/json"
"errors"
"testing"
"time"
)
var t0 = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
const day = 24 * time.Hour
func noJitter(time.Duration) time.Duration { return 0 }
func TestLeaseTerms(t *testing.T) {
far := t0.AddDate(1, 0, 0)
tests := []struct {
name string
tier Tier
maintenance bool
licenseUntil time.Time
valid, renew time.Duration // relativ zu t0
ok bool
}{
{"community", TierCommunity, false, far, 14 * day, 7 * day, true},
{"community ignoriert Vertragsende", TierCommunity, false, t0.Add(day), 14 * day, 7 * day, true},
{"pro", TierPro, false, far, 7 * day, 7 * day * 2 / 3, true},
{"pro in Wartung", TierPro, true, far, 10 * day, 10 * day * 2 / 3, true},
{"enterprise am Vertragsende gekappt", TierEnterprise, false, t0.Add(3 * day), 3 * day, 2 * day, true},
{"Vertrag abgelaufen", TierPro, false, t0, 0, 0, false},
}
for _, tt := range tests {
terms, ok := TermsFor(tt.tier, tt.maintenance, tt.licenseUntil, t0, 0, noJitter)
if ok != tt.ok {
t.Errorf("%s: ok=%v", tt.name, ok)
continue
}
if !ok {
continue
}
if !terms.ValidUntil.Equal(t0.Add(tt.valid)) || !terms.RenewAfter.Equal(t0.Add(tt.renew)) {
t.Errorf("%s: gültig bis +%v, erneuern ab +%v; erwartet +%v / +%v",
tt.name, terms.ValidUntil.Sub(t0), terms.RenewAfter.Sub(t0), tt.valid, tt.renew)
}
}
}
// Community-Erneuerung gestreut (± 12 h), damit nicht alle Agents gleichzeitig erneuern.
func TestLeaseTerms_CommunityJitter(t *testing.T) {
var maxSeen time.Duration
jitter := func(max time.Duration) time.Duration { maxSeen = max; return -5 * time.Hour }
terms, _ := TermsFor(TierCommunity, false, t0.AddDate(1, 0, 0), t0, 0, jitter)
if maxSeen != 12*time.Hour {
t.Errorf("Streuung ±%v, erwartet ±12h", maxSeen)
}
if !terms.RenewAfter.Equal(t0.Add(7*day - 5*time.Hour)) {
t.Errorf("RenewAfter = +%v", terms.RenewAfter.Sub(t0))
}
// Ohne explizite Funktion: echte Streuung, aber innerhalb der Grenzen.
for range 100 {
terms, _ := TermsFor(TierCommunity, false, t0.AddDate(1, 0, 0), t0, 0, nil)
if d := terms.RenewAfter.Sub(t0); d < 7*day-12*time.Hour || d > 7*day+12*time.Hour {
t.Fatalf("Streuung außerhalb: +%v", d)
}
}
}
// Override (Tests, Betrieb): feste Laufzeit, Erneuerung nach 2/3, Vertragsende gilt weiter.
func TestLeaseTerms_Override(t *testing.T) {
terms, ok := TermsFor(TierCommunity, false, t0.AddDate(1, 0, 0), t0, 300*time.Millisecond, noJitter)
if !ok || !terms.ValidUntil.Equal(t0.Add(300*time.Millisecond)) || !terms.RenewAfter.Equal(t0.Add(200*time.Millisecond)) {
t.Errorf("Override: %+v", terms)
}
}
func TestIssueAndParseLease(t *testing.T) {
iss, pub := newIssuer(t)
lic := sample()
terms := LeaseTerms{RenewAfter: t0.Add(4 * day), ValidUntil: t0.Add(7 * day)}
l, err := iss.IssueLease(lic, "node-1", terms, t0)
if err != nil {
t.Fatalf("IssueLease: %v", err)
}
if l.NodeID != "node-1" || l.TenantID != lic.TenantID || !l.IssuedAt.Equal(t0) || !l.ValidUntil.Equal(terms.ValidUntil) ||
l.MaxRoutes != lic.MaxRoutes || len(l.AllowedDomains) != 2 {
t.Errorf("Lease: %+v", l)
}
doc, _ := json.Marshal(l)
parsed, err := ParseLease(doc, pub)
if err != nil {
t.Fatalf("ParseLease: %v", err)
}
if parsed.NodeID != "node-1" || !parsed.ValidUntil.Equal(terms.ValidUntil) {
t.Errorf("geparst: %+v", parsed)
}
if !l.Expired(terms.ValidUntil) || l.Expired(terms.ValidUntil.Add(-time.Second)) {
t.Error("Expired falsch")
}
for name, tamper := range map[string]func(*Lease){
"Knoten": func(l *Lease) { l.NodeID = "node-2" },
"Laufzeit": func(l *Lease) { l.ValidUntil = l.ValidUntil.Add(time.Hour) },
"Domains": func(l *Lease) { l.AllowedDomains = append([]string(nil), "bank.de") },
"Erneuerung": func(l *Lease) { l.RenewAfter = l.RenewAfter.Add(time.Hour) },
} {
c := l
tamper(&c)
if err := c.Verify(pub); !errors.Is(err, ErrInvalidSignature) {
t.Errorf("%s manipuliert: %v", name, err)
}
}
}
// Domänentrennung: Eine Lease ist keine Lizenz und umgekehrt.
func TestLeaseIsNotALicense(t *testing.T) {
iss, pub := newIssuer(t)
lic, _ := iss.Issue(sample())
lease, _ := iss.IssueLease(sample(), "node-1", LeaseTerms{RenewAfter: t0.Add(day), ValidUntil: t0.Add(2 * day)}, t0)
licDoc, _ := json.Marshal(lic)
if _, err := ParseLease(licDoc, pub); err == nil {
t.Error("Lizenz als Lease akzeptiert")
}
asLicense := License{
TenantID: lease.TenantID, Tier: lease.Tier, MaxRoutes: lease.MaxRoutes, AllowedDomains: lease.AllowedDomains,
Features: lease.Features, ValidUntil: lease.ValidUntil, Signature: lease.Signature,
}
if err := asLicense.Verify(pub); !errors.Is(err, ErrInvalidSignature) {
t.Error("Lease-Signatur als Lizenz-Signatur akzeptiert")
}
}
+111
View File
@@ -0,0 +1,111 @@
// Package notify verschickt Benachrichtigungen des Servers (Fristen,
// Mahnstufen, Offline-Alarme, Policy-Verstöße) an Webhooks: Slack-kompatibel
// (auch Mattermost, Rocket.Chat, Discord-Slack-Endpunkt) und ntfy.
package notify
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Severity ist die Dringlichkeit einer Nachricht.
type Severity int
const (
Info Severity = iota
Warning
Critical
)
// Message ist eine Benachrichtigung; Body darf Slack-Markdown enthalten.
type Message struct {
Title string
Body string
Severity Severity
}
// Target ist ein Benachrichtigungsziel.
type Target interface {
Send(ctx context.Context, m Message) error
}
// ErrNoTargets: Multi ohne Ziele.
var ErrNoTargets = errors.New("notify: keine Ziele konfiguriert")
var client = &http.Client{Timeout: 10 * time.Second}
func post(ctx context.Context, url, contentType string, body []byte, headers map[string]string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", contentType)
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("notify: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return fmt.Errorf("notify: %s antwortet %d: %s", url, resp.StatusCode, strings.TrimSpace(string(msg)))
}
return nil
}
var emoji = map[Severity]string{Info: ":information_source:", Warning: ":warning:", Critical: ":rotating_light:"}
// Slack sendet an einen Slack-kompatiblen Incoming Webhook ({"text": …}).
type Slack struct{ URL string }
func (s Slack) Send(ctx context.Context, m Message) error {
body, _ := json.Marshal(map[string]string{"text": fmt.Sprintf("%s *%s*\n%s", emoji[m.Severity], m.Title, m.Body)})
return post(ctx, s.URL, "application/json", body, nil)
}
var ntfyPriority = map[Severity][2]string{
Info: {"default", "information_source"},
Warning: {"high", "warning"},
Critical: {"urgent", "rotating_light"},
}
// Ntfy sendet an ein ntfy-Topic (URL inkl. Topic), optional mit Access-Token.
type Ntfy struct {
URL string
Token string
}
func (n Ntfy) Send(ctx context.Context, m Message) error {
p := ntfyPriority[m.Severity]
headers := map[string]string{"Title": m.Title, "Priority": p[0], "Tags": p[1], "Markdown": "yes"}
if n.Token != "" {
headers["Authorization"] = "Bearer " + n.Token
}
return post(ctx, n.URL, "text/markdown; charset=utf-8", []byte(m.Body), headers)
}
// Multi sendet an alle Ziele; Fehler einzelner Ziele werden gesammelt
// gemeldet, halten die übrigen aber nicht auf.
type Multi []Target
func (m Multi) Send(ctx context.Context, msg Message) error {
if len(m) == 0 {
return ErrNoTargets
}
var errs []error
for _, t := range m {
if err := t.Send(ctx, msg); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
+133
View File
@@ -0,0 +1,133 @@
package notify
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
type received struct {
mu sync.Mutex
reqs []*http.Request
body []string
}
func (r *received) handler(status int) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
b, _ := io.ReadAll(req.Body)
r.mu.Lock()
r.reqs = append(r.reqs, req)
r.body = append(r.body, string(b))
r.mu.Unlock()
w.WriteHeader(status)
}
}
var msg = Message{
Title: "Vertrag kunde läuft ab",
Body: "Noch *14 Tage* bis 2026-12-15.",
Severity: Critical,
}
func TestSlack(t *testing.T) {
var got received
srv := httptest.NewServer(got.handler(200))
defer srv.Close()
if err := (Slack{URL: srv.URL}).Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
if ct := got.reqs[0].Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type %q", ct)
}
var payload map[string]string
if err := json.Unmarshal([]byte(got.body[0]), &payload); err != nil {
t.Fatal(err)
}
text := payload["text"]
if !strings.Contains(text, "*Vertrag kunde läuft ab*") || !strings.Contains(text, "Noch *14 Tage*") || !strings.Contains(text, ":rotating_light:") {
t.Errorf("text = %q", text)
}
}
func TestNtfy(t *testing.T) {
var got received
srv := httptest.NewServer(got.handler(200))
defer srv.Close()
if err := (Ntfy{URL: srv.URL + "/sultan", Token: "tk_geheim"}).Send(context.Background(), msg); err != nil {
t.Fatal(err)
}
r := got.reqs[0]
if r.URL.Path != "/sultan" || got.body[0] != msg.Body {
t.Errorf("Pfad %q, Body %q", r.URL.Path, got.body[0])
}
for h, want := range map[string]string{
"Title": "Vertrag kunde läuft ab",
"Priority": "urgent",
"Tags": "rotating_light",
"Markdown": "yes",
"Authorization": "Bearer tk_geheim",
} {
if r.Header.Get(h) != want {
t.Errorf("%s = %q, erwartet %q", h, r.Header.Get(h), want)
}
}
}
func TestSeverityMapping(t *testing.T) {
for sev, want := range map[Severity][2]string{
Info: {"default", "information_source"},
Warning: {"high", "warning"},
Critical: {"urgent", "rotating_light"},
} {
var got received
srv := httptest.NewServer(got.handler(200))
(Ntfy{URL: srv.URL}).Send(context.Background(), Message{Title: "x", Body: "y", Severity: sev})
srv.Close()
if got.reqs[0].Header.Get("Priority") != want[0] || got.reqs[0].Header.Get("Tags") != want[1] {
t.Errorf("%v: Priority %q Tags %q", sev, got.reqs[0].Header.Get("Priority"), got.reqs[0].Header.Get("Tags"))
}
if got.reqs[0].Header.Get("Authorization") != "" {
t.Error("Authorization ohne Token gesetzt")
}
}
}
func TestErrors(t *testing.T) {
var got received
bad := httptest.NewServer(got.handler(500))
defer bad.Close()
for _, target := range []Target{Slack{URL: bad.URL}, Ntfy{URL: bad.URL}, Slack{URL: "http://127.0.0.1:1"}} {
if err := target.Send(context.Background(), msg); err == nil {
t.Errorf("%T: erwartet Fehler", target)
}
}
}
// Multi sendet an alle Ziele; ein Fehler bei einem Ziel hält die anderen nicht auf.
func TestMulti(t *testing.T) {
var a, b received
okA := httptest.NewServer(a.handler(200))
okB := httptest.NewServer(b.handler(200))
bad := httptest.NewServer((&received{}).handler(500))
defer okA.Close()
defer okB.Close()
defer bad.Close()
m := Multi{Slack{URL: okA.URL}, Ntfy{URL: bad.URL}, Ntfy{URL: okB.URL}}
err := m.Send(context.Background(), msg)
if err == nil {
t.Error("Fehler eines Ziels nicht gemeldet")
}
if len(a.reqs) != 1 || len(b.reqs) != 1 {
t.Errorf("nicht alle Ziele erreicht: %d/%d", len(a.reqs), len(b.reqs))
}
if !errors.Is(Multi{}.Send(context.Background(), msg), ErrNoTargets) {
t.Error("leeres Multi: erwartet ErrNoTargets")
}
}
+56 -3
View File
@@ -21,6 +21,7 @@ package policy
import (
"errors"
"fmt"
"net/url"
"regexp"
"slices"
"strings"
@@ -61,8 +62,23 @@ const (
HeaderStage = "X-Sultan-Stage"
HeaderTenant = "X-Sultan-Tenant"
HeaderTier = "X-Sultan-Tier" // z. B. für den Pro-Hinweis auf Community-Wartungsseiten
// HeaderUntil: voraussichtliches Wartungsende (RFC 3339), nur bei befristeter Wartung.
HeaderUntil = "X-Sultan-Until"
// HeaderPublicReason: öffentlicher Wartungsgrund, URL-kodiert (Header sind
// einzeilig; Umlaute kommen so unverändert beim Lander an).
HeaderPublicReason = "X-Sultan-Public-Reason"
)
// Maintenance ist eine angemeldete Wartung (Knoten oder Dienst).
type Maintenance struct {
Until time.Time // Nullwert = unbefristet
PublicReason string
}
func (m *Maintenance) active(now time.Time) bool {
return m != nil && (m.Until.IsZero() || now.Before(m.Until))
}
// AckCookie setzt der Lander, nachdem er den Mahnhinweis gezeigt hat; der
// Wert ist die Stufe. Ein bestätigter Hinweis gilt nicht für eine höhere
// Stufe. Das Cookie ist bewusst kein Sicherheitsmerkmal – wer es selbst
@@ -124,6 +140,14 @@ type Input struct {
// DownServices: Services (Originalnamen), die der Healthcheck des
// Agenten als ausgefallen meldet.
DownServices map[string]bool
// Maintenance: Wartung des ganzen Knotens (nil = keine).
Maintenance *Maintenance
// ServiceMaintenance: Wartung einzelner Services (Originalnamen), z. B.
// per Label sultan.maintenance=true.
ServiceMaintenance map[string]Maintenance
// Disconnected: Der Knoten ist ungeplant getrennt, seine Lease gilt aber
// noch – Besucher sehen die Wartungsseite (ohne Ende und Grund).
Disconnected bool
}
// ViolationKind klassifiziert einen Verstoß.
@@ -202,7 +226,8 @@ func StageFor(t Tenant, l license.License, now time.Time) Stage {
switch {
case t.Status == store.TenantQuarantined:
return StageBlocked
case t.Status == store.TenantExpired || l.Expired(now):
// Community wird automatisch neu lizenziert: nur der explizite Status zählt.
case t.Status == store.TenantExpired || (l.Tier != license.TierCommunity && l.Expired(now)):
return StageLanding
case l.Tier == license.TierCommunity:
return StageNormal // kein Mahnwesen im Community-Betrieb
@@ -226,7 +251,8 @@ func (e *Engine) Apply(in Input) (Result, error) {
if e.now != nil {
now = e.now
}
stage := StageFor(in.Tenant, in.License, now())
at := now()
stage := StageFor(in.Tenant, in.License, at)
res := Result{Config: &traefik.Configuration{}, Stage: stage}
if stage == StageBlocked || in.NodeDrained || in.Config == nil || in.Config.HTTP == nil {
return res, nil
@@ -289,8 +315,11 @@ func (e *Engine) Apply(in Input) (Result, error) {
out.Services[nr.Service] = copyService(src.Services[r.Service])
}
// Vorrang: Ablauf/Paywall > Wartung (Knoten, Dienst, Healthcheck) >
// Mahnhinweis > normal. (Sperre und drain liefern oben gar keine Routen.)
routerStage := stage
if routerStage == StageNormal && in.DownServices[r.Service] {
maint, inMaint := maintenanceFor(in, r.Service, at)
if inMaint && (stage == StageNormal || slices.Contains(ackStages, stage)) {
routerStage = StageMaintenance
}
if routerStage != StageNormal {
@@ -319,6 +348,14 @@ func (e *Engine) Apply(in Input) (Result, error) {
HeaderTenant: in.Tenant.ID,
HeaderTier: string(in.License.Tier),
}
if routerStage == StageMaintenance {
if !maint.Until.IsZero() {
headers[HeaderUntil] = maint.Until.UTC().Format(time.RFC3339)
}
if maint.PublicReason != "" {
headers[HeaderPublicReason] = url.QueryEscape(maint.PublicReason)
}
}
mwName := "sultan__" + prefix + name
out.Middlewares[mwName] = traefik.Middleware{"headers": map[string]any{"customRequestHeaders": headers}}
nr.Middlewares = append(slices.Clone(nr.Middlewares), mwName)
@@ -336,6 +373,22 @@ func (e *Engine) Apply(in Input) (Result, error) {
return res, nil
}
// maintenanceFor liefert die geltende Wartung eines Services: zuerst die des
// Knotens, dann die des Services, dann ein Healthcheck-Ausfall oder ein
// getrennter Knoten (beides ohne Grund).
func maintenanceFor(in Input, service string, now time.Time) (Maintenance, bool) {
if in.Maintenance.active(now) {
return *in.Maintenance, true
}
if m, ok := in.ServiceMaintenance[service]; ok && m.active(now) {
return m, true
}
if in.DownServices[service] || in.Disconnected {
return Maintenance{}, true
}
return Maintenance{}, false
}
func validateInput(in Input) error {
switch {
case !ValidID(in.Tenant.ID) || in.Tenant.ID == "sultan":
+143 -1
View File
@@ -2,6 +2,7 @@ package policy
import (
"errors"
"net/url"
"reflect"
"testing"
"time"
@@ -296,7 +297,10 @@ func TestStageFor(t *testing.T) {
}{
{"Community aktiv", license.TierCommunity, store.TenantActive, 0, false, StageNormal},
{"Community ohne Mahnwesen", license.TierCommunity, store.TenantActive, 2, false, StageNormal},
{"Community abgelaufen", license.TierCommunity, store.TenantActive, 0, true, StageLanding},
// Community wird automatisch neu lizenziert (5c): kein Ablauf über valid_until …
{"Community mit altem valid_until", license.TierCommunity, store.TenantActive, 0, true, StageNormal},
// … nur der explizite Status expired führt zur Landingpage.
{"Community explizit abgelaufen", license.TierCommunity, store.TenantExpired, 0, false, StageLanding},
{"Pro aktiv", license.TierPro, store.TenantActive, 0, false, StageNormal},
{"Mahnstufe 1", license.TierPro, store.TenantActive, 1, false, StageWatermark},
{"Mahnstufe 2", license.TierPro, store.TenantActive, 2, false, StageFiremark},
@@ -585,3 +589,141 @@ func TestValidID(t *testing.T) {
}
}
}
func headersOf(t *testing.T, res Result, router string) map[string]any {
t.Helper()
mw := res.Config.HTTP.Middlewares["sultan__kunde__node-1__"+router]
if mw == nil {
t.Fatalf("keine Interceptor-Middleware für %s", router)
}
return mw["headers"].(map[string]any)["customRequestHeaders"].(map[string]any)
}
func TestApply_NodeMaintenance(t *testing.T) {
in := input(t, wpLabels())
until := now.Add(2 * time.Hour)
in.Maintenance = &Maintenance{Until: until, PublicReason: "Sicherheitsupdate (CVE-1234) – Sicherheit geht vor."}
res, err := engine().Apply(in)
if err != nil {
t.Fatal(err)
}
if res.Config.HTTP.Routers["kunde__node-1__wp"].Service != InterceptorService {
t.Fatal("Knoten in Wartung nicht zum Interceptor umgeleitet")
}
h := headersOf(t, res, "wp")
if h[HeaderStage] != string(StageMaintenance) || h[HeaderUntil] != until.Format(time.RFC3339) {
t.Errorf("Header: %v", h)
}
// Öffentlicher Grund URL-kodiert (Header sind einzeilig, Traefik reicht Bytes nicht zuverlässig durch).
if h[HeaderPublicReason] != url.QueryEscape("Sicherheitsupdate (CVE-1234) – Sicherheit geht vor.") {
t.Errorf("öffentlicher Grund: %v", h[HeaderPublicReason])
}
}
func TestApply_NodeMaintenanceUnlimitedAndExpired(t *testing.T) {
in := input(t, wpLabels())
in.Maintenance = &Maintenance{} // unbefristet, ohne Grund
res, _ := engine().Apply(in)
h := headersOf(t, res, "wp")
if _, ok := h[HeaderUntil]; ok {
t.Error("unbefristete Wartung mit Ende-Header")
}
if _, ok := h[HeaderPublicReason]; ok {
t.Error("Wartung ohne öffentlichen Grund mit Grund-Header")
}
in.Maintenance = &Maintenance{Until: now} // gerade abgelaufen
res, _ = engine().Apply(in)
if res.Config.HTTP.Routers["kunde__node-1__wp"].Service == InterceptorService {
t.Error("abgelaufene Wartung leitet noch um")
}
}
func TestApply_ServiceMaintenance(t *testing.T) {
labels := wpLabels()
labels["traefik.http.routers.wp.service"] = "wp"
labels["traefik.http.routers.shop.rule"] = "Host(`shop.kunde.de`)"
labels["traefik.http.routers.shop.service"] = "shop"
labels["traefik.http.services.shop.loadbalancer.server.port"] = "8080"
in := input(t, labels)
in.ServiceMaintenance = map[string]Maintenance{"shop": {PublicReason: "Inventur"}}
res, _ := engine().Apply(in)
if res.Config.HTTP.Routers["kunde__node-1__shop"].Service != InterceptorService {
t.Error("Dienst in Wartung nicht umgeleitet")
}
if res.Config.HTTP.Routers["kunde__node-1__wp"].Service == InterceptorService {
t.Error("anderer Dienst wurde umgeleitet")
}
if h := headersOf(t, res, "shop"); h[HeaderPublicReason] != "Inventur" {
t.Errorf("Header: %v", h)
}
}
// Vorrang: Sperre/drain > Ablauf/Paywall > Wartung > Mahnhinweis > normal.
func TestApply_MaintenancePrecedence(t *testing.T) {
tests := []struct {
name string
mutate func(*Input)
stage Stage // Stufe am Router; "" = keine Routen
ackSeen bool
}{
{"Wartung schlägt Mahnstufe 1", func(in *Input) { in.Tenant.DunningLevel = 1 }, StageMaintenance, false},
{"Paywall schlägt Wartung", func(in *Input) { in.Tenant.Status = store.TenantSuspendedPayment }, StagePaywall, false},
{"Ablauf schlägt Wartung", func(in *Input) { in.Tenant.Status = store.TenantExpired }, StageLanding, false},
{"drain schlägt Wartung", func(in *Input) { in.NodeDrained = true }, "", false},
{"Quarantäne schlägt Wartung", func(in *Input) { in.Tenant.Status = store.TenantQuarantined }, "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
in := input(t, wpLabels())
in.Maintenance = &Maintenance{}
tt.mutate(&in)
res, err := engine().Apply(in)
if err != nil {
t.Fatal(err)
}
if tt.stage == "" {
if res.Config.HTTP != nil {
t.Errorf("erwartet keine Routen: %v", keys(res.Config.HTTP.Routers))
}
return
}
if h := headersOf(t, res, "wp"); h[HeaderStage] != string(tt.stage) {
t.Errorf("Stufe %v, erwartet %s", h[HeaderStage], tt.stage)
}
if _, ack := res.Config.HTTP.Routers["sultan__kunde__node-1__wp"]; ack != tt.ackSeen {
t.Errorf("Durchlass-Router vorhanden=%v", ack)
}
})
}
}
// Ungeplant getrennt (Lease noch gültig): Wartungsseite ohne Ende und ohne Grund.
func TestApply_DisconnectedNode(t *testing.T) {
in := input(t, wpLabels())
in.Disconnected = true
res, _ := engine().Apply(in)
h := headersOf(t, res, "wp")
if h[HeaderStage] != string(StageMaintenance) {
t.Errorf("Stufe %v", h[HeaderStage])
}
if _, ok := h[HeaderUntil]; ok {
t.Error("getrennter Knoten mit Ende-Header")
}
// Angemeldete Wartung hat Vorrang (deren Grund/Ende wird gezeigt).
in.Maintenance = &Maintenance{PublicReason: "Reboot nach Update"}
res, _ = engine().Apply(in)
if h := headersOf(t, res, "wp"); h[HeaderPublicReason] != url.QueryEscape("Reboot nach Update") {
t.Errorf("Grund der Wartung fehlt: %v", h)
}
// Paywall bleibt Paywall.
in.Maintenance = nil
in.Tenant.Status = store.TenantSuspendedPayment
res, _ = engine().Apply(in)
if h := headersOf(t, res, "wp"); h[HeaderStage] != string(StagePaywall) {
t.Errorf("Paywall verdrängt: %v", h[HeaderStage])
}
}
+47 -9
View File
@@ -16,6 +16,8 @@ import (
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"sync"
"sultan/pkg/license"
@@ -54,11 +56,22 @@ type NodeState struct {
// NodeContext ist, was die Policy über einen Knoten wissen muss.
type NodeContext struct {
Tenant policy.Tenant
License license.License
Drained bool
Tenant policy.Tenant
License license.License
Drained bool
Maintenance *policy.Maintenance // Wartung des Knotens (nil = keine)
// Disconnected: Knoten getrennt, Routen werden (noch) gehalten – sie
// zeigen die Wartungsseite.
Disconnected bool
}
// Labels, mit denen ein Workload (Container, sultan.d-Dienst) seine Services
// in Wartung schickt.
const (
LabelMaintenance = "sultan.maintenance"
LabelMaintenancePublicReason = "sultan.maintenance.public_reason"
)
// Directory liefert den NodeContext; für gelöschte/unbekannte Knoten
// ErrUnknownNode (ggf. verpackt).
type Directory interface {
@@ -207,6 +220,7 @@ func (r *Registry) evaluate(ctx context.Context, nodeID string, state NodeState)
workloads := slices.Clone(state.Workloads)
slices.SortStableFunc(workloads, func(a, b Workload) int { return cmp.Compare(a.Name, b.Name) })
merged := &traefik.Configuration{}
serviceMaint := map[string]policy.Maintenance{}
seen := map[string]bool{}
for _, w := range workloads {
if seen[w.Name] {
@@ -225,6 +239,11 @@ func (r *Registry) evaluate(ctx context.Context, nodeID string, state NodeState)
continue
}
merged = m
if maint, ok := workloadMaintenance(w.Labels); ok && c.HTTP != nil {
for svc := range c.HTTP.Services {
serviceMaint[svc] = maint
}
}
}
down := map[string]bool{}
@@ -232,12 +251,15 @@ func (r *Registry) evaluate(ctx context.Context, nodeID string, state NodeState)
down[s] = true
}
res, err := r.Policy.Apply(policy.Input{
Tenant: nc.Tenant,
License: nc.License,
NodeID: nodeID,
NodeDrained: nc.Drained,
Config: merged,
DownServices: down,
Tenant: nc.Tenant,
License: nc.License,
NodeID: nodeID,
NodeDrained: nc.Drained,
Config: merged,
DownServices: down,
Maintenance: nc.Maintenance,
ServiceMaintenance: serviceMaint,
Disconnected: nc.Disconnected,
})
if err != nil {
return nil, err
@@ -249,6 +271,22 @@ func (r *Registry) evaluate(ctx context.Context, nodeID string, state NodeState)
return entry, nil
}
// workloadMaintenance liest sultan.maintenance(=true) und den optionalen
// öffentlichen Grund; Schlüssel ohne Beachtung der Groß-/Kleinschreibung.
func workloadMaintenance(labels map[string]string) (policy.Maintenance, bool) {
var on bool
var m policy.Maintenance
for k, v := range labels {
switch strings.ToLower(k) {
case LabelMaintenance:
on, _ = strconv.ParseBool(strings.TrimSpace(v))
case LabelMaintenancePublicReason:
m.PublicReason = strings.TrimSpace(v)
}
}
return m, on
}
// store übernimmt entry und meldet nur Verstöße, die vorher nicht bestanden.
func (r *Registry) store(nodeID string, entry *nodeEntry) {
if r.nodes == nil {
+43
View File
@@ -398,3 +398,46 @@ func TestRegistry_Concurrent(t *testing.T) {
t.Errorf("%d Router, erwartet 20", n)
}
}
// Container/Dienst in Wartung per Label: nur dessen Router gehen zum Lander.
func TestRegistry_ServiceMaintenanceLabel(t *testing.T) {
r, _, _ := newRegistry()
shop := wp("shop", "100.64.0.1", "shop.kunde.de")
shop.Labels["traefik.http.routers.wp.rule"] = "Host(`shop.kunde.de`)"
shop.Labels = map[string]string{
"traefik.enable": "true",
"traefik.http.routers.shop.rule": "Host(`shop.kunde.de`)",
"traefik.http.services.shop.loadbalancer.server.port": "8080",
"sultan.maintenance": "true",
"sultan.maintenance.public_reason": "Inventur",
}
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de"), shop}})
cfg := r.Config().HTTP
if cfg.Routers["kunde__n1__shop"].Service != policy.InterceptorService {
t.Error("Dienst mit sultan.maintenance=true nicht umgeleitet")
}
if cfg.Routers["kunde__n1__wp"].Service == policy.InterceptorService {
t.Error("anderer Dienst wurde umgeleitet")
}
hdr := cfg.Middlewares["sultan__kunde__n1__shop"]["headers"].(map[string]any)["customRequestHeaders"].(map[string]any)
if hdr[policy.HeaderPublicReason] != "Inventur" {
t.Errorf("öffentlicher Grund: %v", hdr)
}
shop.Labels["sultan.maintenance"] = "false"
r.Update(ctx, "n1", NodeState{Workloads: []Workload{shop}})
if r.Config().HTTP.Routers["kunde__n1__shop"].Service == policy.InterceptorService {
t.Error("sultan.maintenance=false leitet um")
}
}
func TestRegistry_NodeMaintenanceFromDirectory(t *testing.T) {
r, dir, _ := newRegistry()
r.Update(ctx, "n1", NodeState{Workloads: []Workload{wp("wordpress", "100.64.0.1", "kunde.de")}})
dir.set("n1", func(nc *NodeContext) { nc.Maintenance = &policy.Maintenance{Until: time.Now().Add(time.Hour)} })
r.Refresh(ctx)
if r.Config().HTTP.Routers["kunde__n1__wp"].Service != policy.InterceptorService {
t.Error("Knotenwartung aus dem Directory nicht angewendet")
}
}
+34 -11
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"time"
"sultan/pkg/license"
"sultan/pkg/policy"
@@ -14,8 +15,8 @@ import (
// directory liefert der Registry Mandant, Lizenz und Drain-Status eines
// Knotens aus dem Store.
//
// Solange Migration v2 fehlt (Spec Abschnitt 9), wird die Lizenz aus dem
// Mandanten-Datensatz abgeleitet: ohne Features und ohne Mahnstufe.
// Die Lizenz wird aus dem Mandanten-Datensatz abgeleitet (inkl. Features);
// die Mahnstufe ergibt sich aus der offenen Zahlung (DunningSchedule).
type directory struct {
store *store.Store
}
@@ -36,15 +37,37 @@ func (d *directory) NodeContext(ctx context.Context, nodeID string) (registry.No
return registry.NodeContext{}, err
}
return registry.NodeContext{
Tenant: policy.Tenant{ID: t.ID, Status: t.Status},
License: license.License{
TenantID: t.ID,
Tier: license.Tier(t.Tier),
MaxRoutes: t.MaxRoutes,
AllowedDomains: t.AllowedDomains,
Features: []license.Feature{},
ValidUntil: t.ValidUntil,
Tenant: policy.Tenant{
ID: t.ID,
Status: t.Status,
DunningLevel: policy.DunningSchedule{}.Level(t.PaymentOverdueSince, time.Now()),
},
Drained: node.Status == store.NodeDrained,
License: licenseOf(t),
Drained: node.Status == store.NodeDrained,
Maintenance: maintenanceOf(node),
Disconnected: node.Status == store.NodeOffline,
}, nil
}
// licenseOf leitet die (unsignierte) Lizenz aus dem Mandanten-Datensatz ab.
func licenseOf(t store.Tenant) license.License {
features := make([]license.Feature, 0, len(t.Features))
for _, f := range t.Features {
features = append(features, license.Feature(f))
}
return license.License{
TenantID: t.ID,
Tier: license.Tier(t.Tier),
MaxRoutes: t.MaxRoutes,
AllowedDomains: t.AllowedDomains,
Features: features,
ValidUntil: t.ValidUntil,
}
}
func maintenanceOf(n store.Node) *policy.Maintenance {
if n.Maintenance == nil {
return nil
}
return &policy.Maintenance{Until: n.Maintenance.Until, PublicReason: n.Maintenance.PublicReason}
}
+118
View File
@@ -3,9 +3,12 @@ package server
import (
"encoding/json"
"errors"
"math"
"net"
"net/http"
"strconv"
"strings"
"time"
"sultan/pkg/auth"
"sultan/pkg/policy"
@@ -28,17 +31,40 @@ type EnrollResponse struct {
MasterPublicKey string `json:"master_public_key"` // zum Prüfen von Lizenzen/Leases
}
// MaintenanceRequest ist der Body von POST /api/v1/maintenance: Der Knoten
// meldet Wartung an oder ab (z. B. aus einem Patch-/Reboot-Skript).
type MaintenanceRequest struct {
Enabled bool `json:"enabled"`
Duration string `json:"duration,omitempty"` // Go-Dauer; "" = 4h, "0" = unbefristet
Reason string `json:"reason,omitempty"` // intern
PublicReason string `json:"public_reason,omitempty"`
}
// RotateKeyRequest ist der Body von POST /api/v1/rotate-key. Der Request ist
// mit dem bisherigen Schlüssel signiert; Proof (auth.RotationProof) beweist
// den Besitz des neuen.
type RotateKeyRequest struct {
PublicKey string `json:"public_key"`
Proof string `json:"proof"`
}
// DefaultMaintenance ist die Wartungsdauer ohne Angabe.
const DefaultMaintenance = 4 * time.Hour
func (s *Server) agentRoutes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/enroll", s.handleEnroll)
mux.HandleFunc("GET /api/v1/stream", s.handleStream)
mux.HandleFunc("POST /api/v1/state", s.handleState)
mux.HandleFunc("POST /api/v1/maintenance", s.handleMaintenance)
mux.HandleFunc("POST /api/v1/rotate-key", s.handleRotateKey)
return mux
}
func (s *Server) providerRoutes() http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /v1/traefik-config", s.registry.Handler())
mux.HandleFunc("GET /metrics/checkmk", s.handleCheckmk)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("ok\n"))
})
@@ -46,6 +72,13 @@ func (s *Server) providerRoutes() http.Handler {
}
func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
// Vor der Signaturprüfung: Fluten kosten so kaum etwas.
if ok, retry := s.enrollRL.allow(remoteIP(r)); !ok {
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(retry.Seconds()))))
s.log.Warn("Enrollment gebremst (zu viele Versuche)", "ip", remoteIP(r))
http.Error(w, "zu viele Versuche", http.StatusTooManyRequests)
return
}
pub, err := s.verifier.VerifyUnregistered(r)
if err != nil {
authError(w, err)
@@ -144,6 +177,91 @@ func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleMaintenance(w http.ResponseWriter, r *http.Request) {
id, err := s.verifier.Verify(r)
if err != nil {
authError(w, err)
return
}
var req MaintenanceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger Body", http.StatusBadRequest)
return
}
if !req.Enabled {
err = s.store.ClearNodeMaintenance(r.Context(), id.Node.ID)
} else {
d := DefaultMaintenance
if req.Duration != "" {
if d, err = time.ParseDuration(req.Duration); err != nil || d < 0 {
http.Error(w, "duration ungültig (z. B. 30m, 4h; 0 = unbefristet)", http.StatusBadRequest)
return
}
}
m := store.Maintenance{Reason: req.Reason, PublicReason: req.PublicReason}
if d > 0 {
m.Until = time.Now().Add(d)
}
err = s.store.SetNodeMaintenance(r.Context(), id.Node.ID, m)
}
switch {
case errors.Is(err, store.ErrInvalid):
http.Error(w, err.Error(), http.StatusBadRequest)
return
case err != nil:
s.log.Error("Wartung setzen", "node", id.Node.ID, "err", err)
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
if req.Enabled {
s.log.Info("Wartung angemeldet", "node", id.Node.ID, "duration", req.Duration, "reason", req.Reason,
"public_reason", req.PublicReason)
} else {
s.log.Info("Wartung beendet", "node", id.Node.ID)
}
// Sofort wirksam, nicht erst beim nächsten Refresh.
if err := s.registry.Refresh(r.Context()); err != nil {
s.log.Error("Neubewertung nach Wartungsänderung", "err", err)
}
w.WriteHeader(http.StatusNoContent)
}
// handleRotateKey tauscht den Schlüssel eines Knotens. Idempotent: Ist der
// neue Schlüssel schon aktiv (Wiederholung nach einem Abbruch, signiert mit
// dem neuen Schlüssel), antwortet er ebenfalls mit 204.
func (s *Server) handleRotateKey(w http.ResponseWriter, r *http.Request) {
id, err := s.verifier.Verify(r)
if err != nil {
authError(w, err)
return
}
var req RotateKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger Body", http.StatusBadRequest)
return
}
pub, err := auth.DecodePublicKey(req.PublicKey)
if err != nil || !auth.VerifyRotationProof(id.Node.ID, pub, req.Proof) {
http.Error(w, "Besitznachweis für den neuen Schlüssel ungültig", http.StatusBadRequest)
return
}
encoded := auth.EncodePublicKey(pub)
if encoded != id.Node.PublicKey {
err := s.store.UpdateNodePublicKey(r.Context(), id.Node.ID, encoded)
switch {
case errors.Is(err, store.ErrExists):
http.Error(w, "Schlüssel bereits vergeben", http.StatusConflict)
return
case err != nil:
s.log.Error("Schlüssel tauschen", "node", id.Node.ID, "err", err)
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
s.log.Info("Knotenschlüssel gewechselt", "node", id.Node.ID)
}
w.WriteHeader(http.StatusNoContent)
}
func authError(w http.ResponseWriter, err error) {
code := auth.StatusCode(err)
msg := http.StatusText(code)
+229
View File
@@ -0,0 +1,229 @@
package server
import (
"context"
"fmt"
"net/http"
"time"
"sultan/pkg/checkmk"
"sultan/pkg/notify"
"sultan/pkg/policy"
"sultan/pkg/store"
)
// retention: Aufbewahrung von Einladungen, abgelaufenen Leases und
// zugestellten Befehlen (entspricht der Community-Lease-Laufzeit).
const retention = 10 * 24 * time.Hour
func commercial(t store.Tenant) bool { return t.Tier != store.TierCommunity }
// runJobs läuft bei jedem Refresh: Vertragsablauf, Benachrichtigungen,
// Aufräumen.
func (s *Server) runJobs(ctx context.Context) {
now := time.Now()
tenants, err := s.store.ListTenants(ctx)
if err != nil {
s.log.Error("Job: Mandanten lesen", "err", err)
return
}
for _, t := range tenants {
s.expireContract(ctx, t, now)
if s.notifier != nil && commercial(t) {
s.notifyContract(ctx, t, now)
s.notifyDunning(ctx, t, now)
}
}
if s.notifier != nil {
s.notifyOffline(ctx, now)
}
s.housekeeping(ctx)
}
// expireContract setzt kommerzielle Verträge ab dem Ablaufzeitpunkt dauerhaft
// auf expired (Community wird automatisch neu lizenziert).
func (s *Server) expireContract(ctx context.Context, t store.Tenant, now time.Time) {
if !commercial(t) || t.Status == store.TenantExpired || t.Status == store.TenantQuarantined || now.Before(t.ValidUntil) {
return
}
if err := s.store.SetTenantStatus(ctx, t.ID, store.TenantExpired); err != nil {
s.log.Error("Job: Vertrag auf expired setzen", "tenant", t.ID, "err", err)
return
}
s.log.Warn("Vertrag abgelaufen – Mandant auf expired gesetzt", "tenant", t.ID, "valid_until", t.ValidUntil)
}
func (s *Server) notifyContract(ctx context.Context, t store.Tenant, now time.Time) {
day := t.ValidUntil.UTC().Format(time.DateOnly)
days := int(t.ValidUntil.Sub(now).Hours() / 24)
switch {
case t.Status == store.TenantExpired || !now.Before(t.ValidUntil):
s.sendOnce(ctx, "contract:"+t.ID+":"+day+":0", notify.Message{
Severity: notify.Critical,
Title: "Vertrag " + t.ID + " abgelaufen",
Body: fmt.Sprintf("Der Vertrag von *%s* ist am %s abgelaufen. Die Seiten zeigen die Landingpage.", t.ID, day),
})
case days <= checkmk.ContractCritDays:
s.sendOnce(ctx, "contract:"+t.ID+":"+day+":14", notify.Message{
Severity: notify.Critical,
Title: fmt.Sprintf("Vertrag %s läuft in %d Tagen ab", t.ID, days),
Body: fmt.Sprintf("Vertragsende für *%s*: %s. Verlängerung erforderlich.", t.ID, day),
})
case days <= checkmk.ContractWarnDays:
s.sendOnce(ctx, "contract:"+t.ID+":"+day+":30", notify.Message{
Severity: notify.Warning,
Title: fmt.Sprintf("Vertrag %s läuft in %d Tagen ab", t.ID, days),
Body: fmt.Sprintf("Vertragsende für *%s*: %s.", t.ID, day),
})
}
}
func (s *Server) notifyDunning(ctx context.Context, t store.Tenant, now time.Time) {
level := policy.DunningSchedule{}.Level(t.PaymentOverdueSince, now)
if level == 0 {
return
}
sev := notify.Warning
if level >= 2 {
sev = notify.Critical
}
text := map[int]string{1: "Hinweisseite mit 5 s Weiterleitung", 2: "Hinweisseite mit 10 s Weiterleitung", 3: "Kunde offline (Einladungsseite)"}[min(level, 3)]
s.sendOnce(ctx, fmt.Sprintf("dunning:%s:%s:%d", t.ID, t.PaymentOverdueSince.UTC().Format(time.DateOnly), level), notify.Message{
Severity: sev,
Title: fmt.Sprintf("Mahnstufe %d für %s", level, t.ID),
Body: fmt.Sprintf("Zahlung von *%s* seit %s offen. Wirkung: %s.", t.ID, t.PaymentOverdueSince.UTC().Format(time.DateOnly), text),
})
}
// notifyOffline alarmiert einmal pro Ausfall, wenn ein Knoten länger als
// OfflineAlertAfter getrennt ist – nicht bei angemeldeter Wartung oder drain.
func (s *Server) notifyOffline(ctx context.Context, now time.Time) {
nodes, err := s.store.ListNodes(ctx, "")
if err != nil {
return
}
for _, n := range nodes {
if n.Status != store.NodeOffline || n.Maintenance.Active(now) || now.Sub(n.LastSeen) < s.cfg.OfflineAlertAfter {
continue
}
s.sendOnce(ctx, "offline:"+n.ID+":"+n.LastSeen.UTC().Format(time.RFC3339Nano), notify.Message{
Severity: notify.Critical,
Title: "Knoten " + n.ID + " offline",
Body: fmt.Sprintf("Knoten *%s* (Mandant %s, %s) ist seit %s getrennt. Besucher sehen die Wartungsseite, solange die Lease gilt.",
n.ID, n.TenantID, n.Hostname, n.LastSeen.Local().Format("2006-01-02 15:04")),
})
}
}
// notifyBackOnline meldet „wieder online“ – nur für Ausfälle, zu denen ein
// Offline-Alarm verschickt wurde, und je Ausfall genau einmal.
func (s *Server) notifyBackOnline(nodeID string) {
ctx := context.Background()
prefix := "offline:" + nodeID + ":"
keys, err := s.store.NotificationKeys(ctx, prefix)
if err != nil {
return
}
for _, k := range keys {
msg := notify.Message{Severity: notify.Info, Title: "Knoten " + nodeID + " wieder online"}
if since, err := time.Parse(time.RFC3339Nano, k[len(prefix):]); err == nil {
msg.Body = fmt.Sprintf("Knoten *%s* ist nach %s wieder verbunden.", nodeID, outage(time.Since(since)))
} else {
msg.Body = fmt.Sprintf("Knoten *%s* ist wieder verbunden.", nodeID)
}
s.sendOnce(ctx, "recovered:"+k, msg)
}
}
func outage(d time.Duration) string {
switch {
case d < time.Minute:
return fmt.Sprintf("%d s", int(d/time.Second))
case d < time.Hour:
return fmt.Sprintf("%d min", int(d/time.Minute))
default:
return fmt.Sprintf("%d h %d min", int(d/time.Hour), int(d%time.Hour/time.Minute))
}
}
// sendOnce verschickt eine Nachricht genau einmal je Schlüssel; scheitert
// der Versand, wird es beim nächsten Durchlauf erneut versucht.
func (s *Server) sendOnce(ctx context.Context, key string, m notify.Message) {
if s.store.Notified(ctx, key) {
return
}
if err := s.notifier.Send(ctx, m); err != nil {
s.log.Warn("Benachrichtigung nicht zugestellt – nächster Versuch beim nächsten Durchlauf", "key", key, "err", err)
return
}
if err := s.store.RecordNotification(ctx, key); err != nil {
s.log.Error("Benachrichtigung vermerken", "key", key, "err", err)
}
}
func (s *Server) housekeeping(ctx context.Context) {
for name, purge := range map[string]func(context.Context, time.Duration) (int64, error){
"Einladungen": s.store.PurgeEnrollmentTokens,
"Leases": s.store.PurgeLeases,
"Befehle": s.store.PurgeNodeCommands,
} {
if n, err := purge(ctx, retention); err != nil {
s.log.Error("Aufräumen", "was", name, "err", err)
} else if n > 0 {
s.log.Info("Aufgeräumt", "was", name, "anzahl", n)
}
}
}
// recordViolation zählt Verstöße für Checkmk (letzte 24 h, im Speicher).
func (s *Server) recordViolation(at time.Time) {
s.vmu.Lock()
defer s.vmu.Unlock()
s.violations = append(s.violations, at)
}
func (s *Server) violations24h(now time.Time) int {
s.vmu.Lock()
defer s.vmu.Unlock()
cutoff := now.Add(-24 * time.Hour)
i := 0
for i < len(s.violations) && s.violations[i].Before(cutoff) {
i++
}
s.violations = s.violations[i:]
return len(s.violations)
}
// handleCheckmk liefert den Zustand im Checkmk-Local-Check-Format.
func (s *Server) handleCheckmk(w http.ResponseWriter, r *http.Request) {
ctx, now := r.Context(), time.Now()
state := checkmk.State{Violations24h: s.violations24h(now), OfflineCrit: s.cfg.OfflineAlertAfter, Location: time.Local}
tenants, err := s.store.ListTenants(ctx)
if err != nil {
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
for _, t := range tenants {
state.Tenants = append(state.Tenants, checkmk.Tenant{
ID: t.ID, Commercial: commercial(t), Status: string(t.Status), ValidUntil: t.ValidUntil,
DunningLevel: policy.DunningSchedule{}.Level(t.PaymentOverdueSince, now), OverdueSince: t.PaymentOverdueSince,
})
}
nodes, err := s.store.ListNodes(ctx, "")
if err != nil {
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
for _, n := range nodes {
cn := checkmk.Node{ID: n.ID, Status: string(n.Status), OfflineSince: n.LastSeen}
if n.Maintenance.Active(now) {
cn.MaintenanceActive, cn.MaintenanceUntil, cn.MaintenanceReason = true, n.Maintenance.Until, n.Maintenance.Reason
}
if l, err := s.store.GetLease(ctx, n.ID); err == nil {
cn.LeaseUntil = l.ValidUntil
}
state.Nodes = append(state.Nodes, cn)
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(checkmk.Render(state, now)))
}
+92
View File
@@ -0,0 +1,92 @@
package server
import (
"context"
"encoding/json"
"errors"
"time"
"sultan/pkg/license"
"sultan/pkg/store"
"sultan/pkg/stream"
)
// ensureLease stellt die Lease eines verbundenen Knotens aus bzw. erneuert
// sie ab renew_after und schickt sie per lease_renew. Mit always wird auch
// eine noch gültige Lease erneut zugestellt (beim Verbinden).
func (s *Server) ensureLease(nodeID string, always bool) {
ctx := context.Background()
now := time.Now()
current, err := s.store.GetLease(ctx, nodeID)
if err == nil && now.Before(current.RenewAfter) {
if always {
s.sendLease(nodeID, []byte(current.Document))
}
return
}
if err != nil && !errors.Is(err, store.ErrNotFound) {
s.log.Error("Lease lesen", "node", nodeID, "err", err)
return
}
node, err := s.store.GetNode(ctx, nodeID)
if err != nil {
return
}
tenant, err := s.store.GetTenant(ctx, node.TenantID)
if err != nil || tenant.Status == store.TenantQuarantined {
return
}
lic := licenseOf(tenant)
terms, ok := license.TermsFor(lic.Tier, node.Maintenance.Active(now), lic.ValidUntil, now, s.cfg.LeaseDuration, nil)
if !ok {
s.log.Warn("Keine Lease: Vertrag abgelaufen", "node", nodeID, "tenant", tenant.ID)
return
}
lease, err := (&license.Issuer{Key: s.master}).IssueLease(lic, nodeID, terms, now)
if err != nil {
s.log.Error("Lease ausstellen", "node", nodeID, "err", err)
return
}
doc, _ := json.Marshal(lease)
if err := s.store.PutLease(ctx, store.Lease{
NodeID: nodeID, IssuedAt: lease.IssuedAt, RenewAfter: lease.RenewAfter, ValidUntil: lease.ValidUntil, Document: string(doc),
}); err != nil {
s.log.Error("Lease speichern", "node", nodeID, "err", err)
return
}
s.log.Info("Lease ausgestellt", "node", nodeID, "valid_until", terms.ValidUntil, "renew_after", terms.RenewAfter)
s.sendLease(nodeID, doc)
}
func (s *Server) sendLease(nodeID string, doc []byte) {
if err := s.hub.Send(nodeID, stream.Event{Type: stream.EventLeaseRenew, Payload: doc}); err != nil {
s.log.Debug("Lease nicht zugestellt", "node", nodeID, "err", err)
}
}
// leaseValid meldet, ob der Knoten eine noch gültige Lease hat.
func (s *Server) leaseValid(nodeID string) bool {
l, err := s.store.GetLease(context.Background(), nodeID)
return err == nil && time.Now().Before(l.ValidUntil)
}
// dispatchCommands stellt Befehle aus der Warteschlange zu.
func (s *Server) dispatchCommands(nodeID string) {
ctx := context.Background()
cmds, err := s.store.PendingNodeCommands(ctx, nodeID)
if err != nil {
s.log.Error("Befehle lesen", "node", nodeID, "err", err)
return
}
for _, c := range cmds {
payload, _ := json.Marshal(stream.NodeCommand{Command: c.Command, Reason: c.Reason})
if err := s.hub.Send(nodeID, stream.Event{Type: stream.EventNodeCommand, Payload: payload}); err != nil {
return // später erneut (nächster Refresh oder nächste Verbindung)
}
if err := s.store.MarkNodeCommandSent(ctx, c.ID); err != nil {
s.log.Error("Befehl als gesendet markieren", "node", nodeID, "err", err)
return
}
s.log.Info("Befehl zugestellt", "node", nodeID, "command", c.Command, "reason", c.Reason)
}
}
+61
View File
@@ -0,0 +1,61 @@
package server
import (
"sync"
"time"
)
// rateLimiter begrenzt Versuche je Schlüssel (Client-IP) auf limit pro
// window (gleitendes Fenster). limit < 0 = unbegrenzt.
type rateLimiter struct {
limit int
window time.Duration
now func() time.Time
mu sync.Mutex
hits map[string][]time.Time
lastPurge time.Time
}
// allow verbucht einen Versuch; bei Ablehnung liefert es die Wartezeit.
func (l *rateLimiter) allow(key string) (bool, time.Duration) {
if l.limit < 0 {
return true, 0
}
now := time.Now()
if l.now != nil {
now = l.now()
}
cutoff := now.Add(-l.window)
l.mu.Lock()
defer l.mu.Unlock()
if l.hits == nil {
l.hits = map[string][]time.Time{}
}
if now.Sub(l.lastPurge) >= l.window { // vergessen, was nicht mehr zählt
for k, ts := range l.hits {
if !ts[len(ts)-1].After(cutoff) {
delete(l.hits, k)
}
}
l.lastPurge = now
}
ts := l.hits[key]
i := 0
for i < len(ts) && !ts[i].After(cutoff) {
i++
}
ts = ts[i:]
if len(ts) >= l.limit {
l.hits[key] = ts
return false, ts[0].Add(l.window).Sub(now)
}
l.hits[key] = append(ts, now)
return true, 0
}
func (l *rateLimiter) size() int {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.hits)
}
+56
View File
@@ -0,0 +1,56 @@
package server
import (
"testing"
"time"
)
func TestRateLimiter(t *testing.T) {
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
l := &rateLimiter{limit: 3, window: time.Minute, now: func() time.Time { return now }}
for i := range 3 {
if ok, _ := l.allow("1.2.3.4"); !ok {
t.Fatalf("Versuch %d abgelehnt", i+1)
}
}
ok, retry := l.allow("1.2.3.4")
if ok || retry != time.Minute {
t.Errorf("4. Versuch: ok=%v retry=%v, erwartet abgelehnt mit 1 min", ok, retry)
}
if ok, _ := l.allow("5.6.7.8"); !ok {
t.Error("andere IP mitbetroffen")
}
now = now.Add(40 * time.Second)
if _, retry := l.allow("1.2.3.4"); retry != 20*time.Second {
t.Errorf("Retry-After nach 40 s: %v, erwartet 20 s", retry)
}
now = now.Add(20 * time.Second)
if ok, _ := l.allow("1.2.3.4"); !ok {
t.Error("nach Ablauf des Fensters weiter gesperrt")
}
}
// Die Zählerliste darf bei vielen IPs nicht unbegrenzt wachsen.
func TestRateLimiter_ForgetsOldEntries(t *testing.T) {
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
l := &rateLimiter{limit: 1, window: time.Minute, now: func() time.Time { return now }}
for i := range 1000 {
l.allow(string(rune('a'+i%26)) + time.Duration(i).String())
}
now = now.Add(2 * time.Minute)
l.allow("neu")
if n := l.size(); n != 1 {
t.Errorf("%d Einträge nach Ablauf, erwartet 1", n)
}
}
func TestRateLimiter_Disabled(t *testing.T) {
l := &rateLimiter{limit: -1, window: time.Minute}
for range 100 {
if ok, _ := l.allow("x"); !ok {
t.Fatal("Limit -1 muss unbegrenzt sein")
}
}
}
+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)
}
}()
}
+725 -9
View File
@@ -3,6 +3,7 @@ package server
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"io"
@@ -10,6 +11,7 @@ import (
"net/http"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
@@ -17,6 +19,8 @@ import (
"sultan/pkg/auth"
"sultan/pkg/crypto"
"sultan/pkg/license"
"sultan/pkg/notify"
"sultan/pkg/policy"
"sultan/pkg/registry"
"sultan/pkg/store"
@@ -58,6 +62,7 @@ func startServer(t *testing.T, mutate ...func(*Config)) (*Server, *syncBuffer) {
DataDir: filepath.Join(t.TempDir(), "suldata"),
AgentAddr: "127.0.0.1:0",
ProviderAddr: "127.0.0.1:0",
LanderAddr: "127.0.0.1:0",
RefreshInterval: time.Hour,
Logger: slog.New(slog.NewTextHandler(logs, nil)),
}
@@ -92,7 +97,7 @@ func createTenant(t *testing.T, s *Server, id string, domains ...string) {
// issueToken erzeugt einen Enrollment-Token wie `sultan token` es tun wird.
func issueToken(t *testing.T, s *Server, tenant string) string {
t.Helper()
tok, err := crypto.NewEnrollmentToken(s.Fingerprint())
tok, err := crypto.NewEnrollmentToken(s.Fingerprint(), "")
if err != nil {
t.Fatal(err)
}
@@ -116,11 +121,10 @@ func newAgent(t *testing.T, s *Server, token string) *testAgent {
if err != nil {
t.Fatal(err)
}
ks := &crypto.KnownServers{Path: filepath.Join(t.TempDir(), "known_servers")}
priv, _ := auth.GenerateKey()
return &testAgent{
base: "https://" + s.AgentAddr(),
hc: &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.ClientTLSConfig(s.AgentAddr(), ks, tok.Fingerprint)}},
hc: &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(tok.Fingerprint)}},
signer: &auth.Signer{Key: priv},
secret: tok.Secret,
}
@@ -151,13 +155,46 @@ func (a *testAgent) enroll(t *testing.T, nodeID string) *http.Response {
// connect hält den Stream im Hintergrund offen, bis cancel aufgerufen wird.
func (a *testAgent) connect(t *testing.T) (cancel func()) {
t.Helper()
cancel, _ = a.connectEvents(t)
return cancel
}
// connectEvents hält den Stream offen und liefert die empfangenen Events.
func (a *testAgent) connectEvents(t *testing.T) (cancel func(), events <-chan stream.Event) {
t.Helper()
sctx, cancel := context.WithCancel(ctx)
ch := make(chan stream.Event, 64)
c := &stream.Client{URL: a.base + "/api/v1/stream", HTTPClient: a.hc, Prepare: a.signer.Sign, MinBackoff: 10 * time.Millisecond}
done := make(chan struct{})
go func() { c.Run(sctx, func(stream.Event) error { return nil }); close(done) }()
go func() {
c.Run(sctx, func(ev stream.Event) error {
select {
case ch <- ev:
default:
}
return nil
})
close(done)
}()
t.Cleanup(func() { cancel(); <-done })
return cancel
return cancel, ch
}
// nextEvent wartet auf das nächste Event des Typs typ.
func nextEvent(t *testing.T, events <-chan stream.Event, typ stream.EventType) stream.Event {
t.Helper()
deadline := time.After(3 * time.Second)
for {
select {
case ev := <-events:
if ev.Type == typ {
return ev
}
case <-deadline:
t.Fatalf("kein Event %s empfangen", typ)
}
}
}
func wpState(domain string) registry.NodeState {
@@ -187,7 +224,7 @@ func TestConfigDefaults(t *testing.T) {
if c.DataDir != "./suldata" || c.AgentAddr != ":8443" || c.ProviderAddr != "127.0.0.1:8080" {
t.Errorf("Defaults: %+v", c)
}
if c.InterceptorURL != "http://127.0.0.1:8081" || c.RefreshInterval != time.Minute || c.Logger == nil {
if c.InterceptorURL != "http://127.0.0.1:8081" || c.LanderAddr != "127.0.0.1:8081" || c.RefreshInterval != time.Minute || c.Logger == nil {
t.Errorf("Defaults: %+v", c)
}
}
@@ -299,8 +336,11 @@ func TestEndToEnd(t *testing.T) {
t.Errorf("Traefik-Konfiguration: %s", cfg)
}
// Ungeplant getrennt: Routen bleiben als Wartungsseite, solange die Lease gilt.
stop()
eventually(t, "Route nach Trennung entfernt", func() bool { return providerConfig(t, s) == "{}" })
eventually(t, "Wartungsseite nach Trennung", func() bool {
return strings.Contains(providerConfig(t, s), policy.InterceptorService)
})
eventually(t, "Knoten offline", func() bool {
n, _ := s.store.GetNode(ctx, "node-1")
return n.Status == store.NodeOffline
@@ -442,7 +482,7 @@ func TestDirectory(t *testing.T) {
func TestRun_ShutdownWithOpenStream(t *testing.T) {
cfg := Config{
DataDir: filepath.Join(t.TempDir(), "suldata"), AgentAddr: "127.0.0.1:0", ProviderAddr: "127.0.0.1:0",
Logger: slog.New(slog.DiscardHandler),
LanderAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler),
}
s, err := New(ctx, cfg)
if err != nil {
@@ -474,7 +514,7 @@ func TestStart_PortInUse(t *testing.T) {
a, _ := startServer(t)
b, err := New(ctx, Config{
DataDir: filepath.Join(t.TempDir(), "suldata"), AgentAddr: a.AgentAddr(), ProviderAddr: "127.0.0.1:0",
Logger: slog.New(slog.DiscardHandler),
LanderAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatal(err)
@@ -484,3 +524,679 @@ func TestStart_PortInUse(t *testing.T) {
t.Error("belegter Port: erwartet Fehler")
}
}
// Wie Traefik: Konfiguration lesen, Router zum Interceptor finden, dessen
// Header-Middleware anwenden und den Lander aufrufen.
func TestLander_EndToEndMaintenance(t *testing.T) {
s, _ := startServer(t, func(c *Config) { c.ProURL = "https://hoster.example/pro" })
if s.LanderAddr() == "" || strings.HasSuffix(s.LanderAddr(), ":0") {
t.Fatalf("Lander-Adresse: %q", s.LanderAddr())
}
createTenant(t, s, "kunde", "kunde.de")
agent := newAgent(t, s, issueToken(t, s, "kunde"))
agent.enroll(t, "n1")
agent.connect(t)
eventually(t, "Stream", func() bool { return s.hub.Connected("n1") })
st := wpState("kunde.de")
st.DownServices = []string{"wp"}
if r := agent.do(t, http.MethodPost, "/api/v1/state", st); r.StatusCode != http.StatusNoContent {
t.Fatalf("State: %d", r.StatusCode)
}
var cfg struct {
HTTP struct {
Routers map[string]struct {
Service string
Middlewares []string
}
Services map[string]struct {
LoadBalancer struct{ Servers []struct{ URL string } }
}
Middlewares map[string]struct {
Headers struct{ CustomRequestHeaders map[string]string }
}
}
}
if err := json.Unmarshal([]byte(providerConfig(t, s)), &cfg); err != nil {
t.Fatal(err)
}
router := cfg.HTTP.Routers["kunde__n1__wp"]
interceptor := cfg.HTTP.Services[router.Service].LoadBalancer.Servers[0].URL
if interceptor != "http://"+s.LanderAddr() {
t.Fatalf("Interceptor-URL %q, erwartet den gebundenen Lander %q", interceptor, s.LanderAddr())
}
req, _ := http.NewRequest(http.MethodGet, interceptor+"/shop", nil)
for _, mw := range router.Middlewares {
for k, v := range cfg.HTTP.Middlewares[mw].Headers.CustomRequestHeaders {
req.Header.Set(k, v)
}
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusServiceUnavailable || resp.Header.Get("Retry-After") != "60" ||
!strings.Contains(string(body), "Gleich wieder da") {
t.Errorf("Lander: %d %q\n%s", resp.StatusCode, resp.Header.Get("Retry-After"), body)
}
}
func TestLander_CustomTemplatesFromDataDir(t *testing.T) {
s, _ := startServer(t)
dir := filepath.Join(s.cfg.DataDir, "landing")
os.MkdirAll(dir, 0o755)
os.WriteFile(filepath.Join(dir, "maintenance.html"), []byte("Eigene Seite {{.Tenant}}"), 0o644)
req, _ := http.NewRequest(http.MethodGet, "http://"+s.LanderAddr()+"/", nil)
req.Header.Set("X-Sultan-Stage", "maintenance")
req.Header.Set("X-Sultan-Tenant", "kunde")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if body, _ := io.ReadAll(resp.Body); string(body) != "Eigene Seite kunde" {
t.Errorf("body = %q", body)
}
}
func TestLander_Disabled(t *testing.T) {
s, _ := startServer(t, func(c *Config) { c.LanderAddr = "none"; c.InterceptorURL = "http://lander.intern:8081" })
if s.LanderAddr() != "" {
t.Errorf("Lander läuft trotz none: %q", s.LanderAddr())
}
if s.registry.Policy.InterceptorURL != "http://lander.intern:8081" {
t.Errorf("explizite Interceptor-URL überschrieben: %q", s.registry.Policy.InterceptorURL)
}
}
// Die öffentliche Adresse wird im Store gemerkt, damit `sultan token invite`
// (eigener Prozess) sie in Einladungen einbetten kann.
func TestPublicURLs(t *testing.T) {
dir := filepath.Join(t.TempDir(), "suldata")
discard := slog.New(slog.DiscardHandler)
if _, err := New(ctx, Config{DataDir: dir, PublicURLs: []string{"https://ok.example:8443", "http://ingress:8443"}, Logger: discard}); err == nil {
t.Error("http-URL akzeptiert")
}
s, err := New(ctx, Config{DataDir: dir, PublicURLs: []string{"https://Ingress.example:8443/", "https://100.64.0.1:8443"}, Logger: discard})
if err != nil {
t.Fatal(err)
}
want := []string{"https://ingress.example:8443", "https://100.64.0.1:8443"}
if got, err := s.PublicURLs(ctx); err != nil || !reflect.DeepEqual(got, want) {
t.Errorf("gespeichert: %v %v", got, err)
}
s.Shutdown(ctx)
// Neustart ohne --public-url: gemerkte Adressen bleiben.
s, err = New(ctx, Config{DataDir: dir, Logger: discard})
if err != nil {
t.Fatal(err)
}
defer s.Shutdown(ctx)
if got, _ := s.PublicURLs(ctx); !reflect.DeepEqual(got, want) {
t.Errorf("nach Neustart: %v", got)
}
}
// Beim Verbinden bekommt der Agent die vollständige Adressliste (signiert).
func TestConfigCommandOnConnect(t *testing.T) {
urls := []string{"https://ingress.example:8443", "https://100.64.0.1:8443"}
s, _ := startServer(t, func(c *Config) { c.PublicURLs = urls })
createTenant(t, s, "kunde", "kunde.de")
agent := newAgent(t, s, issueToken(t, s, "kunde"))
agent.enroll(t, "n1")
_, events := agent.connectEvents(t)
ev := nextEvent(t, events, stream.EventNodeCommand)
if err := stream.VerifyEvent(ev, s.MasterPublicKey()); err != nil {
t.Fatal(err)
}
cmd, err := stream.ParseNodeCommand(ev)
if err != nil || cmd.Command != stream.CmdConfig || !reflect.DeepEqual(cmd.ServerURLs, urls) {
t.Errorf("config-Befehl: %+v %v", cmd, err)
}
}
func TestNoConfigCommandWithoutPublicURLs(t *testing.T) {
s, _ := startServer(t)
createTenant(t, s, "kunde", "kunde.de")
agent := newAgent(t, s, issueToken(t, s, "kunde"))
agent.enroll(t, "n1")
_, events := agent.connectEvents(t)
nextEvent(t, events, stream.EventLeaseRenew)
select {
case ev := <-events:
if ev.Type == stream.EventNodeCommand {
t.Errorf("config ohne Adressen gesendet: %s", ev.Payload)
}
case <-time.After(200 * time.Millisecond):
}
}
// connectedAgent: enrollt, verbindet und meldet wpState(kunde.de).
func connectedAgent(t *testing.T, s *Server, node string) (*testAgent, func()) {
t.Helper()
if _, err := s.store.GetTenant(ctx, "kunde"); err != nil {
createTenant(t, s, "kunde", "kunde.de")
}
agent := newAgent(t, s, issueToken(t, s, "kunde"))
if r := agent.enroll(t, node); r.StatusCode != http.StatusCreated {
t.Fatalf("Enrollment: %d", r.StatusCode)
}
stop := agent.connect(t)
eventually(t, "Stream", func() bool { return s.hub.Connected(node) })
if r := agent.do(t, http.MethodPost, "/api/v1/state", wpState("kunde.de")); r.StatusCode != http.StatusNoContent {
t.Fatalf("State: %d", r.StatusCode)
}
return agent, stop
}
func TestMaintenance_AgentEndpoint(t *testing.T) {
s, logs := startServer(t)
agent, _ := connectedAgent(t, s, "n1")
resp := agent.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{
Enabled: true, Duration: "2h", Reason: "Kernel 6.12", PublicReason: "Sicherheitsupdate",
})
if resp.StatusCode != http.StatusNoContent {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("Wartung an: %d %s", resp.StatusCode, b)
}
n, _ := s.store.GetNode(ctx, "n1")
if m := n.Maintenance; m == nil || m.Reason != "Kernel 6.12" || m.PublicReason != "Sicherheitsupdate" ||
time.Until(m.Until) < 119*time.Minute || time.Until(m.Until) > 121*time.Minute {
t.Errorf("gespeichert: %+v", m)
}
// Sofort wirksam, nicht erst beim nächsten Refresh.
if cfg := providerConfig(t, s); !strings.Contains(cfg, policy.InterceptorService) {
t.Errorf("Wartung nicht sofort wirksam: %s", cfg)
}
if !strings.Contains(logs.String(), "Kernel 6.12") {
t.Error("interner Grund nicht protokolliert")
}
if r := agent.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{Enabled: false}); r.StatusCode != http.StatusNoContent {
t.Fatalf("Wartung aus: %d", r.StatusCode)
}
if n, _ := s.store.GetNode(ctx, "n1"); n.Maintenance != nil {
t.Error("Wartung nicht beendet")
}
if cfg := providerConfig(t, s); strings.Contains(cfg, policy.InterceptorService) {
t.Errorf("nach Wartungsende noch umgeleitet: %s", cfg)
}
}
func TestMaintenance_DefaultAndUnlimited(t *testing.T) {
s, _ := startServer(t)
agent, _ := connectedAgent(t, s, "n1")
agent.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{Enabled: true})
n, _ := s.store.GetNode(ctx, "n1")
if d := time.Until(n.Maintenance.Until); d < 239*time.Minute || d > 241*time.Minute {
t.Errorf("Default-Dauer %v, erwartet 4h", d)
}
agent.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{Enabled: true, Duration: "0"})
if n, _ := s.store.GetNode(ctx, "n1"); n.Maintenance == nil || !n.Maintenance.Until.IsZero() {
t.Errorf("Dauer 0 = unbefristet: %+v", n.Maintenance)
}
}
func TestMaintenance_InvalidRequests(t *testing.T) {
s, _ := startServer(t)
agent, _ := connectedAgent(t, s, "n1")
for name, req := range map[string]any{
"Dauer unlesbar": MaintenanceRequest{Enabled: true, Duration: "bald"},
"Dauer negativ": MaintenanceRequest{Enabled: true, Duration: "-1h"},
"Grund zu lang": MaintenanceRequest{Enabled: true, PublicReason: strings.Repeat("x", 301)},
"Grund mehrzeilig": MaintenanceRequest{Enabled: true, PublicReason: "Zeile 1\nZeile 2"},
"kein JSON-Objekt": "an",
} {
if r := agent.do(t, http.MethodPost, "/api/v1/maintenance", req); r.StatusCode != http.StatusBadRequest {
t.Errorf("%s: Status %d", name, r.StatusCode)
}
}
plain, _ := http.NewRequest(http.MethodPost, agent.base+"/api/v1/maintenance", strings.NewReader(`{"enabled":true}`))
if resp, err := agent.hc.Do(plain); err != nil || resp.StatusCode != http.StatusUnauthorized {
t.Errorf("unsigniert: %v %v", resp, err)
}
}
// Reboot während der Wartung: Routen bleiben als Wartungsseite stehen, bis
// die Wartung endet – erst dann verschwinden sie.
func TestMaintenance_DisconnectKeepsRoutesUntilEnd(t *testing.T) {
// Kurze Lease, damit nach Wartungsende nicht die Lease die Routen hält.
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond; c.LeaseDuration = 150 * time.Millisecond })
agent, stop := connectedAgent(t, s, "n1")
agent.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{Enabled: true, Duration: "400ms"})
stop()
eventually(t, "Knoten getrennt", func() bool { return !s.hub.Connected("n1") })
time.Sleep(100 * time.Millisecond) // einige Refresh-Runden
if cfg := providerConfig(t, s); !strings.Contains(cfg, policy.InterceptorService) {
t.Fatalf("Routen während der Wartung entfernt: %s", cfg)
}
eventually(t, "Routen nach Wartungsende entfernt", func() bool { return providerConfig(t, s) == "{}" })
}
// Per CLI (anderer Prozess, direkt im Store) gesetzte Wartung wirkt beim nächsten Refresh.
func TestMaintenance_FromStoreAppliedOnRefresh(t *testing.T) {
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond })
connectedAgent(t, s, "n1")
if err := s.store.SetNodeMaintenance(ctx, "n1", store.Maintenance{Until: time.Now().Add(time.Hour)}); err != nil {
t.Fatal(err)
}
eventually(t, "Umleitung", func() bool { return strings.Contains(providerConfig(t, s), policy.InterceptorService) })
}
func TestDirectory_Maintenance(t *testing.T) {
s, _ := startServer(t)
createTenant(t, s, "kunde", "kunde.de")
s.store.CreateNode(ctx, store.Node{ID: "n1", TenantID: "kunde", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"})
until := time.Now().Add(time.Hour).UTC().Truncate(time.Second)
s.store.SetNodeMaintenance(ctx, "n1", store.Maintenance{Until: until, Reason: "intern", PublicReason: "öffentlich"})
nc, err := (&directory{store: s.store}).NodeContext(ctx, "n1")
if err != nil {
t.Fatal(err)
}
if nc.Maintenance == nil || !nc.Maintenance.Until.Equal(until) || nc.Maintenance.PublicReason != "öffentlich" {
t.Errorf("Maintenance: %+v", nc.Maintenance)
}
}
func TestLease_IssuedOnConnectAndSigned(t *testing.T) {
s, _ := startServer(t)
createTenant(t, s, "kunde", "kunde.de")
agent := newAgent(t, s, issueToken(t, s, "kunde"))
agent.enroll(t, "n1")
_, events := agent.connectEvents(t)
ev := nextEvent(t, events, stream.EventLeaseRenew)
if err := stream.VerifyEvent(ev, s.MasterPublicKey()); err != nil {
t.Fatalf("Event nicht vom Server signiert: %v", err)
}
l, err := license.ParseLease(ev.Payload, s.MasterPublicKey())
if err != nil {
t.Fatalf("Lease ungültig: %v", err)
}
if l.NodeID != "n1" || l.TenantID != "kunde" {
t.Errorf("Lease: %+v", l)
}
if d := time.Until(l.ValidUntil); d < 7*24*time.Hour-time.Minute || d > 7*24*time.Hour {
t.Errorf("Pro-Lease läuft %v, erwartet 7 Tage", d)
}
stored, err := s.store.GetLease(ctx, "n1")
if err != nil || !stored.ValidUntil.Equal(l.ValidUntil) {
t.Errorf("gespeicherte Lease: %+v %v", stored, err)
}
}
func TestLease_Renewal(t *testing.T) {
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond; c.LeaseDuration = 300 * time.Millisecond })
createTenant(t, s, "kunde", "kunde.de")
agent := newAgent(t, s, issueToken(t, s, "kunde"))
agent.enroll(t, "n1")
_, events := agent.connectEvents(t)
first, _ := license.ParseLease(nextEvent(t, events, stream.EventLeaseRenew).Payload, s.MasterPublicKey())
second, _ := license.ParseLease(nextEvent(t, events, stream.EventLeaseRenew).Payload, s.MasterPublicKey())
if !second.ValidUntil.After(first.ValidUntil) {
t.Errorf("nicht verlängert: %v → %v", first.ValidUntil, second.ValidUntil)
}
}
// Ungeplantes Trennen: Wartungsseite bis zum Ablauf der Lease, danach weg.
func TestDisconnect_RoutesUntilLeaseExpiry(t *testing.T) {
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond; c.LeaseDuration = 600 * time.Millisecond })
_, stop := connectedAgent(t, s, "n1")
stop()
eventually(t, "Wartungsseite", func() bool { return strings.Contains(providerConfig(t, s), policy.InterceptorService) })
eventually(t, "Routen nach Lease-Ablauf entfernt", func() bool { return providerConfig(t, s) == "{}" })
}
// drain entfernt die Routen eines getrennten Knotens sofort – trotz gültiger Lease.
func TestDisconnect_DrainRemovesImmediately(t *testing.T) {
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond })
_, stop := connectedAgent(t, s, "n1")
stop()
eventually(t, "Wartungsseite", func() bool { return strings.Contains(providerConfig(t, s), policy.InterceptorService) })
if err := s.store.SetNodeStatus(ctx, "n1", store.NodeDrained); err != nil {
t.Fatal(err)
}
eventually(t, "drain entfernt Routen", func() bool { return providerConfig(t, s) == "{}" })
}
// Befehle aus der Warteschlange (die CLI ist ein eigener Prozess) werden
// zugestellt – auch solche, die vor dem Verbinden eingereiht wurden.
func TestNodeCommands_Delivered(t *testing.T) {
s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond })
createTenant(t, s, "kunde", "kunde.de")
agent := newAgent(t, s, issueToken(t, s, "kunde"))
agent.enroll(t, "n1")
s.store.EnqueueNodeCommand(ctx, "n1", stream.CmdResync, "")
_, events := agent.connectEvents(t)
ev := nextEvent(t, events, stream.EventNodeCommand)
if err := stream.VerifyEvent(ev, s.MasterPublicKey()); err != nil {
t.Fatalf("Befehl nicht signiert: %v", err)
}
if cmd, err := stream.ParseNodeCommand(ev); err != nil || cmd.Command != stream.CmdResync {
t.Errorf("Befehl: %+v %v", cmd, err)
}
s.store.EnqueueNodeCommand(ctx, "n1", stream.CmdDrain, "Umzug")
ev = nextEvent(t, events, stream.EventNodeCommand)
if cmd, _ := stream.ParseNodeCommand(ev); cmd.Command != stream.CmdDrain || cmd.Reason != "Umzug" {
t.Errorf("zweiter Befehl: %+v", cmd)
}
eventually(t, "als gesendet markiert", func() bool {
cmds, _ := s.store.PendingNodeCommands(ctx, "n1")
return len(cmds) == 0
})
}
func TestRotateKey(t *testing.T) {
s, _ := startServer(t)
agent, _ := connectedAgent(t, s, "n1")
other, _ := connectedAgent(t, s, "n2")
newKey, _ := auth.GenerateKey()
newPub := auth.EncodePublicKey(newKey.Public().(ed25519.PublicKey))
req := RotateKeyRequest{PublicKey: newPub, Proof: auth.RotationProof("n1", newKey)}
if r := agent.do(t, http.MethodPost, "/api/v1/rotate-key", RotateKeyRequest{PublicKey: newPub, Proof: "AAAA"}); r.StatusCode != http.StatusBadRequest {
t.Errorf("falscher Besitznachweis: %d", r.StatusCode)
}
if r := agent.do(t, http.MethodPost, "/api/v1/rotate-key", req); r.StatusCode != http.StatusNoContent {
t.Fatalf("Rotation: %d", r.StatusCode)
}
if n, _ := s.store.GetNode(ctx, "n1"); n.PublicKey != newPub {
t.Error("Schlüssel nicht getauscht")
}
if r := agent.do(t, http.MethodPost, "/api/v1/rotate-key", req); r.StatusCode != http.StatusUnauthorized {
t.Errorf("alter Schlüssel noch gültig: %d", r.StatusCode)
}
oldSigner := agent.signer
agent.signer = &auth.Signer{Key: newKey}
if r := agent.do(t, http.MethodPost, "/api/v1/rotate-key", req); r.StatusCode != http.StatusNoContent {
t.Errorf("Wiederholung mit neuem Schlüssel (Wiederaufnahme nach Absturz): %d", r.StatusCode)
}
agent.signer = oldSigner
// Der Besitznachweis ist an die Knoten-ID gebunden: Ein Nachweis für n2
// gilt nicht für n1.
_ = other
another, _ := auth.GenerateKey()
anotherPub := auth.EncodePublicKey(another.Public().(ed25519.PublicKey))
agent.signer = &auth.Signer{Key: newKey}
foreign := RotateKeyRequest{PublicKey: anotherPub, Proof: auth.RotationProof("n2", another)}
if r := agent.do(t, http.MethodPost, "/api/v1/rotate-key", foreign); r.StatusCode != http.StatusBadRequest {
t.Errorf("Nachweis für anderen Knoten: %d", r.StatusCode)
}
}
func TestDirectory_FeaturesPaymentDisconnected(t *testing.T) {
s, _ := startServer(t)
if _, err := s.store.CreateTenant(ctx, store.Tenant{
ID: "kunde", Name: "k", Email: "k@x.de", Tier: store.TierPro, MaxRoutes: 5,
AllowedDomains: []string{"kunde.de"}, ValidUntil: time.Now().AddDate(1, 0, 0),
Features: []string{"custom_middleware"},
}); err != nil {
t.Fatal(err)
}
s.store.CreateNode(ctx, store.Node{ID: "n1", TenantID: "kunde", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4", Status: store.NodeOffline})
s.store.SetTenantPayment(ctx, "kunde", time.Now().Add(-10*24*time.Hour))
nc, err := (&directory{store: s.store}).NodeContext(ctx, "n1")
if err != nil {
t.Fatal(err)
}
if !nc.License.HasFeature(license.FeatureCustomMiddleware) {
t.Error("Features nicht in der Lizenz")
}
if nc.Tenant.DunningLevel != 1 {
t.Errorf("Mahnstufe %d, erwartet 1 (10 Tage überfällig)", nc.Tenant.DunningLevel)
}
if !nc.Disconnected {
t.Error("getrennter Knoten nicht erkannt")
}
}
// recorder ist ein Benachrichtigungsziel für Tests; failFirst lässt die
// ersten n Zustellungen scheitern.
type recorder struct {
mu sync.Mutex
msgs []notify.Message
failFirst int
}
func (r *recorder) Send(_ context.Context, m notify.Message) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.failFirst > 0 {
r.failFirst--
return errors.New("Ziel nicht erreichbar")
}
r.msgs = append(r.msgs, m)
return nil
}
func (r *recorder) matching(substr string) []notify.Message {
r.mu.Lock()
defer r.mu.Unlock()
var out []notify.Message
for _, m := range r.msgs {
if strings.Contains(m.Title+" "+m.Body, substr) {
out = append(out, m)
}
}
return out
}
func fastJobs(rec *recorder) func(*Config) {
return func(c *Config) {
c.RefreshInterval = 20 * time.Millisecond
c.Notify = rec
}
}
func commercialTenant(t *testing.T, s *Server, id string, validFor time.Duration) {
t.Helper()
if _, err := s.store.CreateTenant(ctx, store.Tenant{
ID: id, Name: id, Email: id + "@example.com", Tier: store.TierPro, MaxRoutes: 10,
AllowedDomains: []string{"kunde.de"}, ValidUntil: time.Now().Add(validFor),
}); err != nil {
t.Fatal(err)
}
}
func TestCheckmkEndpoint(t *testing.T) {
s, _ := startServer(t)
commercialTenant(t, s, "kunde", 20*24*time.Hour)
connectedAgent(t, s, "n1")
resp, err := http.Get("http://" + s.ProviderAddr() + "/metrics/checkmk")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") {
t.Fatalf("Status %d, Content-Type %q", resp.StatusCode, resp.Header.Get("Content-Type"))
}
out := string(body)
for _, want := range []string{`1 "Sultan_Tenant_kunde" days_left=19;30;14`, `0 "Sultan_Payment_kunde"`, `0 "Sultan_Node_n1"`, `0 "Sultan_Policy_Violations"`} {
if !strings.Contains(out, want) {
t.Errorf("fehlt %q in:\n%s", want, out)
}
}
}
func TestJob_ExpiresCommercialTenantOnly(t *testing.T) {
rec := &recorder{}
s, _ := startServer(t, fastJobs(rec))
commercialTenant(t, s, "kunde", 150*time.Millisecond)
if _, err := s.store.CreateTenant(ctx, store.Tenant{
ID: "homelab", Name: "h", Email: "h@x.de", Tier: store.TierCommunity, MaxRoutes: 5,
AllowedDomains: []string{"x.de"}, ValidUntil: time.Now().Add(150 * time.Millisecond),
}); err != nil {
t.Fatal(err)
}
eventually(t, "kommerzieller Vertrag auf expired", func() bool {
tn, _ := s.store.GetTenant(ctx, "kunde")
return tn.Status == store.TenantExpired
})
time.Sleep(100 * time.Millisecond)
if tn, _ := s.store.GetTenant(ctx, "homelab"); tn.Status != store.TenantActive {
t.Errorf("Community abgelaufen: %s", tn.Status)
}
eventually(t, "Benachrichtigung Vertragsende", func() bool { return len(rec.matching("abgelaufen")) == 1 })
if m := rec.matching("abgelaufen")[0]; m.Severity != notify.Critical || !strings.Contains(m.Title, "kunde") {
t.Errorf("Nachricht: %+v", m)
}
}
func TestNotifications_ContractOnce(t *testing.T) {
rec := &recorder{}
s, _ := startServer(t, fastJobs(rec))
commercialTenant(t, s, "kunde", 10*24*time.Hour) // innerhalb der 14-Tage-Schwelle
eventually(t, "Benachrichtigung", func() bool { return len(rec.matching("kunde")) > 0 })
time.Sleep(150 * time.Millisecond) // mehrere Job-Runden
if got := rec.matching("kunde"); len(got) != 1 || got[0].Severity != notify.Critical {
t.Errorf("erwartet genau eine kritische Nachricht, bekommen %+v", got)
}
}
func TestNotifications_Dunning(t *testing.T) {
rec := &recorder{}
s, _ := startServer(t, fastJobs(rec))
commercialTenant(t, s, "kunde", 365*24*time.Hour)
s.store.SetTenantPayment(ctx, "kunde", time.Now().Add(-9*24*time.Hour))
eventually(t, "Mahnstufe 1", func() bool { return len(rec.matching("Mahnstufe 1")) == 1 })
if m := rec.matching("Mahnstufe 1")[0]; m.Severity != notify.Warning {
t.Errorf("Schweregrad: %v", m.Severity)
}
}
func TestNotifications_NodeOffline(t *testing.T) {
rec := &recorder{}
s, _ := startServer(t, fastJobs(rec), func(c *Config) { c.OfflineAlertAfter = 100 * time.Millisecond })
_, stop := connectedAgent(t, s, "n1")
_, stop2 := connectedAgent(t, s, "n2")
agent2, _ := connectedAgent(t, s, "n3")
agent2.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{Enabled: true})
_ = stop2
stop()
eventually(t, "Offline-Alarm", func() bool { return len(rec.matching("n1")) == 1 })
time.Sleep(150 * time.Millisecond)
if got := rec.matching("n1"); len(got) != 1 || got[0].Severity != notify.Critical {
t.Errorf("erwartet genau einen Alarm: %+v", got)
}
if len(rec.matching("n2")) != 0 || len(rec.matching("n3")) != 0 {
t.Error("Alarm für verbundenen Knoten")
}
}
func TestNotifications_NoAlarmDuringMaintenance(t *testing.T) {
rec := &recorder{}
s, _ := startServer(t, fastJobs(rec), func(c *Config) { c.OfflineAlertAfter = 50 * time.Millisecond })
agent, stop := connectedAgent(t, s, "n1")
agent.do(t, http.MethodPost, "/api/v1/maintenance", MaintenanceRequest{Enabled: true, Duration: "1h"})
stop()
time.Sleep(300 * time.Millisecond)
if got := rec.matching("n1"); len(got) != 0 {
t.Errorf("Alarm trotz angemeldeter Wartung: %+v", got)
}
}
func TestNotifications_Violation(t *testing.T) {
rec := &recorder{}
s, _ := startServer(t, fastJobs(rec))
agent, _ := connectedAgent(t, s, "n1")
agent.do(t, http.MethodPost, "/api/v1/state", wpState("bank.de"))
eventually(t, "Verstoß gemeldet", func() bool { return len(rec.matching("bank.de")) == 1 })
if m := rec.matching("bank.de")[0]; m.Severity != notify.Warning || !strings.Contains(m.Body, "n1") {
t.Errorf("Nachricht: %+v", m)
}
}
// Scheitert die Zustellung, wird es beim nächsten Durchlauf erneut versucht.
func TestNotifications_RetryOnFailure(t *testing.T) {
rec := &recorder{failFirst: 2}
s, _ := startServer(t, fastJobs(rec))
commercialTenant(t, s, "kunde", 10*24*time.Hour)
eventually(t, "Zustellung nach Wiederholung", func() bool { return len(rec.matching("kunde")) == 1 })
time.Sleep(100 * time.Millisecond)
if n := len(rec.matching("kunde")); n != 1 {
t.Errorf("%d Nachrichten, erwartet 1", n)
}
}
// Massenhaftes Durchprobieren von Einladungen wird gebremst – vor der
// Signaturprüfung, pro Client-IP.
func TestEnroll_RateLimited(t *testing.T) {
s, _ := startServer(t, func(c *Config) { c.EnrollLimit = 3 })
createTenant(t, s, "kunde", "kunde.de")
agent := newAgent(t, s, issueToken(t, s, "kunde"))
agent.secret = crypto.TokenPrefix + strings.Repeat("A", 43) // falsch geraten
for i := range 3 {
if r := agent.enroll(t, "n1"); r.StatusCode != http.StatusForbidden {
t.Fatalf("Versuch %d: %d", i+1, r.StatusCode)
}
}
r := agent.enroll(t, "n1")
if r.StatusCode != http.StatusTooManyRequests || r.Header.Get("Retry-After") == "" {
t.Errorf("4. Versuch: %d, Retry-After %q", r.StatusCode, r.Header.Get("Retry-After"))
}
// Auch unsignierte Fluten zählen.
req, _ := http.NewRequest(http.MethodPost, agent.base+"/api/v1/enroll", strings.NewReader(`{}`))
if resp, err := agent.hc.Do(req); err != nil || resp.StatusCode != http.StatusTooManyRequests {
t.Errorf("unsigniert: %v %v", resp, err)
}
}
func TestConfigDefaults_EnrollLimit(t *testing.T) {
if c := (Config{}).withDefaults(); c.EnrollLimit != 10 {
t.Errorf("EnrollLimit-Default %d, erwartet 10 pro Minute", c.EnrollLimit)
}
}
// „Wieder online“ nur nach einem Offline-Alarm und genau einmal pro Ausfall.
func TestNotifications_BackOnline(t *testing.T) {
rec := &recorder{}
s, _ := startServer(t, fastJobs(rec), func(c *Config) { c.OfflineAlertAfter = 100 * time.Millisecond })
agent, stop := connectedAgent(t, s, "n1")
stop()
eventually(t, "Offline-Alarm", func() bool { return len(rec.matching("offline")) == 1 })
agent.connect(t)
eventually(t, "wieder online", func() bool { return len(rec.matching("wieder online")) == 1 })
m := rec.matching("wieder online")[0]
if m.Severity != notify.Info || !strings.Contains(m.Body, "n1") {
t.Errorf("Nachricht: %+v", m)
}
time.Sleep(150 * time.Millisecond)
if n := len(rec.matching("wieder online")); n != 1 {
t.Errorf("%d Wieder-online-Nachrichten, erwartet 1", n)
}
}
func TestNotifications_NoBackOnlineWithoutAlarm(t *testing.T) {
rec := &recorder{}
s, _ := startServer(t, fastJobs(rec), func(c *Config) { c.OfflineAlertAfter = time.Hour })
agent, stop := connectedAgent(t, s, "n1")
stop()
eventually(t, "offline", func() bool { n, _ := s.store.GetNode(ctx, "n1"); return n.Status == store.NodeOffline })
agent.connect(t)
eventually(t, "online", func() bool { return s.hub.Connected("n1") })
time.Sleep(150 * time.Millisecond)
if got := rec.matching("wieder online"); len(got) != 0 {
t.Errorf("Meldung ohne vorherigen Alarm: %+v", got)
}
}
func TestConfigDefaults_EnrollLimitDisabled(t *testing.T) {
if c := (Config{EnrollLimit: -1}).withDefaults(); c.EnrollLimit != -1 {
t.Errorf("-1 (unbegrenzt) überschrieben: %d", c.EnrollLimit)
}
}
+63
View File
@@ -0,0 +1,63 @@
package store
import (
"context"
"time"
)
// PurgeLeases löscht Leases, die vor mehr als olderThan abgelaufen sind.
func (s *Store) PurgeLeases(ctx context.Context, olderThan time.Duration) (int64, error) {
res, err := s.db.ExecContext(ctx, `DELETE FROM leases WHERE valid_until <= ?`, formatTime(s.now().Add(-olderThan)))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// PurgeNodeCommands löscht zugestellte Befehle, die älter als olderThan sind.
func (s *Store) PurgeNodeCommands(ctx context.Context, olderThan time.Duration) (int64, error) {
res, err := s.db.ExecContext(ctx, `DELETE FROM node_commands WHERE sent_at IS NOT NULL AND sent_at <= ?`,
formatTime(s.now().Add(-olderThan)))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// Notified meldet, ob zum Ereignis key schon benachrichtigt wurde.
func (s *Store) Notified(ctx context.Context, key string) bool {
var one int
return s.db.QueryRowContext(ctx, `SELECT 1 FROM notifications WHERE key = ?`, key).Scan(&one) == nil
}
// RecordNotification vermerkt den Versand zu key (mehrfach unschädlich).
func (s *Store) RecordNotification(ctx context.Context, key string) error {
_, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO notifications (key, sent_at) VALUES (?, ?)`,
key, formatTime(s.now()))
return err
}
// MarkNodeSeen setzt last_seen auf jetzt (z. B. beim Trennen: "offline seit").
func (s *Store) MarkNodeSeen(ctx context.Context, nodeID string) error {
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET last_seen = ? WHERE id = ?`, formatTime(s.now()), nodeID)
return requireAffected(res, err, "Knoten "+nodeID)
}
// NotificationKeys liefert die vermerkten Ereignisse mit dem Präfix prefix.
func (s *Store) NotificationKeys(ctx context.Context, prefix string) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT key FROM notifications WHERE substr(key, 1, length(?)) = ? ORDER BY key`,
prefix, prefix)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
return nil, err
}
keys = append(keys, k)
}
return keys, rows.Err()
}
+101
View File
@@ -0,0 +1,101 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// Lease ist die aktuelle Lease eines Knotens. Document ist das signierte
// JSON (license.Lease); die Zeiten liegen zusätzlich als Spalten vor, damit
// der Server ohne Parsen entscheiden kann.
type Lease struct {
NodeID string
IssuedAt time.Time
RenewAfter time.Time
ValidUntil time.Time
Document string
}
// PutLease speichert bzw. ersetzt die Lease eines Knotens.
func (s *Store) PutLease(ctx context.Context, l Lease) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO leases (node_id, issued_at, renew_after, valid_until, document)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(node_id) DO UPDATE SET issued_at = excluded.issued_at, renew_after = excluded.renew_after,
valid_until = excluded.valid_until, document = excluded.document`,
l.NodeID, formatTime(l.IssuedAt), formatTime(l.RenewAfter), formatTime(l.ValidUntil), l.Document)
if sqliteCode(err) != 0 {
return fmt.Errorf("Lease für %s: Knoten: %w", l.NodeID, ErrNotFound)
}
return err
}
// GetLease liest die Lease eines Knotens.
func (s *Store) GetLease(ctx context.Context, nodeID string) (Lease, error) {
l := Lease{NodeID: nodeID}
err := s.db.QueryRowContext(ctx, `SELECT issued_at, renew_after, valid_until, document FROM leases WHERE node_id = ?`, nodeID).
Scan(dbTime{&l.IssuedAt}, dbTime{&l.RenewAfter}, dbTime{&l.ValidUntil}, &l.Document)
if errors.Is(err, sql.ErrNoRows) {
return Lease{}, fmt.Errorf("Lease für %s: %w", nodeID, ErrNotFound)
}
return l, err
}
// NodeCommand ist ein Befehl in der Warteschlange für einen Knoten. Die CLI
// (eigener Prozess) reiht ein, der Server stellt zu.
type NodeCommand struct {
ID int64
NodeID string
Command string
Reason string
CreatedAt time.Time
}
// EnqueueNodeCommand reiht einen Befehl für nodeID ein.
func (s *Store) EnqueueNodeCommand(ctx context.Context, nodeID, command, reason string) error {
if blank(command) {
return invalid("Befehl fehlt")
}
_, err := s.db.ExecContext(ctx, `INSERT INTO node_commands (node_id, command, reason, created_at) VALUES (?, ?, ?, ?)`,
nodeID, command, reason, formatTime(s.now()))
return mapConstraint(err, "Befehl für "+nodeID)
}
// PendingNodeCommands liefert die noch nicht zugestellten Befehle in Reihenfolge.
func (s *Store) PendingNodeCommands(ctx context.Context, nodeID string) ([]NodeCommand, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, command, reason, created_at FROM node_commands
WHERE node_id = ? AND sent_at IS NULL ORDER BY id`, nodeID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NodeCommand
for rows.Next() {
c := NodeCommand{NodeID: nodeID}
if err := rows.Scan(&c.ID, &c.Command, &c.Reason, dbTime{&c.CreatedAt}); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// MarkNodeCommandSent vermerkt die Zustellung.
func (s *Store) MarkNodeCommandSent(ctx context.Context, id int64) error {
res, err := s.db.ExecContext(ctx, `UPDATE node_commands SET sent_at = ? WHERE id = ?`, formatTime(s.now()), id)
return requireAffected(res, err, fmt.Sprintf("Befehl %d", id))
}
// UpdateNodePublicKey tauscht den Schlüssel eines Knotens (rotate_key).
func (s *Store) UpdateNodePublicKey(ctx context.Context, nodeID, publicKey string) error {
if blank(publicKey) {
return invalid("Public Key fehlt")
}
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET public_key = ? WHERE id = ?`, publicKey, nodeID)
if err != nil {
return mapConstraint(err, "Knoten "+nodeID)
}
return requireAffected(res, nil, "Knoten "+nodeID)
}
+61
View File
@@ -0,0 +1,61 @@
package store
import (
"context"
"strings"
"time"
"unicode"
"unicode/utf8"
)
// Maintenance ist eine angemeldete Wartung eines Knotens.
type Maintenance struct {
Since time.Time
Until time.Time // Nullwert = unbefristet
Reason string // intern (Log, Monitoring) – nie öffentlich
PublicReason string // optional, ersetzt den Standardtext der Wartungsseite
}
// Active meldet, ob die Wartung zum Zeitpunkt now gilt (nil = keine).
func (m *Maintenance) Active(now time.Time) bool {
return m != nil && (m.Until.IsZero() || now.Before(m.Until))
}
const (
maxPublicReason = 300
maxReason = 500
)
// validText: einzeilig, ohne Steuerzeichen, höchstens max Zeichen.
func validText(s string, max int) bool {
return utf8.RuneCountInString(s) <= max && !strings.ContainsFunc(s, unicode.IsControl)
}
// SetNodeMaintenance meldet eine Wartung an (Since = jetzt). m.Until muss
// in der Zukunft liegen oder Nullwert (unbefristet) sein.
func (s *Store) SetNodeMaintenance(ctx context.Context, nodeID string, m Maintenance) error {
now := s.now()
switch {
case !m.Until.IsZero() && !m.Until.After(now):
return invalid("Wartungsende liegt nicht in der Zukunft")
case !validText(m.PublicReason, maxPublicReason):
return invalid("öffentlicher Grund: einzeilig, höchstens %d Zeichen", maxPublicReason)
case !validText(m.Reason, maxReason):
return invalid("Grund: einzeilig, höchstens %d Zeichen", maxReason)
}
var until any
if !m.Until.IsZero() {
until = formatTime(m.Until)
}
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET maintenance_since = ?, maintenance_until = ?,
maintenance_reason = ?, maintenance_public_reason = ? WHERE id = ?`,
formatTime(now), until, m.Reason, m.PublicReason, nodeID)
return requireAffected(res, err, "Knoten "+nodeID)
}
// ClearNodeMaintenance beendet die Wartung eines Knotens.
func (s *Store) ClearNodeMaintenance(ctx context.Context, nodeID string) error {
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET maintenance_since = NULL, maintenance_until = NULL,
maintenance_reason = '', maintenance_public_reason = '' WHERE id = ?`, nodeID)
return requireAffected(res, err, "Knoten "+nodeID)
}
+79
View File
@@ -0,0 +1,79 @@
package store
import (
"errors"
"testing"
"time"
)
func TestNodeMaintenance(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
if n, _ := s.GetNode(ctx, "n1"); n.Maintenance != nil {
t.Fatalf("neuer Knoten mit Wartung: %+v", n.Maintenance)
}
until := clock.now().Add(4 * time.Hour)
err := s.SetNodeMaintenance(ctx, "n1", Maintenance{
Until: until, Reason: "Kernel-Update", PublicReason: "Wir spielen ein Sicherheitsupdate ein.",
})
if err != nil {
t.Fatalf("SetNodeMaintenance: %v", err)
}
n, _ := s.GetNode(ctx, "n1")
m := n.Maintenance
if m == nil || !m.Since.Equal(clock.now()) || !m.Until.Equal(until) ||
m.Reason != "Kernel-Update" || m.PublicReason != "Wir spielen ein Sicherheitsupdate ein." {
t.Errorf("Wartung: %+v", m)
}
if n.Status != NodeOnline {
t.Errorf("Wartung darf den Verbindungsstatus nicht ändern: %s", n.Status)
}
if nodes, _ := s.ListNodes(ctx, ""); nodes[0].Maintenance == nil {
t.Error("ListNodes ohne Wartungsdaten")
}
// Unbefristet: Until bleibt Nullwert.
if err := s.SetNodeMaintenance(ctx, "n1", Maintenance{Reason: "Umbau"}); err != nil {
t.Fatal(err)
}
if n, _ := s.GetNode(ctx, "n1"); n.Maintenance == nil || !n.Maintenance.Until.IsZero() {
t.Errorf("unbefristet: %+v", n.Maintenance)
}
if err := s.ClearNodeMaintenance(ctx, "n1"); err != nil {
t.Fatal(err)
}
if n, _ := s.GetNode(ctx, "n1"); n.Maintenance != nil {
t.Errorf("nach Clear: %+v", n.Maintenance)
}
if err := s.SetNodeMaintenance(ctx, "fehlt", Maintenance{}); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Knoten: %v", err)
}
if err := s.ClearNodeMaintenance(ctx, "fehlt"); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Knoten: %v", err)
}
if err := s.SetNodeMaintenance(ctx, "n1", Maintenance{Until: clock.now().Add(-time.Minute)}); !errors.Is(err, ErrInvalid) {
t.Errorf("Ende in der Vergangenheit: %v", err)
}
}
func TestMaintenanceActive(t *testing.T) {
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
m *Maintenance
want bool
}{
{"keine", nil, false},
{"unbefristet", &Maintenance{Since: now.Add(-time.Hour)}, true},
{"läuft", &Maintenance{Since: now.Add(-time.Hour), Until: now.Add(time.Minute)}, true},
{"abgelaufen", &Maintenance{Since: now.Add(-time.Hour), Until: now}, false},
}
for _, tt := range tests {
if got := tt.m.Active(now); got != tt.want {
t.Errorf("%s: Active = %v", tt.name, got)
}
}
}
+11 -2
View File
@@ -30,6 +30,9 @@ type Node struct {
LastSeen time.Time
IPAddress string
Status NodeStatus
// Maintenance ist die angemeldete Wartung (nil = keine). Sie ist eine
// eigene Dimension: Ein Knoten in Wartung kann verbunden sein oder nicht.
Maintenance *Maintenance
}
func (n *Node) normalize(now time.Time) error {
@@ -79,11 +82,17 @@ func (s *Store) insertNode(ctx context.Context, db execer, n Node) (Node, error)
return n, nil
}
const nodeColumns = `id, tenant_id, hostname, public_key, last_seen, ip_address, status`
const nodeColumns = `id, tenant_id, hostname, public_key, last_seen, ip_address, status,
maintenance_since, maintenance_until, maintenance_reason, maintenance_public_reason`
func scanNode(row interface{ Scan(...any) error }) (Node, error) {
var n Node
err := row.Scan(&n.ID, &n.TenantID, &n.Hostname, &n.PublicKey, dbTime{&n.LastSeen}, &n.IPAddress, &n.Status)
var m Maintenance
err := row.Scan(&n.ID, &n.TenantID, &n.Hostname, &n.PublicKey, dbTime{&n.LastSeen}, &n.IPAddress, &n.Status,
dbTime{&m.Since}, dbTime{&m.Until}, &m.Reason, &m.PublicReason)
if err == nil && !m.Since.IsZero() {
n.Maintenance = &m
}
return n, err
}
+29
View File
@@ -0,0 +1,29 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
)
// Setting liest eine Server-Einstellung (ErrNotFound, wenn nicht gesetzt).
func (s *Store) Setting(ctx context.Context, key string) (string, error) {
var v string
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("Einstellung %s: %w", key, ErrNotFound)
}
return v, err
}
// SetSetting setzt bzw. überschreibt eine Server-Einstellung.
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
if blank(key) {
return invalid("Schlüssel fehlt")
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
key, value)
return err
}
+44
View File
@@ -0,0 +1,44 @@
package store
import (
"errors"
"path/filepath"
"testing"
)
func TestSettings(t *testing.T) {
s, _ := openTest(t)
if _, err := s.Setting(ctx, "public_url"); !errors.Is(err, ErrNotFound) {
t.Errorf("fehlende Einstellung: erwartet ErrNotFound, bekommen %v", err)
}
if err := s.SetSetting(ctx, "public_url", "https://a.example:8443"); err != nil {
t.Fatal(err)
}
if err := s.SetSetting(ctx, "public_url", "https://b.example:8443"); err != nil {
t.Fatal(err)
}
if v, err := s.Setting(ctx, "public_url"); err != nil || v != "https://b.example:8443" {
t.Errorf("Setting = %q, %v", v, err)
}
if err := s.SetSetting(ctx, "", "x"); !errors.Is(err, ErrInvalid) {
t.Errorf("leerer Schlüssel: %v", err)
}
}
func TestSettings_SurviveReopen(t *testing.T) {
path := filepath.Join(t.TempDir(), "sultan.db")
s, err := Open(ctx, path)
if err != nil {
t.Fatal(err)
}
s.SetSetting(ctx, "public_url", "https://a.example:8443")
s.Close()
s, _ = Open(ctx, path)
defer s.Close()
if v, _ := s.Setting(ctx, "public_url"); v != "https://a.example:8443" {
t.Errorf("nach Neustart: %q", v)
}
if v, _ := s.SchemaVersion(ctx); v < 2 {
t.Errorf("Schema-Version %d, erwartet mindestens 2", v)
}
}
+38
View File
@@ -64,6 +64,41 @@ var migrations = []string{
used INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX enrollment_tokens_tenant_id ON enrollment_tokens(tenant_id);`,
// v2: Server-Einstellungen, die auch `sultan token` (eigener Prozess) braucht.
`CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);`,
// v3: Wartung pro Knoten (eigene Dimension neben online/offline/drained).
`ALTER TABLE nodes ADD COLUMN maintenance_since DATETIME;
ALTER TABLE nodes ADD COLUMN maintenance_until DATETIME;
ALTER TABLE nodes ADD COLUMN maintenance_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE nodes ADD COLUMN maintenance_public_reason TEXT NOT NULL DEFAULT '';`,
// v4: Leases, Befehlswarteschlange, Features und Zahlungsstatus, Einlösezeitpunkt.
`CREATE TABLE leases (
node_id TEXT PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
issued_at DATETIME NOT NULL,
renew_after DATETIME NOT NULL,
valid_until DATETIME NOT NULL,
document TEXT NOT NULL
);
CREATE TABLE node_commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
command TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
sent_at DATETIME
);
CREATE INDEX node_commands_pending ON node_commands(node_id, sent_at);
ALTER TABLE tenants ADD COLUMN features TEXT NOT NULL DEFAULT '[]';
ALTER TABLE tenants ADD COLUMN payment_overdue_since DATETIME;
ALTER TABLE enrollment_tokens ADD COLUMN used_at DATETIME;`,
// v5: versandte Benachrichtigungen (jedes Ereignis genau einmal).
`CREATE TABLE notifications (
key TEXT PRIMARY KEY,
sent_at DATETIME NOT NULL
);`,
}
// Store ist der Zugriff auf die SQLite-Datenbank. Er ist für parallele
@@ -147,6 +182,9 @@ type dbTime struct{ t *time.Time }
func (d dbTime) Scan(src any) error {
switch v := src.(type) {
case nil: // NULL = nicht gesetzt
*d.t = time.Time{}
return nil
case time.Time:
*d.t = normTime(v)
return nil
+49 -19
View File
@@ -50,8 +50,12 @@ type Tenant struct {
AllowedDomains []string
ValidUntil time.Time
Status TenantStatus
CreatedAt time.Time
UpdatedAt time.Time
Features []string // freigeschaltete Lizenz-Features (z. B. custom_middleware)
// PaymentOverdueSince: seit wann eine Zahlung offen ist (Nullwert = nichts
// offen); daraus berechnet die Policy die Mahnstufe.
PaymentOverdueSince time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// normalize setzt Defaults (Tier community, Status active, leere
@@ -66,6 +70,9 @@ func (t *Tenant) normalize() error {
if t.AllowedDomains == nil {
t.AllowedDomains = []string{}
}
if t.Features == nil {
t.Features = []string{}
}
t.ValidUntil = normTime(t.ValidUntil)
switch {
@@ -89,6 +96,11 @@ func (t *Tenant) normalize() error {
return invalid("leerer Eintrag in allowed_domains")
}
}
for _, f := range t.Features {
if blank(f) {
return invalid("leerer Eintrag in features")
}
}
return nil
}
@@ -98,40 +110,47 @@ func (s *Store) CreateTenant(ctx context.Context, t Tenant) (Tenant, error) {
if err := t.normalize(); err != nil {
return Tenant{}, err
}
domains, err := json.Marshal(t.AllowedDomains)
if err != nil {
return Tenant{}, err
}
domains, _ := json.Marshal(t.AllowedDomains)
features, _ := json.Marshal(t.Features)
now := normTime(s.now())
t.CreatedAt, t.UpdatedAt = now, now
t.PaymentOverdueSince = time.Time{}
_, err = s.db.ExecContext(ctx, `INSERT INTO tenants
(id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
_, err := s.db.ExecContext(ctx, `INSERT INTO tenants
(id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at, features)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Name, t.Email, t.Tier, t.MaxRoutes, string(domains),
formatTime(t.ValidUntil), t.Status, formatTime(now), formatTime(now))
formatTime(t.ValidUntil), t.Status, formatTime(now), formatTime(now), string(features))
if err != nil {
return Tenant{}, mapConstraint(err, "Mandant "+t.ID)
}
return t, nil
}
const tenantColumns = `id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at`
const tenantColumns = `id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at,
features, payment_overdue_since`
func scanTenant(row interface{ Scan(...any) error }) (Tenant, error) {
var t Tenant
var domains string
var domains, features string
err := row.Scan(&t.ID, &t.Name, &t.Email, &t.Tier, &t.MaxRoutes, &domains,
dbTime{&t.ValidUntil}, &t.Status, dbTime{&t.CreatedAt}, dbTime{&t.UpdatedAt})
dbTime{&t.ValidUntil}, &t.Status, dbTime{&t.CreatedAt}, dbTime{&t.UpdatedAt},
&features, dbTime{&t.PaymentOverdueSince})
if err != nil {
return Tenant{}, err
}
if err := json.Unmarshal([]byte(domains), &t.AllowedDomains); err != nil {
return Tenant{}, fmt.Errorf("store: allowed_domains von %s: %w", t.ID, err)
}
if err := json.Unmarshal([]byte(features), &t.Features); err != nil {
return Tenant{}, fmt.Errorf("store: features von %s: %w", t.ID, err)
}
if t.AllowedDomains == nil {
t.AllowedDomains = []string{}
}
if t.Features == nil {
t.Features = []string{}
}
return t, nil
}
@@ -168,15 +187,14 @@ func (s *Store) UpdateTenant(ctx context.Context, t Tenant) (Tenant, error) {
if err := t.normalize(); err != nil {
return Tenant{}, err
}
domains, err := json.Marshal(t.AllowedDomains)
if err != nil {
return Tenant{}, err
}
domains, _ := json.Marshal(t.AllowedDomains)
features, _ := json.Marshal(t.Features)
res, err := s.db.ExecContext(ctx, `UPDATE tenants SET
name = ?, email = ?, tier = ?, max_routes = ?, allowed_domains = ?, valid_until = ?, status = ?, updated_at = ?
name = ?, email = ?, tier = ?, max_routes = ?, allowed_domains = ?, valid_until = ?, status = ?, updated_at = ?,
features = ?
WHERE id = ?`,
t.Name, t.Email, t.Tier, t.MaxRoutes, string(domains), formatTime(t.ValidUntil), t.Status,
formatTime(s.now()), t.ID)
formatTime(s.now()), string(features), t.ID)
if err := requireAffected(res, err, "Mandant "+t.ID); err != nil {
return Tenant{}, err
}
@@ -193,6 +211,18 @@ func (s *Store) SetTenantStatus(ctx context.Context, id string, status TenantSta
return requireAffected(res, err, "Mandant "+id)
}
// SetTenantPayment setzt, seit wann eine Zahlung offen ist (Nullwert =
// bezahlt). Grundlage der Mahnstufen.
func (s *Store) SetTenantPayment(ctx context.Context, id string, overdueSince time.Time) error {
var v any
if !overdueSince.IsZero() {
v = formatTime(overdueSince)
}
res, err := s.db.ExecContext(ctx, `UPDATE tenants SET payment_overdue_since = ?, updated_at = ? WHERE id = ?`,
v, formatTime(s.now()), id)
return requireAffected(res, err, "Mandant "+id)
}
// DeleteTenant löscht den Mandanten samt seiner Knoten und Tokens.
func (s *Store) DeleteTenant(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM tenants WHERE id = ?`, id)
+8 -6
View File
@@ -62,7 +62,8 @@ func (s *Store) Enroll(ctx context.Context, token string, n Node) (Node, error)
return Node{}, ErrTokenExpired
}
if _, err := tx.ExecContext(ctx, `UPDATE enrollment_tokens SET used = 1 WHERE token_hash = ?`, hash); err != nil {
if _, err := tx.ExecContext(ctx, `UPDATE enrollment_tokens SET used = 1, used_at = ? WHERE token_hash = ?`,
formatTime(s.now()), hash); err != nil {
return Node{}, err
}
n.TenantID = tenantID
@@ -76,11 +77,12 @@ func (s *Store) Enroll(ctx context.Context, token string, n Node) (Node, error)
return node, nil
}
// PurgeEnrollmentTokens löscht verbrauchte und abgelaufene Tokens und
// liefert ihre Anzahl.
func (s *Store) PurgeEnrollmentTokens(ctx context.Context) (int64, error) {
res, err := s.db.ExecContext(ctx,
`DELETE FROM enrollment_tokens WHERE used = 1 OR expires_at <= ?`, formatTime(s.now()))
// PurgeEnrollmentTokens löscht Einladungen, die vor mehr als olderThan
// eingelöst wurden oder abgelaufen sind (0 = sofort), und liefert ihre Anzahl.
func (s *Store) PurgeEnrollmentTokens(ctx context.Context, olderThan time.Duration) (int64, error) {
cutoff := formatTime(s.now().Add(-olderThan))
res, err := s.db.ExecContext(ctx, `DELETE FROM enrollment_tokens
WHERE (used = 1 AND (used_at IS NULL OR used_at <= ?)) OR expires_at <= ?`, cutoff, cutoff)
if err != nil {
return 0, err
}
+1 -1
View File
@@ -172,7 +172,7 @@ func TestPurgeEnrollmentTokens(t *testing.T) {
}
clock.advance(time.Minute)
n, err := s.PurgeEnrollmentTokens(ctx)
n, err := s.PurgeEnrollmentTokens(ctx, 0)
if err != nil {
t.Fatal(err)
}
+155
View File
@@ -0,0 +1,155 @@
package store
import (
"errors"
"reflect"
"testing"
"time"
)
func TestLeases(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
if _, err := s.GetLease(ctx, "n1"); !errors.Is(err, ErrNotFound) {
t.Errorf("ohne Lease: %v", err)
}
l := Lease{
NodeID: "n1",
IssuedAt: clock.now(),
RenewAfter: clock.now().Add(7 * 24 * time.Hour),
ValidUntil: clock.now().Add(14 * 24 * time.Hour),
Document: `{"node_id":"n1"}`,
}
if err := s.PutLease(ctx, l); err != nil {
t.Fatalf("PutLease: %v", err)
}
got, err := s.GetLease(ctx, "n1")
if err != nil || !reflect.DeepEqual(got, l) {
t.Errorf("GetLease = %+v, %v", got, err)
}
l.ValidUntil = l.ValidUntil.Add(time.Hour)
if err := s.PutLease(ctx, l); err != nil {
t.Fatal(err)
}
if got, _ := s.GetLease(ctx, "n1"); !got.ValidUntil.Equal(l.ValidUntil) {
t.Error("PutLease ersetzt nicht")
}
if err := s.PutLease(ctx, Lease{NodeID: "fehlt", Document: "{}"}); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Knoten: %v", err)
}
s.DeleteNode(ctx, "n1")
if _, err := s.GetLease(ctx, "n1"); !errors.Is(err, ErrNotFound) {
t.Error("Lease nach Löschen des Knotens noch da")
}
}
func TestTenantFeaturesAndPayment(t *testing.T) {
s, clock := openTest(t)
in := sampleTenant("t1")
in.Features = []string{"custom_middleware", "websockets"}
created, err := s.CreateTenant(ctx, in)
if err != nil {
t.Fatal(err)
}
got, _ := s.GetTenant(ctx, "t1")
if !reflect.DeepEqual(got.Features, []string{"custom_middleware", "websockets"}) || !reflect.DeepEqual(got, created) {
t.Errorf("Features: %+v", got.Features)
}
if !got.PaymentOverdueSince.IsZero() {
t.Error("neuer Mandant mit offener Zahlung")
}
if def, _ := s.CreateTenant(ctx, sampleTenant("t2")); def.Features == nil || len(def.Features) != 0 {
t.Errorf("Default-Features: %#v", def.Features)
}
due := clock.now().Add(-10 * 24 * time.Hour)
if err := s.SetTenantPayment(ctx, "t1", due); err != nil {
t.Fatal(err)
}
if got, _ := s.GetTenant(ctx, "t1"); !got.PaymentOverdueSince.Equal(due) {
t.Errorf("offen seit: %v", got.PaymentOverdueSince)
}
if err := s.SetTenantPayment(ctx, "t1", time.Time{}); err != nil {
t.Fatal(err)
}
if got, _ := s.GetTenant(ctx, "t1"); !got.PaymentOverdueSince.IsZero() {
t.Error("bezahlt: Fälligkeit nicht gelöscht")
}
if err := s.SetTenantPayment(ctx, "fehlt", due); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Mandant: %v", err)
}
bad := sampleTenant("t3")
bad.Features = []string{"ok", " "}
if _, err := s.CreateTenant(ctx, bad); !errors.Is(err, ErrInvalid) {
t.Errorf("leeres Feature: %v", err)
}
}
func TestEnroll_RecordsUsedAt(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
s.AddEnrollmentToken(ctx, "tok", "t1", clock.now().Add(time.Hour))
clock.advance(time.Minute)
if _, err := s.Enroll(ctx, "tok", sampleNode("n1", "k1")); err != nil {
t.Fatal(err)
}
var usedAt time.Time
if err := s.db.QueryRowContext(ctx, "SELECT used_at FROM enrollment_tokens").Scan(dbTime{&usedAt}); err != nil {
t.Fatal(err)
}
if !usedAt.Equal(clock.now()) {
t.Errorf("used_at = %v, erwartet %v", usedAt, clock.now())
}
}
func TestNodeCommands(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
if err := s.EnqueueNodeCommand(ctx, "n1", "resync", ""); err != nil {
t.Fatal(err)
}
clock.advance(time.Second)
s.EnqueueNodeCommand(ctx, "n1", "drain", "Umzug")
cmds, err := s.PendingNodeCommands(ctx, "n1")
if err != nil || len(cmds) != 2 || cmds[0].Command != "resync" || cmds[1].Command != "drain" || cmds[1].Reason != "Umzug" {
t.Fatalf("ausstehend: %+v %v", cmds, err)
}
if err := s.MarkNodeCommandSent(ctx, cmds[0].ID); err != nil {
t.Fatal(err)
}
if cmds, _ := s.PendingNodeCommands(ctx, "n1"); len(cmds) != 1 || cmds[0].Command != "drain" {
t.Errorf("nach Versand: %+v", cmds)
}
if err := s.EnqueueNodeCommand(ctx, "fehlt", "resync", ""); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Knoten: %v", err)
}
if err := s.EnqueueNodeCommand(ctx, "n1", "", ""); !errors.Is(err, ErrInvalid) {
t.Errorf("leerer Befehl: %v", err)
}
}
func TestUpdateNodePublicKey(t *testing.T) {
s, _ := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
mustNode(t, s, "n2", "t1", "k2")
if err := s.UpdateNodePublicKey(ctx, "n1", "k1-neu"); err != nil {
t.Fatal(err)
}
if n, _ := s.GetNodeByPublicKey(ctx, "k1-neu"); n.ID != "n1" {
t.Errorf("neuer Schlüssel nicht gefunden: %+v", n)
}
if _, err := s.GetNodeByPublicKey(ctx, "k1"); !errors.Is(err, ErrNotFound) {
t.Error("alter Schlüssel noch gültig")
}
if err := s.UpdateNodePublicKey(ctx, "n1", "k2"); !errors.Is(err, ErrExists) {
t.Errorf("fremder Schlüssel: %v", err)
}
if err := s.UpdateNodePublicKey(ctx, "fehlt", "x"); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannter Knoten: %v", err)
}
}
+97
View File
@@ -0,0 +1,97 @@
package store
import (
"testing"
"time"
)
// Aufräumen erst nach einer Frist (Job: 10 Tage), CLI-purge sofort (0).
func TestPurgeEnrollmentTokens_Retention(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
start := clock.now()
s.AddEnrollmentToken(ctx, "alt-benutzt", "t1", start.Add(time.Hour))
s.AddEnrollmentToken(ctx, "jung-benutzt", "t1", start.Add(30*24*time.Hour))
s.AddEnrollmentToken(ctx, "alt-abgelaufen", "t1", start.Add(time.Hour))
s.AddEnrollmentToken(ctx, "gueltig", "t1", start.Add(30*24*time.Hour))
s.Enroll(ctx, "alt-benutzt", sampleNode("n1", "k1"))
clock.advance(9 * 24 * time.Hour)
s.Enroll(ctx, "jung-benutzt", sampleNode("n2", "k2"))
clock.advance(2 * 24 * time.Hour) // jetzt: alt-* vor > 10 Tagen benutzt/abgelaufen
n, err := s.PurgeEnrollmentTokens(ctx, 10*24*time.Hour)
if err != nil || n != 2 {
t.Errorf("gelöscht %d (%v), erwartet 2 (alt-benutzt, alt-abgelaufen)", n, err)
}
if n, _ := s.PurgeEnrollmentTokens(ctx, 0); n != 1 {
t.Errorf("sofort: %d gelöscht, erwartet 1 (jung-benutzt)", n)
}
}
func TestPurgeLeasesAndCommands(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
mustNode(t, s, "n2", "t1", "k2")
now := clock.now()
s.PutLease(ctx, Lease{NodeID: "n1", IssuedAt: now, RenewAfter: now, ValidUntil: now.Add(time.Hour), Document: "{}"})
s.PutLease(ctx, Lease{NodeID: "n2", IssuedAt: now, RenewAfter: now, ValidUntil: now.Add(20 * 24 * time.Hour), Document: "{}"})
s.EnqueueNodeCommand(ctx, "n1", "resync", "")
s.EnqueueNodeCommand(ctx, "n1", "drain", "")
cmds, _ := s.PendingNodeCommands(ctx, "n1")
s.MarkNodeCommandSent(ctx, cmds[0].ID)
clock.advance(11 * 24 * time.Hour)
if n, err := s.PurgeLeases(ctx, 10*24*time.Hour); err != nil || n != 1 {
t.Errorf("Leases: %d %v", n, err)
}
if _, err := s.GetLease(ctx, "n2"); err != nil {
t.Error("gültige Lease gelöscht")
}
if n, err := s.PurgeNodeCommands(ctx, 10*24*time.Hour); err != nil || n != 1 {
t.Errorf("Befehle: %d %v", n, err)
}
if cmds, _ := s.PendingNodeCommands(ctx, "n1"); len(cmds) != 1 {
t.Error("nicht zugestellter Befehl gelöscht")
}
}
func TestNotificationsOnce(t *testing.T) {
s, _ := openTest(t)
if s.Notified(ctx, "contract:t1:2026-12-15:14") {
t.Error("neu: schon benachrichtigt")
}
if err := s.RecordNotification(ctx, "contract:t1:2026-12-15:14"); err != nil {
t.Fatal(err)
}
if !s.Notified(ctx, "contract:t1:2026-12-15:14") {
t.Error("nicht vermerkt")
}
if err := s.RecordNotification(ctx, "contract:t1:2026-12-15:14"); err != nil {
t.Errorf("doppelt vermerken: %v", err)
}
}
func TestMarkNodeSeen(t *testing.T) {
s, clock := openTest(t)
mustTenant(t, s, "t1")
mustNode(t, s, "n1", "t1", "k1")
clock.advance(time.Hour)
if err := s.MarkNodeSeen(ctx, "n1"); err != nil {
t.Fatal(err)
}
if n, _ := s.GetNode(ctx, "n1"); !n.LastSeen.Equal(clock.now()) {
t.Errorf("last_seen = %v", n.LastSeen)
}
}
func TestNotificationKeysWithPrefix(t *testing.T) {
s, _ := openTest(t)
for _, k := range []string{"offline:n1:a", "offline:n1:b", "offline:n10:c", "contract:x"} {
s.RecordNotification(ctx, k)
}
got, err := s.NotificationKeys(ctx, "offline:n1:")
if err != nil || len(got) != 2 || got[0] != "offline:n1:a" || got[1] != "offline:n1:b" {
t.Errorf("Schlüssel: %v %v", got, err)
}
}
+6
View File
@@ -38,6 +38,9 @@ type Client struct {
MinBackoff, MaxBackoff time.Duration
// OnError wird in Run vor jeder Wartezeit mit dem Grund aufgerufen.
OnError func(err error, retryIn time.Duration)
// OnConnect wird nach jedem erfolgreichen Verbindungsaufbau aufgerufen,
// bevor Events gelesen werden – z. B. um den eigenen Zustand neu zu senden.
OnConnect func()
sleep func(ctx context.Context, d time.Duration) error // für Tests ersetzbar
}
@@ -102,6 +105,9 @@ func (c *Client) connect(ctx context.Context, handle func(Event) error) (connect
return false, fmt.Errorf("%w: Content-Type %q", ErrUnexpectedResponse, resp.Header.Get("Content-Type"))
}
if c.OnConnect != nil {
c.OnConnect()
}
idle := c.idleTimeout()
timer := time.AfterFunc(idle, func() { cancel(ErrIdleTimeout) })
defer timer.Stop()
+33
View File
@@ -331,3 +331,36 @@ func TestSleepCtx(t *testing.T) {
t.Error("sleepCtx hat trotz Abbruch gewartet")
}
}
// OnConnect wird nach jedem erfolgreichen Verbindungsaufbau aufgerufen –
// nicht bei Fehlantworten.
func TestClient_OnConnect(t *testing.T) {
var requests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requests.Add(1)%2 == 1 {
http.Error(w, "später", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/event-stream")
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var connects, sleeps atomic.Int32
c := &Client{
URL: srv.URL,
OnConnect: func() { connects.Add(1) },
sleep: func(ctx context.Context, d time.Duration) error {
if sleeps.Add(1) == 4 {
cancel()
return ctx.Err()
}
return nil
},
}
c.Run(ctx, func(Event) error { return nil })
if n := connects.Load(); n != 2 {
t.Errorf("OnConnect %d-mal, erwartet 2 (bei 4 Versuchen, davon 2 erfolgreich)", n)
}
}
+3
View File
@@ -42,6 +42,9 @@ type Event struct {
Type EventType `json:"type"`
NodeID string `json:"node_id"`
Payload json.RawMessage `json:"payload,omitempty"`
// Signature: Ed25519-Signatur des Servers über alle übrigen Felder
// (SignEvent/VerifyEvent); der Agent verwirft Events ohne gültige Signatur.
Signature string `json:"signature,omitempty"`
}
// NewEvent erzeugt ein Event mit neuer ID, aktuellem Zeitstempel und der als
+16 -6
View File
@@ -1,6 +1,7 @@
package stream
import (
"crypto/ed25519"
"errors"
"fmt"
"net/http"
@@ -41,6 +42,9 @@ type Hub struct {
// nur ein weiteres OnConnect, kein OnDisconnect.
OnConnect func(nodeID string)
OnDisconnect func(nodeID string)
// SigningKey: Ist er gesetzt, signiert der Hub jedes Event, nachdem er ID,
// Zeitstempel und Knoten-ID ergänzt hat.
SigningKey ed25519.PrivateKey
mu sync.Mutex
conns map[string]*conn
@@ -86,6 +90,15 @@ func (h *Hub) Serve(w http.ResponseWriter, r *http.Request, nodeID string) error
return ErrHubClosed
}
// Erst registrieren, dann die Header senden: Sobald der Client die
// Antwort sieht, muss der Knoten als verbunden gelten (der Agent schickt
// direkt danach seinen Zustand).
c := &conn{frames: make(chan []byte, h.bufferSize()), done: make(chan struct{})}
if err := h.register(nodeID, c); err != nil {
return err
}
defer h.unregister(nodeID, c)
rc := http.NewResponseController(w)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
@@ -98,12 +111,6 @@ func (h *Hub) Serve(w http.ResponseWriter, r *http.Request, nodeID string) error
return err
}
c := &conn{frames: make(chan []byte, h.bufferSize()), done: make(chan struct{})}
if err := h.register(nodeID, c); err != nil {
return err
}
defer h.unregister(nodeID, c)
ticker := time.NewTicker(h.keepAlive())
defer ticker.Stop()
write := func(p []byte) error {
@@ -185,6 +192,9 @@ func (h *Hub) Send(nodeID string, ev Event) error {
ev.Timestamp = time.Now().Unix()
}
ev.NodeID = nodeID
if h.SigningKey != nil {
ev = SignEvent(ev, h.SigningKey)
}
frame, err := encode(ev)
if err != nil {
return err
+13
View File
@@ -404,3 +404,16 @@ func TestHub_Disconnect(t *testing.T) {
t.Errorf("zweites Disconnect: erwartet ErrNodeOffline, bekommen %v", err)
}
}
// Sobald der Client die Antwort-Header hat, muss der Knoten registriert sein –
// sonst scheitert ein direkt folgender Request ("Stream nicht verbunden").
func TestHub_ConnectedWhenHeadersArrive(t *testing.T) {
for range 50 {
h := &Hub{}
srv, _ := hubServer(t, h)
openStream(t, context.Background(), srv, "n")
if !h.Connected("n") {
t.Fatal("Header empfangen, Knoten aber noch nicht registriert")
}
}
}
+78
View File
@@ -0,0 +1,78 @@
package stream
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
)
var (
// ErrUnsigned: Das Event trägt keine Signatur.
ErrUnsigned = errors.New("stream: Event nicht signiert")
// ErrBadSignature: Die Signatur passt nicht (fremder Schlüssel oder verändertes Event).
ErrBadSignature = errors.New("stream: Event-Signatur ungültig")
)
const eventSignatureVersion = "SULTAN-EVENT-V1\n"
func eventMessage(ev Event) []byte {
ev.Signature = ""
b, _ := json.Marshal(ev)
return append([]byte(eventSignatureVersion), b...)
}
// SignEvent signiert ev (alle Felder außer der Signatur) mit dem Master-Key.
func SignEvent(ev Event, key ed25519.PrivateKey) Event {
ev.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(key, eventMessage(ev)))
return ev
}
// VerifyEvent prüft die Signatur mit dem Public Key des Master-Keys.
func VerifyEvent(ev Event, pub ed25519.PublicKey) error {
if ev.Signature == "" {
return ErrUnsigned
}
sig, err := base64.StdEncoding.DecodeString(ev.Signature)
if err != nil || len(sig) != ed25519.SignatureSize || len(pub) != ed25519.PublicKeySize {
return ErrBadSignature
}
if !ed25519.Verify(pub, eventMessage(ev), sig) {
return ErrBadSignature
}
return nil
}
// Befehle in node_command – eine feste Liste, keine beliebigen Kommandos.
const (
CmdResync = "resync" // Agent sendet sofort seinen Zustand
CmdRotateKey = "rotate_key" // Agent erzeugt einen neuen node.key
CmdDrain = "drain" // Hinweis: Routen entfernt
CmdMaintenance = "maintenance" // Hinweis: Wartung angemeldet
CmdResume = "resume" // Hinweis: Wartung/drain beendet
CmdConfig = "config" // Server-Adressen (ServerURLs) für den Ausweichweg
)
// Commands sind alle bekannten Befehle.
var Commands = []string{CmdResync, CmdRotateKey, CmdDrain, CmdMaintenance, CmdResume, CmdConfig}
// NodeCommand ist die Payload eines node_command-Events.
type NodeCommand struct {
Command string `json:"command"`
Reason string `json:"reason,omitempty"`
// ServerURLs: bei config die Adressen, unter denen der Server erreichbar ist.
ServerURLs []string `json:"server_urls,omitempty"`
}
// ParseNodeCommand liest die Payload eines node_command-Events.
func ParseNodeCommand(ev Event) (NodeCommand, error) {
if ev.Type != EventNodeCommand {
return NodeCommand{}, fmt.Errorf("%w: kein node_command", ErrInvalidEvent)
}
var c NodeCommand
if err := json.Unmarshal(ev.Payload, &c); err != nil || c.Command == "" {
return NodeCommand{}, fmt.Errorf("%w: Befehl fehlt", ErrInvalidEvent)
}
return c, nil
}
+86
View File
@@ -0,0 +1,86 @@
package stream
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"errors"
"testing"
)
func TestSignAndVerifyEvent(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
ev := Event{ID: "evt_1", Timestamp: 42, Type: EventNodeCommand, NodeID: "n1", Payload: json.RawMessage(`{"command":"resync"}`)}
signed := SignEvent(ev, priv)
if signed.Signature == "" {
t.Fatal("keine Signatur")
}
if err := VerifyEvent(signed, pub); err != nil {
t.Errorf("Verify: %v", err)
}
otherPub, _, _ := ed25519.GenerateKey(rand.Reader)
for name, tc := range map[string]struct {
ev Event
pub ed25519.PublicKey
err error
}{
"unsigniert": {ev, pub, ErrUnsigned},
"fremder Schlüssel": {signed, otherPub, ErrBadSignature},
"Payload geändert": {func() Event { e := signed; e.Payload = json.RawMessage(`{"command":"rotate_key"}`); return e }(), pub, ErrBadSignature},
"Knoten geändert": {func() Event { e := signed; e.NodeID = "n2"; return e }(), pub, ErrBadSignature},
"Signatur Müll": {func() Event { e := signed; e.Signature = "!!!"; return e }(), pub, ErrBadSignature},
} {
if err := VerifyEvent(tc.ev, tc.pub); !errors.Is(err, tc.err) {
t.Errorf("%s: erwartet %v, bekommen %v", name, tc.err, err)
}
}
}
// Mit SigningKey signiert der Hub jedes Event – nachdem er ID, Zeitstempel
// und Knoten-ID ergänzt hat, damit auch diese geschützt sind.
func TestHub_SignsEvents(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
h := &Hub{SigningKey: priv}
srv, _ := hubServer(t, h)
resp := openStream(t, context.Background(), srv, "n1")
if err := h.Send("n1", Event{Type: EventNodeCommand, Payload: json.RawMessage(`{"command":"resync"}`)}); err != nil {
t.Fatal(err)
}
ev, err := NewDecoder(resp.Body).Next()
if err != nil {
t.Fatal(err)
}
if ev.NodeID != "n1" || ev.ID == "" {
t.Errorf("Event: %+v", ev)
}
if err := VerifyEvent(ev, pub); err != nil {
t.Errorf("Signatur nach Übertragung ungültig: %v", err)
}
}
func TestNodeCommandPayload(t *testing.T) {
ev, err := NewEvent(EventNodeCommand, NodeCommand{Command: CmdRotateKey, Reason: "Routine"})
if err != nil {
t.Fatal(err)
}
cmd, err := ParseNodeCommand(ev)
if err != nil || cmd.Command != CmdRotateKey || cmd.Reason != "Routine" {
t.Errorf("ParseNodeCommand: %+v %v", cmd, err)
}
if _, err := ParseNodeCommand(Event{Type: EventLeaseRenew}); err == nil {
t.Error("falscher Event-Typ akzeptiert")
}
if _, err := ParseNodeCommand(Event{Type: EventNodeCommand, Payload: json.RawMessage(`{}`)}); err == nil {
t.Error("Befehl ohne Namen akzeptiert")
}
}
func TestNodeCommandServerURLs(t *testing.T) {
ev, _ := NewEvent(EventNodeCommand, NodeCommand{Command: CmdConfig, ServerURLs: []string{"https://a:8443", "https://b:8443"}})
cmd, err := ParseNodeCommand(ev)
if err != nil || cmd.Command != CmdConfig || len(cmd.ServerURLs) != 2 || cmd.ServerURLs[1] != "https://b:8443" {
t.Errorf("config: %+v %v", cmd, err)
}
}