mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +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:
@@ -0,0 +1,36 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// AllocationBindingStillValidSQL cross-checks a signed workload token's
|
||||
// claims against the durable allocation record before trusting it. A
|
||||
// validly-signed, unexpired token alone is not proof the allocation it names
|
||||
// is still the live binding for that match/server pair -- this closes that
|
||||
// gap defense-in-depth. allocations rows are append-only and never leave
|
||||
// 'ALLOCATED' (see allocator_sql.go), so this is a simple existence check,
|
||||
// not a state-machine walk.
|
||||
const AllocationBindingStillValidSQL = `SELECT 1 FROM allocations
|
||||
WHERE allocation_id = $1 AND match_id = $2 AND server_id = $3 AND state = 'ALLOCATED'`
|
||||
|
||||
// AllocationBindingStillValid reports whether the given (allocationID,
|
||||
// matchID, serverID) triple names a real, still-allocated row. db, and every
|
||||
// identifier, must be non-empty -- callers pass this an already-parsed and
|
||||
// signature-verified token's claims, so empty fields here indicate a caller
|
||||
// bug rather than a legitimate "not found".
|
||||
func AllocationBindingStillValid(ctx context.Context, db *sql.DB, allocationID, matchID, serverID string) (bool, error) {
|
||||
if db == nil || allocationID == "" || matchID == "" || serverID == "" {
|
||||
return false, sql.ErrNoRows
|
||||
}
|
||||
var one int
|
||||
err := db.QueryRowContext(ctx, AllocationBindingStillValidSQL, allocationID, matchID, serverID).Scan(&one)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
Reference in New Issue
Block a user