Files
CosmicClash/server/api/store_adapters.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

112 lines
4.4 KiB
Go

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
// projection to the HTTP boundary. The store query filters expiry and binds
// both match and player; the API still performs its response-shape checks.
func AssignmentProviderFromStore(db *sql.DB) AssignmentProvider {
return func(ctx context.Context, playerID, matchID string, now time.Time) (AssignmentView, error) {
assignment, err := store.GetAssignment(ctx, db, playerID, matchID, now)
if err != nil {
return AssignmentView{}, err
}
return AssignmentView{
MatchID: assignment.MatchID,
ServerID: assignment.ServerID,
PlayerID: assignment.PlayerID,
Slot: assignment.Slot,
ExpiresAt: assignment.ExpiresAt,
ProtocolVersion: assignment.ProtocolVersion,
Transport: assignment.Transport,
JoinAuthorisation: assignment.JoinAuthorisation,
Endpoint: assignment.Endpoint,
Revision: assignment.Revision,
}, nil
}
}
type postgresProposalBackend struct{ db *sql.DB }
func (p postgresProposalBackend) Get(ctx context.Context, playerID, proposalID string, now time.Time) (domain.Proposal, error) {
return store.GetProposal(ctx, p.db, playerID, proposalID, now)
}
func (p postgresProposalBackend) Respond(ctx context.Context, playerID, proposalID, idempotencyKey string, accept bool, expectedRevision uint64, now time.Time) (domain.Proposal, error) {
return store.RespondToProposal(ctx, p.db, playerID, proposalID, idempotencyKey, accept, expectedRevision, now)
}
func ProposalProviderFromStore(db *sql.DB) ProposalBackend {
return postgresProposalBackend{db: db}
}
// ProposalPromoterFromStore turns a durably accepted proposal into its exact
// matcher-selected ALLOCATING match. The store chooses a deterministic match
// ID so an API retry after a transient failure cannot duplicate the match.
func ProposalPromoterFromStore(db *sql.DB) ProposalPromoter {
return ProposalPromoterFunc(func(ctx context.Context, proposal domain.Proposal, now time.Time) error {
if proposal.State != domain.Accepted {
return domain.ErrIllegalTransition
}
return store.PromoteStoredAcceptedProposal(ctx, db, proposal.ProposalID, now)
})
}
type postgresServerRegistrar struct{ db *sql.DB }
func (p postgresServerRegistrar) RegisterServer(ctx context.Context, binding domain.WorkloadBinding, protocol int, assignmentReady bool, idempotencyKey string, now time.Time) error {
return store.AdvanceServerRegistration(ctx, p.db, binding, protocol, assignmentReady, idempotencyKey, now)
}
func ServerRegistrarFromStore(db *sql.DB) ServerRegistrar {
if db == nil {
return nil
}
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
}
}