Files
CosmicClash/server/cmd/testkit-api/main.go
T
Josh Creek 520613aab0 feat(multiplayer): implement WorkloadVerify without a Kubernetes trust boundary
WorkloadVerify (api.Service.WorkloadVerify) was permanently unwired: both
/v1/servers/{id}/register and /v1/servers/{id}/result always 503, because
the only design considered so far was verifying a Kubernetes-projected
service-account JWT (server/workload/jwt.go), which needs a live cluster's
TokenReview/JWKS endpoint to validate against safely -- something this
sandbox cannot do without guessing at a trust boundary.

The API layer doesn't actually require that specific mechanism. serverMutation
only compares WorkloadBinding.ServerID and .MatchID (server/api/service.go);
AdvanceServerRegistration only uses .MatchID/.ServerID/.AllocationID. Nothing
downstream needs Namespace/ServiceAcct/PodUID/GameServerUID populated.

This adds a self-contained alternative: a short-lived, HMAC-signed token the
control plane mints and verifies with a secret only it holds (server/workload/
signed_token.go), the same trust model domain.SessionStore already uses for
player sessions elsewhere in this codebase. It needs no cluster to verify --
signature + expiry is fully self-contained and unit-testable.

The design's soundness rests on the delivery channel, not the crypto: the
token is meant to reach the allocated GameServer via the same Agones
GameServerAllocation annotation channel allocation.go already uses for
match-id/allocation-id, readable only by that pod's own local SDK sidecar. A
caller presenting this token has already proven, via that channel, that it is
the pod Agones allocated. (Wiring the actual annotation delivery -- extending
agones.Client.Allocate and the supervisor's token source -- is a separate,
follow-up change; this commit lands the verification core it depends on.)

store.AllocationBindingStillValid adds defense-in-depth on top of signature
and expiry: it cross-checks the token's claims against the durable
allocations table (append-only, never leaves 'ALLOCATED'), so a validly-signed
token naming an allocation that was never recorded -- or a real allocation id
paired with a mismatched match/server -- is still rejected.

api.WorkloadVerifierFromSignedToken wires the two together and is now plugged
into cmd/control-plane (new --workload-secret / COSMIC_CLASH_WORKLOAD_SECRET
flag; a startup warning is logged if it's left unset, since the route then
stays 503 exactly as before) and cmd/testkit-api (fixed test secret, since
that binary is test-only already).

Verified: new unit tests in server/workload (signature tamper, wrong secret,
expiry boundary, malformed input) and a new Postgres integration suite in
server/api (real allocation row, real signed token, acceptance / unknown-
allocation rejection / mismatched-triple rejection / the previously-503
Service.WorkloadVerify field itself) -- both run clean with -race across
multiple passes against a live postgres:17-alpine container. Full
`go build ./... && go vet ./... && gofmt -l . && go test ./... -race` and
`go test -tags integration ./... -race` both clean.
2026-09-01 14:51:21 +01:00

123 lines
4.9 KiB
Go

// Package main is a TEST-ONLY control-plane binary, built solely to give
// scripts/verify_control_plane_integration.sh a real, running HTTP server --
// backed by real PostgreSQL, running the actual api.Service used in
// production -- for the Godot client to talk to over a real network
// connection. It is never referenced by any Dockerfile stage or Kubernetes
// manifest and must never be treated as a deployment target: fakeSteamLogin
// below accepts ANY non-empty ticket string as a valid identity instead of
// verifying it against the real Steam Web API, which is exactly the kind of
// bypass that must stay confined to a clearly-separate binary, never a flag
// on the real one (see cmd/control-plane, which has no such flag and never
// should). Every other adapter here is wired identically to cmd/control-plane.
package main
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"flag"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/cosmic-clash/cosmic-clash/server/api"
"github.com/cosmic-clash/cosmic-clash/server/domain"
"github.com/cosmic-clash/cosmic-clash/server/migrations"
"github.com/cosmic-clash/cosmic-clash/server/store"
_ "github.com/jackc/pgx/v5/stdlib"
)
func main() {
listen := flag.String("listen", "127.0.0.1:0", "HTTP listen address; port 0 picks a free port, printed on startup")
dsn := flag.String("dsn", os.Getenv("COSMIC_CLASH_POSTGRES_DSN"), "PostgreSQL connection string")
migrationDir := flag.String("migrations", "migrations", "directory containing numbered SQL migrations")
workloadSecret := flag.String("workload-secret", envOrDefault("COSMIC_CLASH_WORKLOAD_SECRET", "testkit-workload-secret"), "HMAC secret for signed workload tokens; defaults to a fixed test value since this binary is test-only")
flag.Parse()
if *dsn == "" {
fatalf("--dsn or COSMIC_CLASH_POSTGRES_DSN is required")
}
db, err := sql.Open("pgx", *dsn)
if err != nil {
fatalf("open PostgreSQL: %v", err)
}
defer db.Close()
startupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := db.PingContext(startupCtx); err != nil {
fatalf("ping PostgreSQL: %v", err)
}
if err := migrations.Apply(startupCtx, db, *migrationDir); err != nil {
fatalf("apply migrations: %v", err)
}
handler := (&api.Service{
SessionBackend: store.PostgresSessions{DB: db},
SessionIssuer: store.PostgresSessions{DB: db},
SteamLogin: fakeSteamLogin{db: db},
QueueBackend: store.PostgresQueue{DB: db},
ProposalBackend: api.ProposalProviderFromStore(db),
ProposalPromoter: api.ProposalPromoterFromStore(db),
ServerRegistrar: api.ServerRegistrarFromStore(db),
ResultSubmitter: store.PostgresResults{DB: db},
RankedProfileProvider: store.PostgresRankedProfiles{DB: db},
Assignment: api.AssignmentProviderFromStore(db),
ProbeRecorder: store.PostgresQueue{DB: db},
WorkloadVerify: api.WorkloadVerifierFromSignedToken([]byte(*workloadSecret), db),
Now: func() time.Time { return time.Now().UTC() },
}).Handler()
listener, err := net.Listen("tcp", *listen)
if err != nil {
fatalf("listen: %v", err)
}
fmt.Printf("testkit-api listening on http://%s\n", listener.Addr())
server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
serveErr := make(chan error, 1)
go func() { serveErr <- server.Serve(listener) }()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
select {
case err := <-serveErr:
if err != nil && err != http.ErrServerClosed {
fatalf("serve: %v", err)
}
case <-ctx.Done():
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
_ = server.Shutdown(shutdownCtx)
}
}
// fakeSteamLogin derives a deterministic identity from the ticket string
// itself (never a real Steam Web API ticket in this binary) and ensures its
// identities row exists so session issuance's foreign key is satisfied.
type fakeSteamLogin struct{ db *sql.DB }
func (f fakeSteamLogin) Authenticate(ctx context.Context, ticket string, _ time.Time) (domain.VerifiedIdentity, error) {
if ticket == "" {
return domain.VerifiedIdentity{}, fmt.Errorf("empty ticket")
}
digest := sha256.Sum256([]byte(ticket))
playerID := "testkit-" + hex.EncodeToString(digest[:8])
steamID := "testkit-steam-" + hex.EncodeToString(digest[8:16])
if _, err := f.db.ExecContext(ctx, `INSERT INTO identities (player_id, steam_id) VALUES ($1, $2) ON CONFLICT (player_id) DO NOTHING`, playerID, steamID); err != nil {
return domain.VerifiedIdentity{}, err
}
return domain.VerifiedIdentity{PlayerID: playerID, SteamID: steamID}, nil
}
func envOrDefault(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "testkit-api: "+format+"\n", args...)
os.Exit(1)
}