mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
b290f49143
MatchSim._recv_input now validates before decoding (§3.1 steps 2-3): per-peer rolling-1s rate limiting (packet count AND byte budget, dropping over-budget packets and disconnecting after 3 consecutive over-budget seconds), and framing validation (redundancy count and payload size checked against NetCodec's own layout before unpack_input ever runs, disconnecting after 20 malformed packets). Framing has to be validated explicitly rather than relying on decode failure: StreamPeerBuffer silently zero-fills past EOF instead of erroring, a finding from Phase 2's adversarial review. networked_match.gd's _on_input_received now rejects any seq claiming to be more than 20 ticks ahead of the current server tick (§3.1 step 4) and counts (rather than silently ignoring) input from a peer with no slot, for observability. Verified with two new permanent regression tests (networked_match_smoke.gd --role=client-abuse-malformed / client-abuse-flood) that call MatchSim._recv_input directly with garbage bytes and a legitimate-but- too-frequent flood, respectively, bypassing the honest client encoder entirely - the same thing a hostile custom client sending raw ENet packets would look like. Both confirm real disconnection, not just that the server tolerates the abuse. Two bugs surfaced by getting these tests to actually pass cleanly: a GDScript lambda-capture-by-value mistake in the tests themselves (a plain `var disconnected := false` mutated inside a signal-handler lambda never became visible to the enclosing function - fixed by capturing a single-element Array instead, which is captured by reference); and a narrow real race where NetworkManager's own ping/pong reply could target a peer that a concurrent abuse-triggered disconnect had just removed from the same poll() batch, now guarded. (Passing disconnect_peer's `force` parameter as an attempted fix for a related one-off benign error was tried and reverted - it made Godot's own peer-list bookkeeping inconsistent, producing hundreds of errors instead of one; verified empirically rather than assumed.) Full regression suite, including the net-sim-latency milestone gate, re-run clean.
183 lines
8.1 KiB
GDScript
183 lines
8.1 KiB
GDScript
extends Node
|
|
|
|
# Autoload (project.godot [autoload] MatchSim). Phase 2 simulation RPCs:
|
|
# match_config (server assigns arena + deterministic slot order from
|
|
# MatchNet.roster), input (client -> server, per-tick action), snapshot
|
|
# (server -> client, NetCodec-packed body state), and a small score_update
|
|
# for the HUD. Lives on an autoload per §1.3's derived decision ("All
|
|
# hot-path RPCs live on autoloads") even though these are scoped to
|
|
# whichever match happens to be running — a scene-node RPC target would
|
|
# need matching NodePaths across peers, which an autoload sidesteps
|
|
# entirely, and it's what lets NetworkedMatch itself stay a plain scene
|
|
# node with no networking-identity concerns of its own.
|
|
#
|
|
# Channel intent per §2.1: 0 reliable (match_config, score_update), 1
|
|
# unreliable-ordered (input), 2 unreliable-ordered (snapshot) — not yet
|
|
# verified against ENet's own reserved system channel offset (§2.1's own
|
|
# "verify empirically" hedge); if that turns out to matter these indices
|
|
# will need adjusting, not the RPC design itself.
|
|
|
|
const NetCodec = preload("res://scripts/net_codec.gd")
|
|
|
|
signal match_config_received(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array)
|
|
signal input_received(peer_id: int, decoded: Dictionary) # decoded: see NetCodec.unpack_input
|
|
signal snapshot_received(decoded: Dictionary) # decoded: see NetCodec.unpack_snapshot
|
|
signal score_update_received(score: Dictionary)
|
|
|
|
# Input validation (multiplayer-todo.md §3.1 steps 2-3, task 3.4). Deliberately
|
|
# lives here rather than in NetworkedMatch: framing/rate abuse is a protocol-
|
|
# level concern independent of any particular match's roster/slot state, and
|
|
# this autoload already owns the RPC that receives the raw bytes.
|
|
#
|
|
# 60Hz * 1.5 + 20, per §3.1 step 2's own numbers.
|
|
const RATE_LIMIT_PACKETS_PER_SEC := 110
|
|
# "Same for a byte budget" (§3.1 step 2) — the worst-case legitimate packet
|
|
# is a full-redundancy input (INPUT_HEADER_SIZE + MAX_REDUNDANCY entries,
|
|
# the "40 B input" §2.3 sizes to), so the byte budget is just the packet
|
|
# budget scaled by that worst-case size — no separate constant to keep in
|
|
# sync by hand.
|
|
const RATE_LIMIT_BYTES_PER_SEC := RATE_LIMIT_PACKETS_PER_SEC * (NetCodec.INPUT_HEADER_SIZE + NetCodec.MAX_REDUNDANCY * NetCodec.INPUT_ENTRY_SIZE)
|
|
const RATE_LIMIT_WINDOW_MS := 1000
|
|
const RATE_LIMIT_OVER_BUDGET_SECONDS_TO_DISCONNECT := 3
|
|
const MALFORMED_LIMIT_TO_DISCONNECT := 20
|
|
|
|
|
|
class _PeerInputState:
|
|
var window_start_ms := 0
|
|
var packets_this_window := 0
|
|
var bytes_this_window := 0
|
|
var over_budget_seconds := 0
|
|
var malformed_count := 0
|
|
|
|
|
|
var _peer_input_state: Dictionary = {} # peer_id -> _PeerInputState, server only
|
|
|
|
|
|
func _ready() -> void:
|
|
NetworkManager.client_disconnected.connect(func(peer_id: int) -> void: _peer_input_state.erase(peer_id))
|
|
|
|
# Server only: the last match_config actually sent, so a client whose own
|
|
# scene load (and therefore its match_config_received listener) finishes
|
|
# AFTER the server already broadcast can still get it — a one-shot
|
|
# broadcast alone is racy against however long the client takes to reach
|
|
# the point where it's listening, and Godot signals never buffer for a
|
|
# late connection. request_match_config() closes that race by turning
|
|
# delivery into "ask until you get it" instead of "hope you were already
|
|
# listening." Also covers a late joiner mid-match (Phase 5 will still need
|
|
# to add live match *state*, not just this static config, for that case).
|
|
var _last_match_config: Dictionary = {}
|
|
|
|
|
|
func send_match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void:
|
|
_last_match_config = {
|
|
"arena_path": arena_path, "peer_ids": peer_ids, "teams": teams, "spawn_indices": spawn_indices,
|
|
}
|
|
_match_config.rpc(arena_path, peer_ids, teams, spawn_indices)
|
|
|
|
|
|
func request_match_config() -> void:
|
|
_request_match_config.rpc_id(1)
|
|
|
|
|
|
func send_input(bytes: PackedByteArray) -> void:
|
|
# bytes is already fully packed (any timestamps it carries are already
|
|
# fixed), so wrapping the dispatch itself is enough — task 2.8.
|
|
NetSim.send(func() -> void: _recv_input.rpc_id(1, bytes), 1)
|
|
|
|
|
|
func send_snapshot(peer_id: int, bytes: PackedByteArray) -> void:
|
|
NetSim.send(func() -> void: _snapshot.rpc_id(peer_id, bytes), peer_id)
|
|
|
|
|
|
func send_score_update(score: Dictionary) -> void:
|
|
_score_update.rpc(score)
|
|
|
|
|
|
@rpc("authority", "call_remote", "reliable", 0)
|
|
func _match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void:
|
|
match_config_received.emit(arena_path, peer_ids, teams, spawn_indices)
|
|
|
|
|
|
@rpc("any_peer", "call_remote", "reliable", 0)
|
|
func _request_match_config() -> void:
|
|
if not multiplayer.is_server() or _last_match_config.is_empty():
|
|
return
|
|
var peer_id := multiplayer.get_remote_sender_id()
|
|
_match_config.rpc_id(
|
|
peer_id, _last_match_config["arena_path"], _last_match_config["peer_ids"],
|
|
_last_match_config["teams"], _last_match_config["spawn_indices"]
|
|
)
|
|
|
|
|
|
@rpc("any_peer", "call_remote", "unreliable_ordered", 1)
|
|
func _recv_input(bytes: PackedByteArray) -> void:
|
|
if not multiplayer.is_server():
|
|
return
|
|
var peer_id := multiplayer.get_remote_sender_id()
|
|
|
|
var state: _PeerInputState = _peer_input_state.get(peer_id)
|
|
if state == null:
|
|
state = _PeerInputState.new()
|
|
_peer_input_state[peer_id] = state
|
|
|
|
# Rolling 1s window (§3.1 step 2). Rolled over lazily on the first
|
|
# packet past the window boundary, not on a timer — this RPC only ever
|
|
# runs when a packet actually arrives, so there's nothing to roll over
|
|
# when nothing is arriving anyway.
|
|
var now_ms := Time.get_ticks_msec()
|
|
if now_ms - state.window_start_ms >= RATE_LIMIT_WINDOW_MS:
|
|
var was_over_budget := state.packets_this_window > RATE_LIMIT_PACKETS_PER_SEC or state.bytes_this_window > RATE_LIMIT_BYTES_PER_SEC
|
|
state.over_budget_seconds = (state.over_budget_seconds + 1) if was_over_budget else 0
|
|
state.window_start_ms = now_ms
|
|
state.packets_this_window = 0
|
|
state.bytes_this_window = 0
|
|
if state.over_budget_seconds >= RATE_LIMIT_OVER_BUDGET_SECONDS_TO_DISCONNECT:
|
|
_disconnect_abusive_peer(peer_id, "input rate limit exceeded for %d consecutive seconds" % state.over_budget_seconds)
|
|
return
|
|
|
|
state.packets_this_window += 1
|
|
state.bytes_this_window += bytes.size()
|
|
if state.packets_this_window > RATE_LIMIT_PACKETS_PER_SEC or state.bytes_this_window > RATE_LIMIT_BYTES_PER_SEC:
|
|
return # over budget for the current window — drop, counted above at the next window roll
|
|
|
|
# Framing (§3.1 step 3), validated before decoding — unpack_input can't
|
|
# be trusted to catch this itself: StreamPeerBuffer silently zero-fills
|
|
# past EOF rather than erroring (found during Phase 2's adversarial
|
|
# review's hostile-client stress test), so a too-short or size-mismatched
|
|
# payload would otherwise decode "successfully" into garbage actions
|
|
# instead of being rejected.
|
|
if bytes.size() < NetCodec.INPUT_HEADER_SIZE:
|
|
_count_malformed(peer_id, state)
|
|
return
|
|
var count: int = bytes[5] # type_version(1) + seq(4) precede count — see pack_input's own layout
|
|
if count == 0 or count > NetCodec.MAX_REDUNDANCY or bytes.size() != NetCodec.INPUT_HEADER_SIZE + count * NetCodec.INPUT_ENTRY_SIZE:
|
|
_count_malformed(peer_id, state)
|
|
return
|
|
|
|
var decoded := NetCodec.unpack_input(bytes)
|
|
input_received.emit(peer_id, decoded)
|
|
|
|
|
|
func _count_malformed(peer_id: int, state: _PeerInputState) -> void:
|
|
state.malformed_count += 1
|
|
if state.malformed_count >= MALFORMED_LIMIT_TO_DISCONNECT:
|
|
_disconnect_abusive_peer(peer_id, "too many malformed input packets (%d)" % state.malformed_count)
|
|
|
|
|
|
func _disconnect_abusive_peer(peer_id: int, reason: String) -> void:
|
|
push_warning("MatchSim: disconnecting peer %d for abuse: %s" % [peer_id, reason])
|
|
_peer_input_state.erase(peer_id)
|
|
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
|
|
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
|
|
|
|
|
|
@rpc("authority", "call_remote", "unreliable_ordered", 2)
|
|
func _snapshot(bytes: PackedByteArray) -> void:
|
|
var decoded := NetCodec.unpack_snapshot(bytes)
|
|
snapshot_received.emit(decoded)
|
|
|
|
|
|
@rpc("authority", "call_remote", "reliable", 0)
|
|
func _score_update(score: Dictionary) -> void:
|
|
score_update_received.emit(score)
|