mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-12 14:53:42 +00:00
feat(multiplayer): Phase 3 task 3.4 - input validation, rate limiting, disconnect policy
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.
This commit is contained in:
@@ -91,6 +91,12 @@ var _input_history: Array[ShipAction] = []
|
||||
var _last_received_snapshot_tick := 0 # client only: echoed back as ack_snapshot_tick
|
||||
var _input_lead_controller := InputLeadController.new() # client only (§3.3)
|
||||
var _last_known_input_buffer_depth := -1 # client only: -1 = no snapshot with this field yet
|
||||
# §3.1 step 4. Not 120: InputLeadController.LEAD_MAX is 12, so anything
|
||||
# claiming to be further ahead of the current server tick than this is
|
||||
# broken or hostile, not just an honest client running a legitimately fast
|
||||
# lead.
|
||||
const MAX_SEQ_LEAD_TICKS := 20
|
||||
var _unknown_sender_input_count := 0 # server only, observability (§3.1 step 1)
|
||||
var _reset_gen := 0 # server only: bumped on every kickoff/goal reset so the client hard-snaps instead of interpolating across the teleport
|
||||
# Server only. _on_goal_scored's reset_ball()/reset_ships() only QUEUE
|
||||
# teleports (task 0.15's queue_teleport — applied on each body's next
|
||||
@@ -207,9 +213,26 @@ func _start_server() -> void:
|
||||
func _on_input_received(peer_id: int, decoded: Dictionary) -> void:
|
||||
for slot in _slots:
|
||||
if slot.peer_id == peer_id:
|
||||
slot.jitter_buffer.ingest(decoded["seq"], decoded["actions"])
|
||||
var seq: int = decoded["seq"]
|
||||
# §3.1 step 4. Not 120: input_lead is clamped to
|
||||
# InputLeadController.LEAD_MAX (12), so anything claiming to be
|
||||
# further ahead than this is broken or hostile, not just a fast
|
||||
# lead. This is also why InputJitterBuffer's ring can be fixed-
|
||||
# size — a client can never make the server allocate — but
|
||||
# rejecting the packet here still keeps garbage-far-future seq
|
||||
# values out of the ring entirely rather than letting them
|
||||
# silently overwrite a near-future slot some honest, in-range
|
||||
# packet is about to need.
|
||||
if seq > Engine.get_physics_frames() + MAX_SEQ_LEAD_TICKS:
|
||||
return
|
||||
slot.jitter_buffer.ingest(seq, decoded["actions"])
|
||||
slot.last_client_send_ms = decoded["client_send_ms"]
|
||||
return
|
||||
# A connected-but-not-yet-slotted peer (or one whose slot somehow
|
||||
# vanished) sending input — harmless (the packet is simply dropped,
|
||||
# same as always), but worth counting for observability (§3.1 step 1)
|
||||
# rather than silently discarding with no trace at all.
|
||||
_unknown_sender_input_count += 1
|
||||
|
||||
|
||||
func _on_goal_registered(conceding_team: int) -> void:
|
||||
|
||||
Reference in New Issue
Block a user