mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-14 09:42:02 +00:00
feat: enforce allocated join roster admission
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user