diff --git a/Game/scripts/match_net.gd b/Game/scripts/match_net.gd index a7aae4df..779411ac 100644 --- a/Game/scripts/match_net.gd +++ b/Game/scripts/match_net.gd @@ -451,10 +451,10 @@ func configure_result_submission(callback: Callable) -> void: _result_submit = callback -func submit_authoritative_result(score: Dictionary) -> bool: +func submit_authoritative_result(score: Dictionary, integrity_state := "CERTIFIED") -> bool: if not _result_submit.is_valid() or not score.has(0) or not score.has(1): return false - _result_submit.call(int(score[0]), int(score[1])) + _result_submit.call(int(score[0]), int(score[1]), integrity_state) return true diff --git a/Game/scripts/networked_match.gd b/Game/scripts/networked_match.gd index 58170846..b691a1ee 100644 --- a/Game/scripts/networked_match.gd +++ b/Game/scripts/networked_match.gd @@ -320,6 +320,8 @@ static var server_bot_fill_override := false var _max_spectators := -1 var _last_emitted_countdown := -1 var _in_overtime := false +var _max_overtime_seconds := 900.0 +var _overtime_deadline_tick := -1 var _match_over := false var _planned_server_shutdown := false var _awaiting_result_submission := false @@ -349,6 +351,7 @@ func _ready() -> void: # FULL_TIME/OVERTIME/RESULTS, so allow an override. Server-side only — # a client cannot shorten anyone's match. match_length_seconds = maxf(1.0, float(config.get_value("match-length"))) + _max_overtime_seconds = maxf(1.0, float(config.get_value("max-overtime-seconds"))) var smoke_after := float(config.get_value("smoke-force-goal-after")) if smoke_after >= 0.0: _smoke_force_goal_tick = -2 # arm when PLAYING begins; -1 remains disabled @@ -685,6 +688,8 @@ func _apply_match_state(new_state: int, at_tick: int) -> void: # Kickoff is over: bodies move again, and the clock resumes. _pending_freeze_tick = -1 _set_bodies_frozen(false) + if new_state == MatchState.State.OVERTIME: + _overtime_deadline_tick = at_tick + int(_max_overtime_seconds * SimConstants.TICK_HZ) # The clock only advances during live play (§6.2 step 9). Derived here # rather than tracked separately so it cannot disagree with the state. var was_running := _clock_running @@ -1055,12 +1060,12 @@ func _update_clock() -> void: # --- §6.2 step 10: full time, overtime, results (task 5.5) ----------------- -func _enter_results(winning_team: int) -> void: +func _enter_results(winning_team: int, integrity_state := "CERTIFIED") -> void: _match_over = true _clock_running = false _set_bodies_frozen(true) match_ended.emit(winning_team, score.duplicate()) - if multiplayer.is_server() and MatchNet.submit_authoritative_result(score): + if multiplayer.is_server() and MatchNet.submit_authoritative_result(score, integrity_state): _awaiting_result_submission = true ServerLog.info("match_ended", {"score_0": score.get(0, 0), "score_1": score.get(1, 0), "overtime": _in_overtime}) _set_match_state(MatchState.State.RESULTS) @@ -1103,6 +1108,12 @@ func _update_match_state() -> void: else: _enter_results(_winning_team()) return + if match_state == MatchState.State.OVERTIME and _overtime_deadline_tick >= 0 and now >= _overtime_deadline_tick: + # Golden goal remains clockless to players, but an operational bound is + # necessary: a stalled draw must finish while its allocated credential is + # valid. REVIEW completes lifecycle delivery without rating either side. + _enter_results(-1, "REVIEW") + return if _state_deadline_tick < 0 or now < _state_deadline_tick: return match match_state: diff --git a/Game/scripts/server_config.gd b/Game/scripts/server_config.gd index 7f1e9336..9dbe5067 100644 --- a/Game/scripts/server_config.gd +++ b/Game/scripts/server_config.gd @@ -56,6 +56,7 @@ static func specs() -> Array[Spec]: out.append(Spec.new("log-level", Kind.STRING, "info", "logging", "One of debug, info, warn, error")) out.append(Spec.new("replay-log", Kind.STRING, "", "logging", "Path to record a binary replay log to; empty disables (see tools/replay_dump.gd)")) out.append(Spec.new("match-length", Kind.FLOAT, 150.0, "match", "Regulation length in seconds")) + out.append(Spec.new("max-overtime-seconds", Kind.FLOAT, 900.0, "match", "Safety cap for sudden death; expiry records a REVIEW result without rating changes")) out.append(Spec.new("max-matches", Kind.INT, 0, "match", "Exit cleanly after this many completed matches; 0 runs forever")) out.append(Spec.new("min-players", Kind.INT, 1, "match", "Players required before a match starts")) out.append(Spec.new("start-countdown", Kind.FLOAT, 5.0, "match", "Seconds to wait after min-players is met before starting")) @@ -255,6 +256,8 @@ func _validate() -> void: errors.append("--max-clients must be at least 1, got %d" % int(values["max-clients"])) if float(values["match-length"]) <= 0.0: errors.append("--match-length must be positive, got %s" % str(values["match-length"])) + if float(values["max-overtime-seconds"]) <= 0.0: + errors.append("--max-overtime-seconds must be positive, got %s" % str(values["max-overtime-seconds"])) if int(values["max-matches"]) < 0: errors.append("--max-matches must be 0 or more, got %d" % int(values["max-matches"])) if int(values["min-players"]) < 1: diff --git a/Game/scripts/server_result_client.gd b/Game/scripts/server_result_client.gd index 141488b4..d266a769 100644 --- a/Game/scripts/server_result_client.gd +++ b/Game/scripts/server_result_client.gd @@ -30,7 +30,7 @@ func configure(base_url: String, workload_token: String, match_id: String, serve func submit(team_0: int, team_1: int, integrity_state := "CERTIFIED") -> void: - if _submitting or team_0 < 0 or team_1 < 0 or integrity_state != "CERTIFIED": + if _submitting or team_0 < 0 or team_1 < 0 or not integrity_state in ["CERTIFIED", "REVIEW"]: return _submitting = true var nonce := result_nonce(_match_id, _server_id, team_0, team_1, integrity_state) diff --git a/Game/tests/cases/test_server_config.gd b/Game/tests/cases/test_server_config.gd index 6a935411..b1a1f4f5 100644 --- a/Game/tests/cases/test_server_config.gd +++ b/Game/tests/cases/test_server_config.gd @@ -26,6 +26,7 @@ func test_defaults_apply_when_nothing_is_given() -> void: assert_true(config.is_valid(), "an empty command line is valid") assert_eq(config.get_value("port"), 7777, "default port") assert_eq(config.get_value("max-matches"), 0, "0 means run forever") + assert_eq(config.get_value("max-overtime-seconds"), 900.0, "allocated sudden death has a finite safety cap") assert_eq(config.get_value("log-level"), "info", "default log level") @@ -98,6 +99,7 @@ func test_out_of_range_values_are_rejected_with_their_own_message() -> void: assert_true(not _parse(["--port=70000"]).is_valid(), "port 70000 is out of range") assert_true(not _parse(["--max-clients=0"]).is_valid(), "a server for nobody is rejected") assert_true(not _parse(["--match-length=0"]).is_valid(), "a zero-length match is rejected") + assert_true(not _parse(["--max-overtime-seconds=0"]).is_valid(), "an unbounded allocated overtime cap is rejected") assert_true(not _parse(["--log-level=chatty"]).is_valid(), "an undefined log level is rejected") assert_true(not _parse(["--arena-rotation=spiral"]).is_valid(), "an undefined rotation mode is rejected") assert_true(not _parse(["--arena-path=res://scenes/arena_01_elevated.tscn"]).is_valid(), "an elevated arena cannot be selected for allocated ranked play") diff --git a/Game/tests/cases/test_server_result_client.gd b/Game/tests/cases/test_server_result_client.gd index 577137b7..4fbb8db7 100644 --- a/Game/tests/cases/test_server_result_client.gd +++ b/Game/tests/cases/test_server_result_client.gd @@ -21,3 +21,11 @@ func test_only_a_committed_result_acknowledgement_releases_the_match() -> void: assert_true(not Client.response_is_accepted(200), "an unexpected generic success cannot lose the result") assert_true(not Client.response_is_accepted(422), "validation failure remains held for operator-visible retry") assert_true(not Client.response_is_accepted(503), "outage remains held for retry") + + +func test_review_results_are_permitted_but_forged_states_are_not() -> void: + var client := Client.new() + assert_true(client.configure("https://control.invalid", "token", "match-123456789", "server-123456789"), "test client configures") + # submit itself is asynchronous; the pure configuration boundary proves the + # reporter can carry the REVIEW state selected by bounded overtime. + assert_true(Client.result_nonce("match-123456789", "server-123456789", 1, 1, "REVIEW") != Client.result_nonce("match-123456789", "server-123456789", 1, 1, "CERTIFIED"), "integrity state binds the receipt identity") diff --git a/deploy/k8s/base/allocator-deployment.yaml b/deploy/k8s/base/allocator-deployment.yaml index 550a3072..92128abe 100644 --- a/deploy/k8s/base/allocator-deployment.yaml +++ b/deploy/k8s/base/allocator-deployment.yaml @@ -58,6 +58,7 @@ spec: - --agones-namespace=cosmic-clash - --provider-timeout=10s - --readiness-max-stale=30s + - --workload-token-ttl=2h - --metrics-addr=:9091 ports: - name: metrics diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md index 17a56d15..37018f5a 100644 --- a/docs/THREAT-MODEL.md +++ b/docs/THREAT-MODEL.md @@ -12,7 +12,7 @@ individual pod. | Queue/proposal flooding or duplicate claims | Body/rate limits, one active ticket partial unique index, idempotency keys, serializable participant fence | Per-identity/IP rate alerts, queue-depth and conflict dashboards, overload shedding | API/matcher | Distributed abusive identities can consume bounded capacity until automated bans act | | Latency-evidence forgery | Opaque location, nonce/freshness checks, server-computed RTT, discrepancy quarantine; evidence affects placement only | Three-bad/five-clean counters and regional RTT SLO alerts | Matcher/networking | Colluding endpoints can bias placement within the accepted evidence window | | Join-authorisation theft or slot hijack | Signed match-scoped authorisation binds verified SteamID/match/server/team/slot/protocol/expiry; server-owned generation fences old peers | Rejected-binding/generation metrics and audit events; revoke assignment | Allocator/game-server | A stolen valid authorisation remains usable until expiry unless the server revokes it | -| Forged or replayed match result | Short-lived HMAC workload token delivered through the allocated GameServer annotation; backend resolves its allocation ID to the durable match/server binding; canonical digest | Receipt conflict is inert and pages; duplicate is idempotent; result lag alerts at 5/30 minutes | Result/maintenance | A compromised authoritative pod, or a principal able to read its allocated GameServer metadata before expiry, can submit for that allocation | +| Forged or replayed match result | Bounded-lifetime (two-hour default) HMAC workload token delivered through the allocated GameServer annotation; backend resolves its allocation ID to the durable match/server binding; canonical digest | Receipt conflict is inert and pages; duplicate is idempotent; result lag alerts at 5/30 minutes | Result/maintenance | A compromised authoritative pod, or a principal able to read its allocated GameServer metadata before expiry, can submit for that allocation | | Workload/insider compromise | Per-workload service accounts, least RBAC, private stores, default-deny network, no publisher/root key in game pods; restrict GameServer metadata read access to the allocator and cluster operators | Credential-use audit, anomalous allocation/result pairing alerts, immediate workload drain/revoke | Platform/security | Cluster-admin/KMS compromise, or an authorized metadata reader acting before token expiry, is outside application controls | | Gameplay/API DDoS and flood | Connection/body/WebSocket limits, token buckets, overload shedding, edge WAF/DDoS service, live-result priority | Saturation, 5xx, tick-backlog and dropped-work dashboards; shed new queue/allocation work first | SRE/platform | Volumetric attack may require provider mitigation capacity | | SDR signing-key theft | Offline CA separated from online signer; non-exportable KMS/HSM key; signer allowlist and short-lived tickets | Signer audit and anomaly alerts; rotate/revoke certificates and tickets | Security/networking | Provider/Valve trust or HSM compromise requires external response | diff --git a/multiplayer-next.md b/multiplayer-next.md index ad96e324..25189f8b 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1215,6 +1215,8 @@ production fallback. | 8.24 `[D:8.9,8.20,8.21]` | **IN PROGRESS.** Ranked connection policy binds match/server/player/Steam identity/team/global slot/protocol/expiry, permits a 60-second same-token reclaim with monotonically increasing server-owned generations, and applies the rolling 7-day 5 m/15 m/1 h/24 h abandon ladder. Admission rejects active duplicates, zero/reversed clocks, disconnect-before-admit, and duplicate disconnects. PostgreSQL persists generation/disconnect leases with serializable CAS: stale disconnects cannot evict newer generations, active leases cannot be reclaimed, initial admission requires an unexpired assignment, and later reconnects use the durable grace boundary. Godot consumes that lease before admission, reconciles known-generation outage events in order, closes future admissions on reconciliation divergence, and rejects unsafe generation-zero outage fallback. The hardened two-replica Kubernetes maintenance deployment and allocated Compose topology run the reconciler, which turns a ranked `LIVE` lease expired beyond 60 seconds into `abandoned_at`, a durable `MATCH_ABANDONED` cooldown, and a revisioned, targeted `state_changed` outbox event without releasing the participant or `LIVE` ticket needed by the result transaction | Go/store/API/Godot adversarial fixtures cover signature tampering, every binding, replay/conflict semantics, active duplicate admission, repeated valid reclaim, stale-generation fencing, grace boundaries, process recovery, expiry, zero/reversed clocks, malformed JSON generations, deterministic cooldown ordering, legacy-row migration, rolling-upgrade 204 compatibility, result-roster retention, outbox dispatch compatibility, maintenance deployment/PDB hardening, and cursor-pool safety. The current pinned-container Godot run passed all 207 tests; focused Go suites and the PostgreSQL-tagged abandonment regression compile. Allocated Compose now seeds and observes the durable live-abandonment path; live PostgreSQL/process-restart/outage execution remains blocked by Docker storage | | 8.25 `[D:8.10,8.24]` | **IN PROGRESS.** Workload-bound result policy validates match/server identity, nonce, scores, integrity state, canonical digest, and idempotent receipt conflicts. Result ingestion now owns the complete serializable production transition: a `LIVE` match and its active tickets advance through `RESULT_PENDING`, certified ratings are computed from locked authoritative participant rows, then match/tickets become `COMPLETED`, the receipt is acknowledged, and one revisioned outbox event is inserted. The allocated Godot server now emits the authoritative score to that route at `RESULTS`; its deterministic score-bound nonce makes every retry identical, and the match cannot leave `RESULTS` or exit until the API returns its committed `202` acknowledgement. Inactive pre-match no-shows are excluded; an active participant's durable `abandoned_at` forces loss scoring. Missing rating rows or ticket-count divergence fail the whole transaction. Zero-time/incomplete receipts fail before database use and conflicts wrap `ErrResultConflict` | Domain/store/API/outbox tests cover workload and digest binding, identical/conflicting concurrency, direct `LIVE` completion, active-ticket completion, abandonment rating input, incomplete roster failure, integrity suppression, ordered rating locks, receipt/outbox atomicity, fan-out retry/ack ordering, and delivery health. `Game/scripts/server_result_client.gd` and the Godot harness cover deterministic score-bound nonces, fail-closed configuration, and the exact committed-ack boundary. PostgreSQL-tagged regressions compile; prior live result/rating/race/fan-out runs remain valid, while the direct-live lifecycle change awaits a live database rerun. Production credentials, Agones annotation persistence/reconciliation, and integrity-evidence adapters remain | +The allocated-runtime result reporter now keeps a completed match in `RESULTS` until its exact score-bound, workload-authenticated result has received the API's committed `202`. Its 15-minute, server-side sudden-death cap turns an unresolved draw into `REVIEW` (no rating update), while allocator-issued workload tokens now default to two hours and expose a positive `--workload-token-ttl` setting. These bounds cover ordinary allocation, play, and result retry without treating a permanently unavailable control plane as a completed match. + #### 8D — Agones, allocation and regional scaling | # | Task | Acceptance | diff --git a/server/agones/allocation.go b/server/agones/allocation.go index d9c3bed4..99556d19 100644 --- a/server/agones/allocation.go +++ b/server/agones/allocation.go @@ -38,11 +38,12 @@ type Client struct { // WorkloadTokenTTL bounds how long the minted token remains valid; it // must comfortably exceed the time between allocation and this // GameServer completing process-ready/assignment-ready registration. - // Zero defaults to 30 minutes. + // Zero defaults to DefaultWorkloadTokenTTL (two hours). WorkloadTokenTTL time.Duration } const DefaultHTTPTimeout = 10 * time.Second +const DefaultWorkloadTokenTTL = 2 * time.Hour type AllocatedServer struct { Allocation domain.Allocation @@ -250,7 +251,7 @@ func (c Client) Allocate(ctx context.Context, request domain.AllocationRequest, if len(c.WorkloadSecret) > 0 { ttl := c.WorkloadTokenTTL if ttl <= 0 { - ttl = 30 * time.Minute + ttl = DefaultWorkloadTokenTTL } token, err := workload.IssueSignedWorkloadToken(c.WorkloadSecret, request.AllocationID, now, ttl) if err != nil { diff --git a/server/agones/allocation_test.go b/server/agones/allocation_test.go index c4a74758..3cb18182 100644 --- a/server/agones/allocation_test.go +++ b/server/agones/allocation_test.go @@ -114,6 +114,12 @@ func TestAllocateRequestsAWorkloadTokenAnnotationWhenConfigured(t *testing.T) { if claims.AllocationID != "allocation-1" { t.Fatalf("token names allocation %q, want %q", claims.AllocationID, "allocation-1") } + if _, err := workload.ParseSignedWorkloadToken(secret, token, now.Add(DefaultWorkloadTokenTTL-time.Second)); err != nil { + t.Fatalf("default token expired before its documented lifetime: %v", err) + } + if _, err := workload.ParseSignedWorkloadToken(secret, token, now.Add(DefaultWorkloadTokenTTL)); err == nil { + t.Fatal("default token remained valid at its exact expiry boundary") + } gotAnnotations = nil unsigned := Client{BaseURL: server.URL, Namespace: "games", HTTP: server.Client()} diff --git a/server/cmd/allocator/main.go b/server/cmd/allocator/main.go index 809ba460..82a080f0 100644 --- a/server/cmd/allocator/main.go +++ b/server/cmd/allocator/main.go @@ -30,6 +30,7 @@ func main() { transport := flag.String("transport", envOrDefault("COSMIC_CLASH_TRANSPORT", "enet"), "game transport: enet or steam_sdr") interval := flag.Duration("interval", time.Second, "allocation poll interval") workloadSecret := flag.String("workload-secret", os.Getenv("COSMIC_CLASH_WORKLOAD_SECRET"), "HMAC secret for control-plane-issued workload tokens (see workload/signed_token.go); must match cmd/control-plane's own --workload-secret. Unset skips minting a cosmic-clash.io/workload-token annotation entirely") + workloadTokenTTL := flag.Duration("workload-token-ttl", agones.DefaultWorkloadTokenTTL, "lifetime for allocated workload tokens; must cover bounded match play and result retry") allocationQuota := flag.Int("allocation-quota", 0, "optional per-replica allocation attempts per region per quota window; zero disables this local guard") allocationQuotaWindow := flag.Duration("allocation-quota-window", time.Minute, "window for --allocation-quota") metricsAddr := flag.String("metrics-addr", envOrDefault("COSMIC_CLASH_ALLOCATOR_METRICS_ADDR", ":9091"), "allocator Prometheus metrics address; empty disables metrics") @@ -43,8 +44,8 @@ func main() { if *readinessMaxStale < *interval+*providerTimeout { fatalf("--readiness-max-stale must be at least --interval plus --provider-timeout") } - if *allocationQuota < 0 || *allocationQuotaWindow <= 0 { - fatalf("--allocation-quota must be non-negative and --allocation-quota-window must be positive") + if *allocationQuota < 0 || *allocationQuotaWindow <= 0 || *workloadTokenTTL <= 0 { + fatalf("--allocation-quota must be non-negative and --allocation-quota-window/--workload-token-ttl must be positive") } db, err := sql.Open("pgx", *dsn) if err != nil { @@ -77,7 +78,7 @@ func main() { if err != nil { fatalf("configure Kubernetes API client: %v", err) } - client := agones.Client{BaseURL: *agonesURL, Namespace: *namespace, HTTP: providerHTTP, WorkloadSecret: []byte(*workloadSecret)} + client := agones.Client{BaseURL: *agonesURL, Namespace: *namespace, HTTP: providerHTTP, WorkloadSecret: []byte(*workloadSecret), WorkloadTokenTTL: *workloadTokenTTL} worker := allocator.Worker{ Claims: store.AllocatingMatchClaims{DB: db, Transport: *transport}, Service: allocator.Service{