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)
}
}