From 68d832f7bc943bdad4f9d75882476c98501fab07 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:39:56 +0100 Subject: [PATCH] feat: enforce allocated join roster admission --- Game/scripts/match_net.gd | 48 ++++++++++++++++++++++++++ Game/scripts/server_boot.gd | 15 ++++++++ Game/scripts/server_config.gd | 3 ++ Game/tests/cases/test_match_net.gd | 17 +++++++++ Game/tests/cases/test_server_config.gd | 2 +- multiplayer-todo.md | 2 +- 6 files changed, 85 insertions(+), 2 deletions(-) diff --git a/Game/scripts/match_net.gd b/Game/scripts/match_net.gd index 806c778c..da5afc60 100644 --- a/Game/scripts/match_net.gd +++ b/Game/scripts/match_net.gd @@ -52,6 +52,9 @@ var local_player_name := "Player" # this empty for backwards compatibility; allocated matches carry the opaque # signed authorisation in hello rather than putting it in the endpoint URL. var join_authorisation := "" +var require_join_authorisation := false +var _allowed_join_authorisations: Dictionary = {} +var _join_authorisation_context: Dictionary = {} # Test hook (tests/match_net_smoke.gd): set false before connecting to # suppress the automatic real hello, so a test can send a deliberately @@ -85,6 +88,23 @@ func _on_disconnected_from_server() -> void: # the same process. func _on_shutting_down() -> void: roster.clear() + _allowed_join_authorisations.clear() + _join_authorisation_context.clear() + require_join_authorisation = false + + +func configure_join_authorisations(tokens: Array, context: Dictionary) -> bool: + var allowed := {} + for token in tokens: + if not token is String or String(token).is_empty(): + return false + allowed[String(token)] = true + if allowed.is_empty() or String(context.get("match_id", "")).is_empty() or String(context.get("server_id", "")).is_empty() or int(context.get("protocol_version", 0)) < 1: + return false + _allowed_join_authorisations = allowed + _join_authorisation_context = context.duplicate(true) + require_join_authorisation = true + return true # Server only: a raw ENet disconnect (crash, timeout) that never sent a @@ -162,6 +182,9 @@ func _hello(protocol_version: int, tick_hz: int, player_name: String, supplied_j if tick_hz != SimConstants.TICK_HZ: await _reject(peer_id, "physics tick rate mismatch: server=%d client=%d" % [SimConstants.TICK_HZ, tick_hz]) return + if require_join_authorisation and not _valid_join_authorisation(supplied_join_authorisation): + await _reject(peer_id, "join authorisation rejected") + return if player_name.length() > MAX_INPUT_LENGTH: await _reject(peer_id, "player name too long") return @@ -181,6 +204,31 @@ func _hello(protocol_version: int, tick_hz: int, player_name: String, supplied_j _player_joined.rpc(peer_id, clean_name, team, false) # broadcast, includes the new peer itself +func _valid_join_authorisation(token: String) -> bool: + if token.is_empty() or not _allowed_join_authorisations.has(token): + return false + var standard_token := token.replace("-", "+").replace("_", "/") + while standard_token.length() % 4 != 0: + standard_token += "=" + var decoded := Marshalls.base64_to_raw(standard_token) + if decoded.is_empty(): + return false + var envelope = JSON.parse_string(decoded.get_string_from_utf8()) + if not envelope is Dictionary or not envelope.has("Authorisation") or not envelope.has("Signature") or str(envelope["Signature"]).is_empty(): + return false + var claims = envelope["Authorisation"] + if not claims is Dictionary: + return false + var protocol := str(claims.get("Protocol", "")) + var expires_at := str(claims.get("ExpiresAt", "")) + var expiry := Time.get_unix_time_from_datetime_string(expires_at) + return str(claims.get("MatchID", "")) == str(_join_authorisation_context.get("match_id", "")) \ + and str(claims.get("ServerID", "")) == str(_join_authorisation_context.get("server_id", "")) \ + and protocol == str(_join_authorisation_context.get("protocol", "")) \ + and int(claims.get("Slot", -1)) >= 0 and int(claims.get("Slot", -1)) <= 5 \ + and expiry > Time.get_unix_time_from_system() + + # Strips control/formatting characters (so a name can't corrupt a log line # or blow out UI layout with e.g. embedded newlines) and clamps to display # length. Input is already bounded to MAX_INPUT_LENGTH by the caller before diff --git a/Game/scripts/server_boot.gd b/Game/scripts/server_boot.gd index af9aa66f..aae7464b 100644 --- a/Game/scripts/server_boot.gd +++ b/Game/scripts/server_boot.gd @@ -1,5 +1,7 @@ extends Node +const NetCodec = preload("res://scripts/net_codec.gd") + # Headless dedicated server entry point (task 1.6). Parses CLI args, hosts # via NetworkManager, logs structured lines, and watches for physics-tick # overrun (§9 gotcha 9: Engine.max_physics_steps_per_frame defaults to 8; @@ -60,6 +62,19 @@ func _ready() -> void: printerr("cosmic-clash-server: allocated transport '%s' is not supported by this build" % assigned_transport) get_tree().quit(1) return + if allocated_mode: + var roster_file := String(config.get_value("join-authorisations-file")) + var roster_json := FileAccess.get_file_as_string(roster_file) + var roster_tokens = JSON.parse_string(roster_json) + if not roster_tokens is Array or roster_tokens.is_empty() or not MatchNet.configure_join_authorisations(roster_tokens, { + "match_id": String(config.get_value("match-id")), + "server_id": String(config.get_value("server-id")), + "protocol": str(NetCodec.PROTOCOL_VERSION), + "protocol_version": NetCodec.PROTOCOL_VERSION, + }): + printerr("cosmic-clash-server: refusing to start with invalid join-authorisations-file") + get_tree().quit(1) + return NetworkManager.client_connected.connect(_on_client_connected) NetworkManager.client_disconnected.connect(_on_client_disconnected) diff --git a/Game/scripts/server_config.gd b/Game/scripts/server_config.gd index 14a61eb7..705b5f02 100644 --- a/Game/scripts/server_config.gd +++ b/Game/scripts/server_config.gd @@ -75,6 +75,7 @@ static func specs() -> Array[Spec]: out.append(Spec.new("server-image-digest", Kind.STRING, "", "allocation", "Expected immutable server image digest (sha256:...)")) out.append(Spec.new("transport", Kind.STRING, "", "allocation", "Assigned transport: steam_sdr or enet")) out.append(Spec.new("region", Kind.STRING, "", "allocation", "Assigned region: EU or NA")) + out.append(Spec.new("join-authorisations-file", Kind.STRING, "", "allocation", "JSON array of control-plane signed join envelopes mounted for this match")) return out @@ -266,6 +267,8 @@ func _validate() -> void: errors.append("--allocated-mode requires --%s" % key) if int(values["assignment-expiry-unix"]) <= int(Time.get_unix_time_from_system()): errors.append("--assignment-expiry-unix must be in the future") + if String(values["join-authorisations-file"]).is_empty(): + errors.append("--join-authorisations-file is required in allocated mode") var digest := String(values["server-image-digest"]) if not _is_sha256_digest(digest): errors.append("--server-image-digest must be sha256:<64 hex characters>") diff --git a/Game/tests/cases/test_match_net.gd b/Game/tests/cases/test_match_net.gd index e9ad2154..77ea62e3 100644 --- a/Game/tests/cases/test_match_net.gd +++ b/Game/tests/cases/test_match_net.gd @@ -37,3 +37,20 @@ func test_empty_or_whitespace_only_falls_back_to_default() -> void: func test_leading_trailing_whitespace_trimmed() -> void: assert_eq(MatchNet._sanitize_player_name(" Bob "), "Bob", "surrounding whitespace trimmed") + + +func test_allocated_join_authorisation_is_allowlisted_and_bound_to_server() -> void: + var claims := { + "MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1", + "SteamID": "steam-1", "Slot": 2, "Team": 1, "Protocol": "1", + "Generation": 1, "ExpiresAt": "2099-08-31T12:00:00Z", + } + var token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "trusted-signature"}).to_utf8_buffer()) + var match_net := MatchNet.new() + assert_true(match_net.configure_join_authorisations([token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}), "valid roster configures") + assert_true(match_net._valid_join_authorisation(token), "allowlisted matching token is accepted") + assert_true(not match_net._valid_join_authorisation(token + "tampered"), "token mutation is rejected") + var wrong_claims := claims.duplicate() + wrong_claims["ServerID"] = "other-server" + var wrong_token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": wrong_claims, "Signature": "trusted-signature"}).to_utf8_buffer()) + assert_true(not match_net._valid_join_authorisation(wrong_token), "wrong server claim is rejected") diff --git a/Game/tests/cases/test_server_config.gd b/Game/tests/cases/test_server_config.gd index 14c2cdcf..9aa25703 100644 --- a/Game/tests/cases/test_server_config.gd +++ b/Game/tests/cases/test_server_config.gd @@ -137,7 +137,7 @@ func test_allocated_mode_is_opt_in_and_requires_compatibility_manifest() -> void var valid = _parse([ "--allocated-mode", "--match-id=match_1234567890123456", "--server-id=server_1234567890123456", "--playlist-version=2026-08-31", "--client-build=client-2026-08-31", "--assignment-expiry-unix=%d" % (Time.get_unix_time_from_system() + 3600), "--server-image-digest=sha256:" + "a".repeat(64), - "--transport=enet", "--region=EU" + "--transport=enet", "--region=EU", "--join-authorisations-file=/run/secrets/join-authorisations.json" ]) assert_true(valid.is_valid(), "a complete allocated compatibility manifest is accepted: %s" % str(valid.errors)) diff --git a/multiplayer-todo.md b/multiplayer-todo.md index c01aace1..c3d87230 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1229,7 +1229,7 @@ the local/CI/community transport, not a silent production fallback. |---|---|---| | 8.39 `[D:8.3,8.14,8.17]` | **IN PROGRESS.** `MatchmakingState` now projects queue → proposal → allocation/process-ready/assignment-ready/connect/live plus terminal failure states; autoload `ControlPlaneClient` provides authenticated queue create/recovery/heartbeat/cancel and proposal response requests with idempotency/revision headers; `matchmaking.tscn`/`matchmaking.gd` expose the state and authoritative actions from the main menu; authenticated proposal recovery now reconciles missed proposal events and expires them at read time; the Go API publishes targeted authenticated revisioned queue/proposal/assignment events and the Godot client consumes state/proposal/assignment events, fetching the authoritative assignment after assignment readiness; the API can now use the durable participant-scoped proposal provider for both recovery and accept/decline mutations | `test_matchmaking_state.gd`, `test_control_plane_client.gd`, `test_matchmaking_ui.gd`, `TestProposalRecoveryIsParticipantScopedAndExpiresAtReadBoundary`, `TestAuthenticatedWebSocketDeliversOnlyTargetedRevisionedEvents`, `TestStateChangingAPIActionsPublishTargetedEvents`, `TestProposalRecoveryUsesDurableBackendAndRemainsParticipantScoped` and store proposal SQL tests reject stale/gapped/conflicting updates, validate endpoint/token/payload normalization, preserve idempotent duplicates, and guarantee visible phase/terminal copy; wiring durable allocation events into the outbox, wait/latency explanations and Godot runtime verification remain | | 8.40 `[D:8.3,8.14]` | **IN PROGRESS.** Pure Go revisioned replica reducer rejects gaps for REST resync, makes duplicate/out-of-order events idempotent, and resumes from the authoritative snapshot revision; authenticated queue-ticket recovery now has an owner-checked REST read; Godot client projection persists non-secret ticket/proposal state, forces authoritative recovery after restart, and can replay a lost queue-create response with the original ticket/idempotency key; the Go API now exposes an authenticated `/v1/events` WebSocket with bounded per-player queues, strict upgrade/vocabulary validation and REST-resync-safe slow-client failure; Godot `ControlPlaneClient` can connect to the stream using Godot 4.7's handshake-header API, consume validated events, automatically reconnect with bounded backoff, and trigger REST recovery on projection gaps and stream return; `OutboxDispatcher` now provides ordered at-least-once delivery after durable commit; assignment recovery events carry the durable assignment revision without leaking it into REST JSON; prediction startup now distinguishes the sequence-0 warm-up acknowledgement from genuine missing/evicted history, preventing a false hard-snap during a live match | `server/domain/sync.go`, `server/api/events.go`, `server/api/service.go`, `server/store/outbox.go`, `service_test.go`, `outbox_test.go`, `matchmaking_state.gd`, `control_plane_client.gd`, `local_prediction_history.gd`, `net_ship_predictor.gd` and tests cover gap, snapshot, replay, same-revision conflict, owner-only ticket recovery, expired-ticket terminal handling, malformed restart snapshots, API-level duplicate-create replay/conflict, authenticated handshake/key rejection, targeted event delivery, exactly-once slow-subscriber closure, invalid-event rejection, delivery-before-ack failure ordering, assignment event/response revision separation and prediction warm-up/hard-resync separation; Godot 4.7.1 headless project parse and 143-test unit harness pass with compatibility rendering, and a two-process ENet authoritative match smoke passes spawn, movement, prediction, 0% snapshot loss and 0 hard snaps; wiring the dispatcher to a production WebSocket/Redis worker and live multi-process control-plane/game verification remain | -| 8.41 `[D:7.8,8.9,8.31,8.40]` | **IN PROGRESS.** Authenticated `GET /v1/assignments/{matchId}` now exposes only a validated, player-scoped assignment view including the hosted endpoint; Godot `AssignmentState`/`ControlPlaneClient.fetch_assignment()` bind the response to the authenticated player, recheck expiry, validate and retain the endpoint, and `connect_to_assignment()` now starts only the validated ENet/Steam transport after assignment readiness; the opaque join authorisation is carried in the MatchNet hello payload rather than the endpoint URL; migration 0002 and the Go store adapter now persist/recover the complete player-scoped assignment projection with conflict-safe identical replay; `AssignmentProviderFromStore` wires that durable projection into the API injection point; `SaveAssignments` publishes a complete signed roster atomically instead of allowing partial player visibility; `SaveVerifiedAssignmentRoster` rechecks signed claims before deriving player rows | `server/api/service.go`, `store_adapters.go`, `service_test.go`, `assignment_state.gd`, `control_plane_client.gd`, `match_net.gd`, `test_assignment_state.gd`, `test_control_plane_client.gd`, `server/contracts/v1/openapi.json`, `server/migrations/0002_assignments.sql` and `server/store/assignment_sql.go` cover participant/identity/expiry/shape/transport/endpoint boundaries, player-scoped schema keys, expiry-filtered reads, assignment upsert conflict handling, atomic batch validation, signed-claim binding, strict endpoint splitting and 144-test Godot compatibility coverage; server-side signature/roster verification, SDR relay-ticket installation, fencing integration and live Godot/PostgreSQL verification remain | +| 8.41 `[D:7.8,8.9,8.31,8.40]` | **IN PROGRESS.** Authenticated `GET /v1/assignments/{matchId}` now exposes only a validated, player-scoped assignment view including the hosted endpoint; Godot `AssignmentState`/`ControlPlaneClient.fetch_assignment()` bind the response to the authenticated player, recheck expiry, validate and retain the endpoint, and `connect_to_assignment()` now starts only the validated ENet/Steam transport after assignment readiness; the opaque join authorisation is carried in the MatchNet hello payload rather than the endpoint URL; allocated Godot servers now fail closed unless an operator-mounted JSON roster of control-plane signed envelopes is present, and MatchNet checks exact token membership plus match/server/protocol/slot/expiry claims before admitting a peer; migration 0002 and the Go store adapter now persist/recover the complete player-scoped assignment projection with conflict-safe identical replay; `AssignmentProviderFromStore` wires that durable projection into the API injection point; `SaveAssignments` publishes a complete signed roster atomically instead of allowing partial player visibility; `SaveVerifiedAssignmentRoster` rechecks signed claims before deriving player rows | `server/api/service.go`, `store_adapters.go`, `service_test.go`, `assignment_state.gd`, `control_plane_client.gd`, `match_net.gd`, `server_boot.gd`, `server_config.gd`, `test_assignment_state.gd`, `test_control_plane_client.gd`, `test_match_net.gd`, `server/contracts/v1/openapi.json`, `server/migrations/0002_assignments.sql` and `server/store/assignment_sql.go` cover participant/identity/expiry/shape/transport/endpoint boundaries, player-scoped schema keys, expiry-filtered reads, assignment upsert conflict handling, atomic batch validation, signed-claim binding, strict endpoint splitting, allowlisted claim rejection and 145-test Godot compatibility coverage; cryptographic signature verification inside the Godot process, SDR relay-ticket installation, reconnect generation fencing and live Godot/PostgreSQL verification remain | | 8.42 `[D:8.22,8.23,8.24,8.40]` | **IN PROGRESS.** `RankedProfileState` and `ControlPlaneClient.fetch_ranked_profile()` expose the backend-authoritative rating/RD/volatility/games/tier/provisional/season view; matchmaking UI displays provisional/tier status without client-side rating math | `test_control_plane_client.gd` validates profile shape, numeric safety and provisional display; ranked profile fetch/display, committed revision after reconnect, abandon status and season countdown remain dependent on live auth/backend events and Godot runtime verification | | 8.43 `[D:8.39,8.40,8.41]` | **IN PROGRESS.** Matchmaking client now distinguishes expired queue recovery, session expiry, missing records and retryable control-plane outages; a 401 clears the in-memory token, emits `session_expired` and disables retry until a new session is configured; terminal messages remain visible and active searches are not falsely failed on transient errors | `MatchmakingState` and `ControlPlaneClient` tests cover explicit expiry and the existing terminal/retry-safe state paths; decline, version mismatch, regional outage retry UI, failed reconnect, duplicate-action recovery and live Godot verification remain |