diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 054b7bec..d970f5b3 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1213,7 +1213,7 @@ the local/CI/community transport, not a silent production fallback. | 8.28 `[D:8.6,8.27]` | **IN PROGRESS.** Supervisor separates explicit process-ready from Agones Ready and never scrapes stdout; allocated mode refuses to mark Ready without a configured readiness probe | `server/supervisor/` tests prove Ready follows the probe and direct mode remains functional; Godot readiness endpoint, detached-container and Health-reclaim integration remain | | 8.29 `[D:8.26,8.27]` | **IN PROGRESS.** Supervisor discovers the Agones endpoint, propagates the actual dynamic `--port`, and exports `SDR_LISTEN_PORT`/`SDR_IP` only for Hosted-SDR while preserving an isolated ENet path | `server/supervisor/` tests cover dynamic port argument/env propagation and SDR-vs-ENet separation; real Agones dynamic/passthrough mapping, POP/cert/firewall/NAT and multi-match fixture remain | | 8.30 `[D:8.18,8.26,8.28,8.29]` | **IN PROGRESS.** Pure Go allocator filters Ready servers by region/build/protocol/transport and atomically claims one with idempotent allocation replay; assignment is not exposed from Ready state | `server/domain/allocator.go` covers deterministic compatible selection, exhaustion, conflicting/identical replay and invalid server input; Agones `GameServerAllocation`, signed roster metadata, bounded cross-replica retry and live integration remain | -| 8.31 `[D:8.9,8.30]` | **Assignment-ready stage:** watch Allocated metadata, verify manifest/bindings, register hosted address, acknowledge backend; only then mint/expose client tickets | Modified/wrong manifest never reaches assignment-ready; clients cannot connect early; secrets never appear in metadata/args/logs | +| 8.31 `[D:8.9,8.30]` | **IN PROGRESS.** Pure Go assignment gate requires Allocated state, exact allocation ID/match/server/region/build/protocol/transport compatibility, non-empty hosted endpoint and verified manifest signature before exposure | `server/domain/assignment.go` covers early-connect, tampered signature/manifest, wrong compatibility and empty endpoint rejection; Agones metadata watch, hosted-address registration, production signer and client-ticket publication remain | | 8.32 `[D:8.2,8.26,8.30]` | FleetAutoscaler with >=2 Ready processes across >=2 on-demand nodes/failure domains per queue-enabled region; pre-pull current/rollback; scale **Allocated** count to zero, never the Ready floor | Warm allocation meets p95 5 s/p99 10 s; disabled regions alone scale fully to zero; one-node loss retains certified Ready/headroom | | 8.33 `[D:8.26,8.32]` | On-demand-only live capacity and measured N+1: loss of largest node leaves two Ready slots plus headroom for surviving Allocated matches | Interruptible nodes cannot receive live matches; forced node loss neither overloads survivors nor prevents the next allocation | | 8.34 `[D:8.28,8.29]` | Native x86_64 benchmark of boot-to-process-ready and assignment-ready, p99 CPU/RSS/network and 60 Hz ticks; limits/node cap with 30% headroom | Measurements replace old estimates and certify density with no tick backlog | diff --git a/server/domain/allocator.go b/server/domain/allocator.go index 2a02f330..00c8cc46 100644 --- a/server/domain/allocator.go +++ b/server/domain/allocator.go @@ -37,6 +37,10 @@ type Allocation struct { AllocationID string MatchID string ServerID string + Region string + Build string + Protocol int + Transport string State ServerLifecycle AllocatedAt time.Time } @@ -96,7 +100,7 @@ func (a *Allocator) Allocate(request AllocationRequest, now time.Time) (Allocati server := a.servers[ids[0]] server.State = ServerAllocated a.servers[server.ServerID] = server - allocation := Allocation{AllocationID: request.AllocationID, MatchID: request.MatchID, ServerID: server.ServerID, State: ServerAllocated, AllocatedAt: now} + allocation := Allocation{AllocationID: request.AllocationID, MatchID: request.MatchID, ServerID: server.ServerID, Region: server.Region, Build: server.Build, Protocol: server.Protocol, Transport: server.Transport, State: ServerAllocated, AllocatedAt: now} a.allocations[request.AllocationID] = allocation a.requestHashes[request.AllocationID] = digest return allocation, nil diff --git a/server/domain/assignment.go b/server/domain/assignment.go new file mode 100644 index 00000000..62e4aad9 --- /dev/null +++ b/server/domain/assignment.go @@ -0,0 +1,50 @@ +package domain + +import ( + "crypto/sha256" + "fmt" +) + +type AllocationManifest struct { + AllocationID string + MatchID string + ServerID string + Region string + Build string + Protocol int + Transport string + RosterDigest string +} + +type Assignment struct { + Allocation Allocation + Manifest AllocationManifest + Endpoint string +} + +var ErrManifestRejected = fmt.Errorf("allocation manifest rejected") + +// VerifyAssignment is the assignment-ready gate. A Ready/Allocated process +// has no client-facing endpoint until its signed manifest, allocator binding, +// and hosted endpoint all pass this check. +func VerifyAssignment(allocation Allocation, manifest AllocationManifest, endpoint string, signature []byte, verify func([]byte, []byte) bool) (Assignment, error) { + if allocation.State != ServerAllocated || allocation.AllocationID == "" || allocation.MatchID == "" || allocation.ServerID == "" || endpoint == "" || len(signature) == 0 || verify == nil { + return Assignment{}, ErrManifestRejected + } + if manifest.AllocationID != allocation.AllocationID || manifest.MatchID != allocation.MatchID || manifest.ServerID != allocation.ServerID || manifest.Region != allocation.Region || manifest.Build != allocation.Build || manifest.Protocol != allocation.Protocol || manifest.Transport != allocation.Transport || manifest.RosterDigest == "" { + return Assignment{}, ErrManifestRejected + } + if !verify(manifestBytes(manifest), signature) { + return Assignment{}, ErrManifestRejected + } + return Assignment{Allocation: allocation, Manifest: manifest, Endpoint: endpoint}, nil +} + +func manifestBytes(manifest AllocationManifest) []byte { + canonical := fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%s\x00%d\x00%s\x00%s", manifest.AllocationID, manifest.MatchID, manifest.ServerID, manifest.Region, manifest.Build, manifest.Protocol, manifest.Transport, manifest.RosterDigest) + return []byte(canonical) +} + +func ManifestDigest(manifest AllocationManifest) [32]byte { + return sha256.Sum256(manifestBytes(manifest)) +} diff --git a/server/domain/assignment_test.go b/server/domain/assignment_test.go new file mode 100644 index 00000000..bdb246ce --- /dev/null +++ b/server/domain/assignment_test.go @@ -0,0 +1,53 @@ +package domain + +import ( + "errors" + "testing" + "time" +) + +func testAllocation() Allocation { + return Allocation{AllocationID: "allocation-1", MatchID: "match-1", ServerID: "server-1", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet", State: ServerAllocated, AllocatedAt: time.Unix(1000, 0)} +} + +func testManifest() AllocationManifest { + return AllocationManifest{AllocationID: "allocation-1", MatchID: "match-1", ServerID: "server-1", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet", RosterDigest: "roster-digest"} +} + +func TestAssignmentReadyRequiresBoundSignedManifestAndEndpoint(t *testing.T) { + manifest := testManifest() + digest := ManifestDigest(manifest) + sign := func(payload, signature []byte) bool { + return string(payload) == string(manifestBytes(manifest)) && string(signature) == string(digest[:]) + } + assignment, err := VerifyAssignment(testAllocation(), manifest, "203.0.113.9:31001", digest[:], sign) + if err != nil || assignment.Endpoint == "" { + t.Fatalf("assignment = %+v err=%v", assignment, err) + } + if _, err := VerifyAssignment(testAllocation(), manifest, "", digest[:], sign); !errors.Is(err, ErrManifestRejected) { + t.Fatalf("empty endpoint accepted: %v", err) + } +} + +func TestAssignmentReadyRejectsTamperedOrPrematureManifest(t *testing.T) { + manifest := testManifest() + digest := ManifestDigest(manifest) + verify := func(payload, signature []byte) bool { + return string(payload) == string(manifestBytes(manifest)) && string(signature) == string(digest[:]) + } + tampered := manifest + tampered.ServerID = "server-2" + if _, err := VerifyAssignment(testAllocation(), tampered, "127.0.0.1:1", digest[:], verify); !errors.Is(err, ErrManifestRejected) { + t.Fatalf("tampered manifest accepted: %v", err) + } + ready := testAllocation() + ready.State = ServerReady + if _, err := VerifyAssignment(ready, manifest, "127.0.0.1:1", digest[:], verify); !errors.Is(err, ErrManifestRejected) { + t.Fatalf("Ready process exposed assignment: %v", err) + } + wrongBuild := manifest + wrongBuild.Build = "build-2" + if _, err := VerifyAssignment(testAllocation(), wrongBuild, "127.0.0.1:1", digest[:], verify); !errors.Is(err, ErrManifestRejected) { + t.Fatalf("incompatible build accepted: %v", err) + } +}