mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-15 12:12:07 +00:00
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.
This commit is contained in:
@@ -3,10 +3,12 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/store"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/workload"
|
||||
)
|
||||
|
||||
// AssignmentProviderFromStore adapts the durable player-scoped assignment
|
||||
@@ -71,3 +73,39 @@ func ServerRegistrarFromStore(db *sql.DB) ServerRegistrar {
|
||||
}
|
||||
return postgresServerRegistrar{db: db}
|
||||
}
|
||||
|
||||
// WorkloadVerifierFromSignedToken builds WorkloadVerify from a control-plane
|
||||
// -owned signed token instead of a Kubernetes-projected JWT (see
|
||||
// workload/signed_token.go for why: it needs no live cluster to verify).
|
||||
// secret must be kept out of source control (env var in cmd/control-plane);
|
||||
// an empty secret returns nil so a misconfigured deployment fails the same
|
||||
// way an unwired verifier already does today (503, not a silent bypass).
|
||||
func WorkloadVerifierFromSignedToken(secret []byte, db *sql.DB) WorkloadVerifier {
|
||||
if len(secret) == 0 || db == nil {
|
||||
return nil
|
||||
}
|
||||
return func(token string, now time.Time) (domain.WorkloadBinding, error) {
|
||||
claims, err := workload.ParseSignedWorkloadToken(secret, token, now)
|
||||
if err != nil {
|
||||
return domain.WorkloadBinding{}, err
|
||||
}
|
||||
// WorkloadVerifier has no context parameter (see its type in
|
||||
// service.go) so the durable cross-check below cannot inherit the
|
||||
// caller's request context; bound it locally instead of running
|
||||
// unbounded against context.Background().
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
ok, err := store.AllocationBindingStillValid(ctx, db, claims.AllocationID, claims.MatchID, claims.ServerID)
|
||||
if err != nil {
|
||||
return domain.WorkloadBinding{}, err
|
||||
}
|
||||
if !ok {
|
||||
return domain.WorkloadBinding{}, fmt.Errorf("signed workload token names an allocation that is no longer valid")
|
||||
}
|
||||
return domain.WorkloadBinding{
|
||||
AllocationID: claims.AllocationID,
|
||||
MatchID: claims.MatchID,
|
||||
ServerID: claims.ServerID,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//go:build integration
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/cosmic-clash/cosmic-clash/server/workload"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// This binary is deliberately opt-in, matching store's integration suite: it
|
||||
// requires a disposable PostgreSQL instance supplied by
|
||||
// scripts/run_postgres_integration.sh.
|
||||
func openIntegrationPostgres(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("COSMIC_CLASH_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("COSMIC_CLASH_POSTGRES_DSN is not set")
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open PostgreSQL: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
t.Fatalf("ping PostgreSQL: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
migrationDir := os.Getenv("COSMIC_CLASH_MIGRATIONS_DIR")
|
||||
if migrationDir == "" {
|
||||
migrationDir = filepath.Join("..", "migrations")
|
||||
}
|
||||
if err := migrations.Apply(ctx, db, migrationDir); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// seedRealAllocation claims a real ready server and allocation row, exactly
|
||||
// the durable state a signed workload token must later be cross-checked
|
||||
// against (see store.AllocationBindingStillValid).
|
||||
func seedRealAllocation(t *testing.T, db *sql.DB, allocationID, matchID string, now time.Time) domain.Allocation {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
serverID := "server-" + allocationID
|
||||
if err := store.RegisterReadyServer(ctx, db, domain.ReadyServer{ServerID: serverID, Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet", State: domain.ServerReady}, now); err != nil {
|
||||
t.Fatalf("register ready server: %v", err)
|
||||
}
|
||||
allocation, err := store.ClaimAllocation(ctx, db, domain.AllocationRequest{AllocationID: allocationID, MatchID: matchID, Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet"}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("claim allocation: %v", err)
|
||||
}
|
||||
return allocation
|
||||
}
|
||||
|
||||
// TestWorkloadVerifierFromSignedTokenAcceptsARealAllocation proves the full
|
||||
// wired path: a token issued by workload.IssueSignedWorkloadToken for a real
|
||||
// allocation row verifies successfully through
|
||||
// WorkloadVerifierFromSignedToken and returns a binding matching what
|
||||
// serverMutation actually checks (ServerID, MatchID). This is the "wired,
|
||||
// working" counterpart to cmd/control-plane's
|
||||
// TestServerRoutesRequireWorkloadVerifyToBeWired, which pins the
|
||||
// unconfigured-503 case.
|
||||
func TestWorkloadVerifierFromSignedTokenAcceptsARealAllocation(t *testing.T) {
|
||||
db := openIntegrationPostgres(t)
|
||||
now := time.Now().UTC()
|
||||
allocation := seedRealAllocation(t, db, "alloc-verify-1", "match-verify-1", now)
|
||||
|
||||
secret := []byte("integration-test-secret")
|
||||
token, err := workload.IssueSignedWorkloadToken(secret, allocation.AllocationID, allocation.MatchID, allocation.ServerID, now, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("issue token: %v", err)
|
||||
}
|
||||
|
||||
verify := WorkloadVerifierFromSignedToken(secret, db)
|
||||
if verify == nil {
|
||||
t.Fatal("WorkloadVerifierFromSignedToken returned nil with a real secret and database")
|
||||
}
|
||||
binding, err := verify(token, now.Add(30*time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if binding.ServerID != allocation.ServerID || binding.MatchID != allocation.MatchID || binding.AllocationID != allocation.AllocationID {
|
||||
t.Fatalf("unexpected binding: %+v, want server=%s match=%s allocation=%s", binding, allocation.ServerID, allocation.MatchID, allocation.AllocationID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkloadVerifierFromSignedTokenRejectsAnUnknownAllocation proves the
|
||||
// durable cross-check actually runs: a validly-signed, unexpired token whose
|
||||
// allocation was never recorded (e.g. superseded, or simply fabricated) must
|
||||
// still be rejected. Signature and expiry checks alone are not enough.
|
||||
func TestWorkloadVerifierFromSignedTokenRejectsAnUnknownAllocation(t *testing.T) {
|
||||
db := openIntegrationPostgres(t)
|
||||
now := time.Now().UTC()
|
||||
secret := []byte("integration-test-secret")
|
||||
token, err := workload.IssueSignedWorkloadToken(secret, "alloc-never-recorded", "match-never-recorded", "server-never-recorded", now, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("issue token: %v", err)
|
||||
}
|
||||
verify := WorkloadVerifierFromSignedToken(secret, db)
|
||||
if _, err := verify(token, now.Add(time.Second)); err == nil {
|
||||
t.Fatal("expected rejection for a token naming an allocation that was never recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkloadVerifierFromSignedTokenRejectsAMismatchedTriple proves the
|
||||
// cross-check binds all three identifiers together, not each independently:
|
||||
// a real allocation's own allocation_id combined with someone else's
|
||||
// match/server must still fail.
|
||||
func TestWorkloadVerifierFromSignedTokenRejectsAMismatchedTriple(t *testing.T) {
|
||||
db := openIntegrationPostgres(t)
|
||||
now := time.Now().UTC()
|
||||
allocation := seedRealAllocation(t, db, "alloc-verify-2", "match-verify-2", now)
|
||||
secret := []byte("integration-test-secret")
|
||||
token, err := workload.IssueSignedWorkloadToken(secret, allocation.AllocationID, "a-different-match", allocation.ServerID, now, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("issue token: %v", err)
|
||||
}
|
||||
verify := WorkloadVerifierFromSignedToken(secret, db)
|
||||
if _, err := verify(token, now.Add(time.Second)); err == nil {
|
||||
t.Fatal("expected rejection for a real allocation id paired with the wrong match id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkloadVerifierFromSignedTokenClosesTheDefaultUnwiredGap proves the
|
||||
// 503-by-default gap pinned by
|
||||
// cmd/control-plane.TestServerRoutesRequireWorkloadVerifyToBeWired is
|
||||
// actually closed once a secret and database are wired: a Service built the
|
||||
// same way newAPIHandler builds one now accepts a validly-issued token for a
|
||||
// real allocation, through the exact Service.WorkloadVerify field the HTTP
|
||||
// handler calls. (serverMutation's deeper match-state transition --
|
||||
// requiring the match to already be ALLOCATING -- is exercised separately by
|
||||
// the store package's own allocation/match integration tests; this test's
|
||||
// job is only the WorkloadVerify boundary itself.)
|
||||
func TestWorkloadVerifierFromSignedTokenClosesTheDefaultUnwiredGap(t *testing.T) {
|
||||
db := openIntegrationPostgres(t)
|
||||
now := time.Now().UTC()
|
||||
allocation := seedRealAllocation(t, db, "alloc-verify-3", "match-verify-3", now)
|
||||
secret := []byte("integration-test-secret")
|
||||
token, err := workload.IssueSignedWorkloadToken(secret, allocation.AllocationID, allocation.MatchID, allocation.ServerID, now, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("issue token: %v", err)
|
||||
}
|
||||
|
||||
svc := &Service{
|
||||
ServerRegistrar: ServerRegistrarFromStore(db),
|
||||
WorkloadVerify: WorkloadVerifierFromSignedToken(secret, db),
|
||||
Now: func() time.Time { return now.Add(time.Second) },
|
||||
}
|
||||
binding, err := svc.WorkloadVerify(token, now.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("WorkloadVerify rejected a validly-issued token for a real allocation: %v", err)
|
||||
}
|
||||
if binding.ServerID != allocation.ServerID {
|
||||
t.Fatalf("binding.ServerID = %q, want %q", binding.ServerID, allocation.ServerID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user