Files
sultan/pkg/auth/integration_test.go
br ca6cdcca63 software steht so weit.
jetzt build + docker build und testen
2026-10-02 13:25:34 +02:00

293 lines
9.1 KiB
Go

package auth
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
"time"
"sultan/pkg/crypto"
"sultan/pkg/store"
"sultan/pkg/stream"
)
// Ende-zu-Ende über TLS: Enrollment gegen den echten SQLite-Store, danach
// verbindet sich der Agent per signiertem SSE-Stream; die Knoten-ID für den
// Hub stammt ausschließlich aus der geprüften Identität.
func TestIntegration_EnrollAndStream(t *testing.T) {
db, err := store.Open(ctx, filepath.Join(t.TempDir(), "sultan.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.CreateTenant(ctx, store.Tenant{
ID: "homelab", Name: "Homelab", Email: "ich@example.com",
Tier: store.TierCommunity, ValidUntil: time.Now().AddDate(1, 0, 0),
}); err != nil {
t.Fatal(err)
}
if err := db.AddEnrollmentToken(ctx, "sltn_einladung", "homelab", time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
ver := &Verifier{Directory: db}
hub := &stream.Hub{}
defer hub.Close()
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/enroll", func(w http.ResponseWriter, r *http.Request) {
pub, err := ver.VerifyUnregistered(r)
if err != nil {
http.Error(w, err.Error(), StatusCode(err))
return
}
_, err = db.Enroll(r.Context(), r.Header.Get("X-Test-Token"), store.Node{
ID: "homelab-pi", Hostname: "pi", PublicKey: EncodePublicKey(pub), IPAddress: "192.168.1.20",
})
if err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
w.WriteHeader(http.StatusCreated)
})
mux.HandleFunc("GET /api/v1/stream", func(w http.ResponseWriter, r *http.Request) {
id, err := ver.Verify(r)
if err != nil {
http.Error(w, err.Error(), StatusCode(err))
return
}
hub.Serve(w, r, id.Node.ID)
})
srv := httptest.NewTLSServer(mux)
defer srv.Close()
priv, _ := GenerateKey()
signer := &Signer{Key: priv}
// Vor dem Enrollment: Stream wird abgelehnt.
client := &stream.Client{URL: srv.URL + "/api/v1/stream", HTTPClient: srv.Client(), Prepare: signer.Sign}
if err := client.Connect(ctx, nil); err == nil {
t.Fatal("Stream ohne Enrollment akzeptiert")
}
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/api/v1/enroll", nil)
req.Header.Set("X-Test-Token", "sltn_einladung")
if err := signer.Sign(req); err != nil {
t.Fatal(err)
}
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("Enrollment: Status %d", resp.StatusCode)
}
got := make(chan stream.Event, 1)
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
go client.Run(runCtx, func(ev stream.Event) error { got <- ev; return nil })
deadline := time.Now().Add(2 * time.Second)
for !hub.Connected("homelab-pi") {
if time.Now().After(deadline) {
t.Fatal("Agent hat sich nicht verbunden")
}
time.Sleep(5 * time.Millisecond)
}
if err := hub.Send("homelab-pi", stream.Event{Type: stream.EventLeaseRenew}); err != nil {
t.Fatal(err)
}
select {
case ev := <-got:
if ev.NodeID != "homelab-pi" {
t.Errorf("NodeID = %q", ev.NodeID)
}
case <-time.After(2 * time.Second):
t.Fatal("kein Event empfangen")
}
}
// Gegenprobe: Ein Klartext-Server bekommt vom Signer gar nichts.
func TestIntegration_PlainHTTPNeverSigned(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("Request hat einen Klartext-Server erreicht")
}))
defer srv.Close()
priv, _ := GenerateKey()
client := &stream.Client{URL: srv.URL, Prepare: (&Signer{Key: priv}).Sign}
if err := client.Connect(ctx, nil); err == nil {
t.Error("erwartet Fehler")
}
}
// pinnedServer startet den Sultan-Server-Teil (Enrollment + Stream) mit
// eigenem Zertifikat aus crypto.LoadOrCreateServerCert.
func pinnedServer(t *testing.T, db *store.Store, hub *stream.Hub) (*httptest.Server, crypto.ServerCert, *atomic.Int32) {
t.Helper()
sc, err := crypto.LoadOrCreateServerCert(t.TempDir())
if err != nil {
t.Fatal(err)
}
var hits atomic.Int32
ver := &Verifier{Directory: db}
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/enroll", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
pub, err := ver.VerifyUnregistered(r)
if err != nil {
http.Error(w, err.Error(), StatusCode(err))
return
}
var req struct{ Token string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if _, err := db.Enroll(r.Context(), req.Token, store.Node{
ID: "homelab-pi", Hostname: "pi", PublicKey: EncodePublicKey(pub), IPAddress: "192.168.1.20",
}); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
w.WriteHeader(http.StatusCreated)
})
mux.HandleFunc("GET /api/v1/stream", func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
id, err := ver.Verify(r)
if err != nil {
http.Error(w, err.Error(), StatusCode(err))
return
}
hub.Serve(w, r, id.Node.ID)
})
srv := httptest.NewUnstartedServer(mux)
srv.TLS = crypto.ServerTLSConfig(sc)
srv.Config.ErrorLog = log.New(io.Discard, "", 0) // abgewiesene Handshakes sind hier gewollt
srv.StartTLS()
t.Cleanup(srv.Close)
return srv, sc, &hits
}
func homelabStore(t *testing.T) *store.Store {
t.Helper()
db, err := store.Open(ctx, filepath.Join(t.TempDir(), "sultan.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.CreateTenant(ctx, store.Tenant{
ID: "homelab", Name: "Homelab", Email: "ich@example.com",
Tier: store.TierCommunity, ValidUntil: time.Now().AddDate(1, 0, 0),
}); err != nil {
t.Fatal(err)
}
return db
}
func enroll(signer *Signer, hc *http.Client, url, secret string) (*http.Response, error) {
body, _ := json.Marshal(map[string]string{"token": secret})
req, _ := http.NewRequest(http.MethodPost, url+"/api/v1/enroll", bytes.NewReader(body))
if err := signer.Sign(req); err != nil {
return nil, err
}
return hc.Do(req)
}
// Kompletter Ablauf wie im Betrieb: Server erzeugt Token mit eingebettetem
// Fingerprint, Agent prüft beim Erstkontakt dagegen, pinnt, enrollt und
// verbindet danach den Stream allein über den Pin.
func TestIntegration_TokenWithPinnedTLS(t *testing.T) {
db := homelabStore(t)
hub := &stream.Hub{}
defer hub.Close()
srv, sc, _ := pinnedServer(t, db, hub)
// Server-Seite (sultan token): Token erzeugen, nur das Secret speichern.
tok, err := crypto.NewEnrollmentToken(sc.Fingerprint, "")
if err != nil {
t.Fatal(err)
}
if err := db.AddEnrollmentToken(ctx, tok.Secret, "homelab", time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
// Agent-Seite: bekommt nur den Token-String (z. B. per --token).
parsed, err := crypto.ParseEnrollmentToken(tok.String())
if err != nil {
t.Fatal(err)
}
firstContact := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(parsed.Fingerprint)}}
priv, _ := GenerateKey()
signer := &Signer{Key: priv}
resp, err := enroll(signer, firstContact, srv.URL, parsed.Secret)
if err != nil {
t.Fatalf("Enrollment: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("Enrollment: Status %d", resp.StatusCode)
}
// Ab jetzt über den gespeicherten Pin (beim Agent: node.json).
pinned := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(parsed.Fingerprint)}}
client := &stream.Client{URL: srv.URL + "/api/v1/stream", HTTPClient: pinned, Prepare: signer.Sign}
got := make(chan stream.Event, 1)
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
go client.Run(runCtx, func(ev stream.Event) error { got <- ev; return nil })
deadline := time.Now().Add(2 * time.Second)
for !hub.Connected("homelab-pi") {
if time.Now().After(deadline) {
t.Fatal("Agent hat sich nicht verbunden")
}
time.Sleep(5 * time.Millisecond)
}
if err := hub.Send("homelab-pi", stream.Event{Type: stream.EventNodeCommand}); err != nil {
t.Fatal(err)
}
select {
case <-got:
case <-time.After(2 * time.Second):
t.Fatal("kein Event empfangen")
}
}
// Angreifer sitzt beim Erstkontakt dazwischen: Der Agent bricht im
// TLS-Handshake ab; Token und Signatur erreichen den Angreifer nie, der
// Token bleibt beim echten Server einlösbar.
func TestIntegration_MITMNeverSeesToken(t *testing.T) {
db := homelabStore(t)
hub := &stream.Hub{}
defer hub.Close()
_, realCert, _ := pinnedServer(t, db, hub)
mitm, _, mitmHits := pinnedServer(t, homelabStore(t), &stream.Hub{})
tok, _ := crypto.NewEnrollmentToken(realCert.Fingerprint, "")
db.AddEnrollmentToken(ctx, tok.Secret, "homelab", time.Now().Add(time.Hour))
hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(tok.Fingerprint)}}
priv, _ := GenerateKey()
_, err := enroll(&Signer{Key: priv}, hc, mitm.URL, tok.Secret)
if !errors.Is(err, crypto.ErrFingerprintMismatch) {
t.Errorf("erwartet ErrFingerprintMismatch, bekommen %v", err)
}
if mitmHits.Load() != 0 {
t.Error("Request hat den Angreifer erreicht")
}
if _, err := db.Enroll(ctx, tok.Secret, store.Node{ID: "n", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"}); err != nil {
t.Errorf("Token beim echten Server nicht mehr einlösbar: %v", err)
}
}