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