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