feat: add authenticated matchmaking control client

This commit is contained in:
Josh Creek
2026-08-31 22:36:05 +01:00
parent 39ebfce1bb
commit 5106ac64da
5 changed files with 158 additions and 2 deletions
+1
View File
@@ -26,6 +26,7 @@ run/main_scene.dedicated_server="res://scenes/server_boot.tscn"
[autoload]
GameSettings="*res://scripts/game_settings.gd"
ControlPlaneClient="*res://scripts/control_plane_client.gd"
VideoSettings="*res://scripts/video_settings.gd"
BackgroundFPS="*res://scripts/background_fps.gd"
PerfOverlay="*res://scripts/perf_overlay.gd"
+129
View File
@@ -0,0 +1,129 @@
class_name ControlPlaneClient
extends Node
# Authenticated HTTP boundary for matchmaking. ENet/Steam carries the match
# itself; this client only handles queue/proposal control-plane state.
signal request_succeeded(operation: String, payload: Dictionary)
signal request_failed(operation: String, http_code: int, detail: String)
const DEFAULT_BASE_URL := "http://127.0.0.1:8080"
var base_url := DEFAULT_BASE_URL
var access_token := ""
var state: MatchmakingState
var _request: HTTPRequest
var _operation := ""
func _ready() -> void:
state = MatchmakingState.new()
_request = HTTPRequest.new()
_request.timeout = 10.0
add_child(_request)
_request.request_completed.connect(_on_request_completed)
func configure(url: String, token: String) -> bool:
var normalized := url.strip_edges().trim_suffix("/")
var normalized_token := token.strip_edges()
if not is_valid_base_url(normalized) or normalized_token.is_empty() or normalized_token.contains("\r") or normalized_token.contains("\n"):
return false
base_url = normalized
access_token = normalized_token
return true
func queue_create(ticket_id: String, playlist: String, client_build: String, protocol_version: int) -> Error:
if ticket_id.is_empty() or (playlist != "casual" and playlist != "ranked") or client_build.is_empty() or protocol_version < 1:
return ERR_INVALID_PARAMETER
if not state.begin_queue(ticket_id, playlist):
return ERR_INVALID_PARAMETER
return _start_request("queue_create", HTTPClient.METHOD_POST, "/v1/queue", {"ticket_id": ticket_id, "playlist": playlist, "client_build": client_build, "protocol_version": protocol_version}, _idempotency_key("queue"))
func recover_queue(ticket_id: String) -> Error:
if ticket_id.is_empty():
return ERR_INVALID_PARAMETER
return _start_request("queue_recover", HTTPClient.METHOD_GET, "/v1/queue/" + ticket_id, {}, "")
func heartbeat(ticket_id: String, expected_revision: int) -> Error:
if ticket_id.is_empty() or expected_revision < 0:
return ERR_INVALID_PARAMETER
return _start_request("queue_heartbeat", HTTPClient.METHOD_POST, "/v1/queue/%s/heartbeat" % ticket_id, {}, _idempotency_key("heartbeat"), expected_revision)
func cancel_queue(ticket_id: String, expected_revision: int) -> Error:
if ticket_id.is_empty() or expected_revision < 0 or not state.can_cancel():
return ERR_INVALID_PARAMETER
return _start_request("queue_cancel", HTTPClient.METHOD_POST, "/v1/queue/%s/cancel" % ticket_id, {}, _idempotency_key("cancel"), expected_revision)
func respond_to_proposal(proposal_id: String, accept: bool, expected_revision: int) -> Error:
if proposal_id.is_empty() or expected_revision < 0:
return ERR_INVALID_PARAMETER
var action := "accept" if accept else "decline"
return _start_request("proposal_" + action, HTTPClient.METHOD_POST, "/v1/proposals/%s/%s" % [proposal_id, action], {}, _idempotency_key("proposal"), expected_revision)
static func is_valid_base_url(url: String) -> bool:
if url.is_empty() or url.contains(" ") or url.contains("\r") or url.contains("\n") or url.contains("?") or url.contains("#") or url.contains("@") or url.ends_with("/"):
return false
return url.begins_with("http://") or url.begins_with("https://")
static func normalize_ticket(payload: Dictionary) -> Dictionary:
var result := payload.duplicate(true)
if result.has("expires_at") and result["expires_at"] is String:
result["expires_at_unix"] = Time.get_unix_time_from_datetime_string(String(result["expires_at"]))
return result
func _start_request(operation: String, method: HTTPClient.Method, path: String, payload: Dictionary, idempotency_key: String, expected_revision: int = -1) -> Error:
if _request == null or not _operation.is_empty() or access_token.is_empty() or not is_valid_base_url(base_url):
return ERR_BUSY if not _operation.is_empty() else ERR_UNAUTHORIZED
var headers := PackedStringArray(["Authorization: Bearer " + access_token, "Accept: application/json"])
if not idempotency_key.is_empty():
headers.append("Idempotency-Key: " + idempotency_key)
if expected_revision >= 0:
headers.append("If-Match-Revision: %d" % expected_revision)
var body := "" if payload.is_empty() else JSON.stringify(payload)
_operation = operation
var err := _request.request(base_url + path, headers, method, body)
if err != OK:
_operation = ""
return err
return OK
func _on_request_completed(result: HTTPRequest.Result, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var operation := _operation
_operation = ""
if result != HTTPRequest.RESULT_SUCCESS:
state.fail("Control-plane request failed")
request_failed.emit(operation, response_code, "network error")
return
var parsed = JSON.parse_string(body.get_string_from_utf8())
if not parsed is Dictionary:
state.fail("Control-plane returned invalid JSON")
request_failed.emit(operation, response_code, "invalid JSON")
return
if response_code < 200 or response_code >= 300:
var detail := String(parsed.get("error", "request rejected"))
state.fail(detail)
request_failed.emit(operation, response_code, detail)
return
var payload: Dictionary = parsed
if operation == "queue_create":
state.begin_queue(String(payload.get("ticket_id", "")), String(payload.get("playlist", "")))
if operation.begins_with("queue_"):
state.apply_ticket_update(normalize_ticket(payload))
elif operation.begins_with("proposal_"):
state.apply_proposal_update(payload)
request_succeeded.emit(operation, payload)
func _idempotency_key(prefix: String) -> String:
return "%s-%s-%s" % [prefix, str(Time.get_ticks_usec()), str(randi())]
@@ -0,0 +1,26 @@
extends "res://tests/test_case.gd"
const ControlPlaneClient = preload("res://scripts/control_plane_client.gd")
func test_base_url_validation_rejects_ambiguous_or_insecure_values() -> void:
assert_true(ControlPlaneClient.is_valid_base_url("http://127.0.0.1:8080"), "local HTTP endpoint is valid")
assert_true(ControlPlaneClient.is_valid_base_url("https://match.example"), "HTTPS endpoint is valid")
assert_true(not ControlPlaneClient.is_valid_base_url("match.example"), "scheme is required")
assert_true(not ControlPlaneClient.is_valid_base_url("http://match.example/"), "trailing slash is normalized before validation")
assert_true(not ControlPlaneClient.is_valid_base_url("http://match example"), "whitespace is rejected")
assert_true(not ControlPlaneClient.is_valid_base_url("https://user:pass@match.example"), "userinfo is rejected")
assert_true(not ControlPlaneClient.is_valid_base_url("https://match.example?token=secret"), "query strings are rejected")
var client := ControlPlaneClient.new()
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "safe access token configures")
assert_true(not client.configure("https://match.example", "token\nforged-header"), "header injection is rejected")
func test_ticket_normalization_preserves_payload_and_derives_expiry() -> void:
var payload := {"ticket_id": "ticket-1", "state": "QUEUED", "expires_at": "2026-08-31T12:00:00Z"}
var normalized := ControlPlaneClient.normalize_ticket(payload)
assert_eq(normalized["ticket_id"], "ticket-1", "normalization preserves ticket identity")
assert_true(normalized.has("expires_at_unix"), "RFC3339 expiry is available to the projection")
assert_true(int(normalized["expires_at_unix"]) > 0, "expiry is converted to a positive epoch")
assert_true(not payload.has("expires_at_unix"), "normalization does not mutate the HTTP payload")
+1 -1
View File
@@ -71,7 +71,7 @@ func test_physics_engine_is_jolt() -> void:
func test_required_autoloads_are_registered() -> void:
# NetworkManager in particular is reached by name from many scripts; losing
# it from [autoload] fails only at the point of use, deep in a smoke test.
for autoload_name in ["GameSettings", "VideoSettings", "NetworkManager", "MatchNet", "MatchSim"]:
for autoload_name in ["GameSettings", "ControlPlaneClient", "VideoSettings", "NetworkManager", "MatchNet", "MatchSim"]:
assert_true(
ProjectSettings.has_setting("autoload/" + autoload_name),
"autoload/%s registered" % autoload_name