diff --git a/Game/scripts/match_net.gd b/Game/scripts/match_net.gd index ea1a2705..a7aae4df 100644 --- a/Game/scripts/match_net.gd +++ b/Game/scripts/match_net.gd @@ -19,6 +19,8 @@ signal player_state_changed(peer_id: int, team: int, ready: bool) signal rejected(reason: String) # client-side only: the server refused our hello signal welcomed() # client-side only: our hello was accepted signal server_shutdown(reason: String) # client-side notification before planned close +signal result_submission_accepted +signal result_submission_retrying(http_code: int) const TEAM_COUNT := 2 const RECONNECT_GRACE_SECONDS := 60.0 @@ -68,6 +70,7 @@ var _join_authorisation_context: Dictionary = {} var _join_signing_key := PackedByteArray() var _connection_lease_claim := Callable() var _connection_lease_disconnect := Callable() +var _result_submit := Callable() # Test hook (tests/match_net_smoke.gd): set false before connecting to # suppress the automatic real hello, so a test can send a deliberately @@ -109,6 +112,7 @@ func _on_shutting_down() -> void: _join_signing_key = PackedByteArray() _connection_lease_claim = Callable() _connection_lease_disconnect = Callable() + _result_submit = Callable() require_join_authorisation = false admissions_open = true @@ -443,6 +447,17 @@ func configure_connection_lease_callbacks(claim: Callable, disconnect: Callable) _connection_lease_disconnect = disconnect +func configure_result_submission(callback: Callable) -> void: + _result_submit = callback + + +func submit_authoritative_result(score: Dictionary) -> 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])) + return true + + func _claim_join_authorisation(token: String, peer_id: int) -> int: var expected_generation := _available_join_generation(token) if expected_generation < 0: diff --git a/Game/scripts/networked_match.gd b/Game/scripts/networked_match.gd index f5d54ff5..58170846 100644 --- a/Game/scripts/networked_match.gd +++ b/Game/scripts/networked_match.gd @@ -322,6 +322,7 @@ var _last_emitted_countdown := -1 var _in_overtime := false var _match_over := false var _planned_server_shutdown := false +var _awaiting_result_submission := false # Dedicated-export smoke hook (task 6.2). It is parsed only by the authoritative # server, cannot be triggered by an RPC, and defaults to disabled. var _smoke_force_goal_tick := -1 @@ -362,6 +363,10 @@ func _ready() -> void: _replay_log = null else: print("NetworkedMatch: recording replay log to %s" % replay_path) + # Result acknowledgement is relevant only to the authority. Clients move + # to their lobby on the replicated RESULTS -> LOBBY transition. + MatchNet.result_submission_accepted.connect(_on_result_submission_accepted) + MatchNet.result_submission_retrying.connect(_on_result_submission_retrying) _start_server() else: for arg: String in OS.get_cmdline_user_args(): @@ -1055,6 +1060,8 @@ func _enter_results(winning_team: int) -> void: _clock_running = false _set_bodies_frozen(true) match_ended.emit(winning_team, score.duplicate()) + if multiplayer.is_server() and MatchNet.submit_authoritative_result(score): + _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) @@ -1107,6 +1114,8 @@ func _update_match_state() -> void: _set_match_state(MatchState.State.WARMUP) _begin_kickoff() MatchState.State.RESULTS: + if _awaiting_result_submission: + return # §6.2 step 10: clients return to the LOBBY, never the main menu — # a community server that empties every 2.5 minutes is dead on # arrival. The state change is what moves both sides; the server @@ -1115,6 +1124,18 @@ func _update_match_state() -> void: get_tree().change_scene_to_file.call_deferred(ScenePaths.LOBBY) +func _on_result_submission_accepted() -> void: + if not multiplayer.is_server() or not _awaiting_result_submission: + return + _awaiting_result_submission = false + _state_deadline_tick = Engine.get_physics_frames() + + +func _on_result_submission_retrying(http_code: int) -> void: + if multiplayer.is_server() and _awaiting_result_submission: + ServerLog.warn("result_submission_retrying", {"http_code": http_code}) + + func _on_state_change_received(state: int, at_tick: int) -> void: # Client path. MatchSim already rejected an unknown state value, and the # server is the only peer allowed to send this (rpc "authority"). diff --git a/Game/scripts/server_boot.gd b/Game/scripts/server_boot.gd index f692f043..6cf23e41 100644 --- a/Game/scripts/server_boot.gd +++ b/Game/scripts/server_boot.gd @@ -5,6 +5,7 @@ const ServerControlScript = preload("res://scripts/server_control.gd") const AgonesSDKScript = preload("res://scripts/agones_sdk.gd") const AssignmentState = preload("res://scripts/assignment_state.gd") const ConnectionLeaseClientScript = preload("res://scripts/connection_lease_client.gd") +const ServerResultClientScript = preload("res://scripts/server_result_client.gd") # Headless dedicated server entry point (task 1.6). Parses CLI args, hosts # via NetworkManager, logs structured lines, and watches for physics-tick @@ -34,6 +35,7 @@ var _control: ServerControl = null var _match_loop: ServerMatchLoop = null var _agones = null var _connection_leases = null +var _result_client = null var _drain_requested := false @@ -123,6 +125,16 @@ func _ready() -> void: printerr("cosmic-clash-server: refusing allocated startup without connection-lease configuration") get_tree().quit(1) return + _result_client = ServerResultClientScript.new() + _result_client.name = "ServerResults" + if not _result_client.configure(lease_url, lease_token, String(config.get_value("match-id")), String(config.get_value("server-id"))): + printerr("cosmic-clash-server: refusing allocated startup without result-submission configuration") + get_tree().quit(1) + return + _result_client.accepted.connect(func(): MatchNet.result_submission_accepted.emit()) + _result_client.retrying.connect(func(http_code): MatchNet.result_submission_retrying.emit(http_code)) + get_tree().root.add_child.call_deferred(_result_client) + MatchNet.configure_result_submission(_result_client.submit) NetworkManager.client_connected.connect(_on_client_connected) NetworkManager.client_disconnected.connect(_on_client_disconnected) diff --git a/Game/scripts/server_result_client.gd b/Game/scripts/server_result_client.gd new file mode 100644 index 00000000..141488b4 --- /dev/null +++ b/Game/scripts/server_result_client.gd @@ -0,0 +1,91 @@ +class_name ServerResultClient +extends Node + +# The allocated server is the sole authority able to finish a match. Keep the +# match in RESULTS until the control plane has durably acknowledged this exact, +# idempotent payload: exiting first would strand the match in LIVE forever. + +signal accepted +signal retrying(http_code: int) + +const RETRY_SECONDS := 1.0 + +var _base_url := "" +var _workload_token := "" +var _match_id := "" +var _server_id := "" +var _submitting := false + + +func configure(base_url: String, workload_token: String, match_id: String, server_id: String) -> bool: + base_url = base_url.strip_edges().trim_suffix("/") + workload_token = workload_token.strip_edges() + if not valid_configuration(base_url, workload_token, match_id, server_id): + return false + _base_url = base_url + _workload_token = workload_token + _match_id = match_id + _server_id = server_id + return true + + +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": + return + _submitting = true + var nonce := result_nonce(_match_id, _server_id, team_0, team_1, integrity_state) + var key := "server-result-" + nonce + var payload := { + "match_id": _match_id, + "result_nonce": nonce, + "score": {"team_0": team_0, "team_1": team_1}, + "integrity_state": integrity_state, + } + while is_inside_tree(): + var response := await _send(payload, key) + if response_is_accepted(int(response.get("code", 0))): + _submitting = false + accepted.emit() + return + retrying.emit(int(response.get("code", 0))) + await get_tree().create_timer(RETRY_SECONDS).timeout + _submitting = false + + +func _send(payload: Dictionary, key: String) -> Dictionary: + var request := HTTPRequest.new() + request.timeout = 5.0 + add_child(request) + var err := request.request("%s/v1/servers/%s/result" % [_base_url, _server_id.uri_encode()], [ + "Authorization: Bearer " + _workload_token, + "Content-Type: application/json", + "Idempotency-Key: " + key, + ], HTTPClient.METHOD_POST, JSON.stringify(payload)) + if err != OK: + request.queue_free() + return {"code": 0} + var raw: Array = await request.request_completed + request.queue_free() + if int(raw[0]) != HTTPRequest.RESULT_SUCCESS: + return {"code": 0} + return {"code": int(raw[1])} + + +static func result_nonce(match_id: String, server_id: String, team_0: int, team_1: int, integrity_state: String) -> String: + # Result score is immutable once NetworkedMatch enters RESULTS. A deterministic + # nonce makes retries after a lost response provably the same submission. + return "result-" + (match_id + "\n" + server_id + "\n" + str(team_0) + "\n" + str(team_1) + "\n" + integrity_state).sha256_text() + + +static func response_is_accepted(http_code: int) -> bool: + # The documented endpoint acknowledges only after its serializable result + # transaction commits. Do not treat a generic 2xx as proof of completion. + return http_code == 202 + + +static func valid_configuration(base_url: String, workload_token: String, match_id: String, server_id: String) -> bool: + if not (base_url.begins_with("http://") or base_url.begins_with("https://")) or base_url.contains("\n") or base_url.contains("\r") or base_url.contains("?") or base_url.contains("#"): + return false + if workload_token.is_empty() or workload_token.contains("\n") or workload_token.contains("\r"): + return false + return match_id.length() >= 8 and server_id.length() >= 8 and not match_id.contains("/") and not server_id.contains("/") diff --git a/Game/tests/cases/test_server_result_client.gd b/Game/tests/cases/test_server_result_client.gd new file mode 100644 index 00000000..577137b7 --- /dev/null +++ b/Game/tests/cases/test_server_result_client.gd @@ -0,0 +1,23 @@ +extends "res://tests/test_case.gd" + +const Client = preload("res://scripts/server_result_client.gd") + + +func test_result_nonce_is_deterministic_and_score_bound() -> void: + var first := Client.result_nonce("match-123456789", "server-123456789", 3, 2, "CERTIFIED") + assert_eq(first, Client.result_nonce("match-123456789", "server-123456789", 3, 2, "CERTIFIED"), "retry keeps the exact nonce") + assert_true(first != Client.result_nonce("match-123456789", "server-123456789", 2, 3, "CERTIFIED"), "a conflicting score cannot reuse the nonce") + assert_true(first.length() >= 16, "nonce satisfies the control-plane minimum") + + +func test_result_configuration_fails_closed() -> void: + assert_true(Client.valid_configuration("https://control.invalid", "token", "match-123456789", "server-123456789"), "valid result reporter configuration is accepted") + assert_true(not Client.valid_configuration("https://control.invalid?token=leak", "token", "match-123456789", "server-123456789"), "query-bearing endpoint is rejected") + assert_true(not Client.valid_configuration("https://control.invalid", "", "match-123456789", "server-123456789"), "empty bearer is rejected") + + +func test_only_a_committed_result_acknowledgement_releases_the_match() -> void: + assert_true(Client.response_is_accepted(202), "the endpoint's accepted response releases RESULTS") + 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") diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md index 5c9671a2..17a56d15 100644 --- a/docs/THREAT-MODEL.md +++ b/docs/THREAT-MODEL.md @@ -26,11 +26,13 @@ individual pod. allocation state or exemptions. - Game servers are authoritative for simulation but are not trusted for identity, allocation ownership, or unrestricted result submission. -- PostgreSQL is the durable authority. Redis, Agones annotations and local - spool files are recoverable transport/cache state. +- PostgreSQL is the durable authority. Redis and Agones annotations are + recoverable transport/cache state. - The offline SDR CA and online leaf signer are separate; API, matcher, allocator and game-server workloads cannot read signer keys. Every accepted residual risk above has an owner and a planned detection path. -Security incidents fail closed for identity/result ownership and degrade open -only for recoverable result delivery, where the signed spool is reconciled. +Security incidents fail closed for identity/result ownership. An allocated +server remains in its results state and retries its idempotent result request +until the control plane durably acknowledges it; it does not exit first and +silently lose the authoritative outcome. diff --git a/multiplayer-next.md b/multiplayer-next.md index 69f048d1..ad96e324 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1213,7 +1213,7 @@ production fallback. | 8.22 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked profile exposes the first ten games as provisional, derives tiers only through validated backend-owned rating bands, and keeps casual ratings outside the API; authenticated HTTP now returns the authoritative ranked view. **"Authoritative ranked view" was durable-adapter-shaped but had no durable adapter**: `rankedProfile`/`profile` only ever read an in-memory map, so every real `GET /v1/profile/ranked` 404'd regardless of a player's actual rating. `RankedProfileProvider` (interface) + `store.PostgresRankedProfiles` close it, preferred over the map when set so existing tests/literals are unaffected; `LastSeasonID`/`SeasonHistory` deliberately left unset (no season pointer on `ratings`, needs its own query/semantics) | `server/domain/rating.go`, `tier_test.go` and `server/api/service.go` cover provisional override, exact band boundaries, malformed policy rejection, session authentication and ranked-only response fields; `server/store/ranked_profile_sql.go`, verified against real PostgreSQL via curl (a fresh identity correctly 404s through the real adapter) and via §8.40's integration test (`control_plane_smoke.gd` now asserts this exact 404 round-trips before queueing); persisted tier policy, client UI and reconnect transport remain | | 8.23 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked-only season policy compresses 25% toward 1500, clamps RD to 200–350, preserves volatility/history, is idempotent by season ID, and defines exact 12-week windows/due detection; migration and Go store now persist a per-player/per-season marker and rating update atomically; `cmd/maintenance` runs bounded due-season batches with signal-bound shutdown | `server/domain/rating.go`, `season_test.go`, `server/migrations/0001_initial.sql` and `server/store/maintenance_sql.go` cover compression, floor/cap, duplicate replay, window boundary, completed-season idempotence, bounded enumeration, row locking and conflict-safe rollover markers; opt-in PostgreSQL execution now covers the durable rating update, marker creation and duplicate replay without a second compression — re-run live for the first time as part of the wider integration-suite verification below, after fixing a test setup gap (a missing `seasons` row tripped the `ranked_season_rollovers` foreign key before the rollover logic itself ran); live maintenance/DB execution remains | | 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. 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. 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 | +| 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 | #### 8D — Agones, allocation and regional scaling