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.
230 lines
9.6 KiB
GDScript
230 lines
9.6 KiB
GDScript
extends Node
|
|
|
|
# Autoload (project.godot [autoload] NetworkManager). Owns the ENet
|
|
# transport: hosting, joining, shutdown, and connection-state signals. Lives
|
|
# at a fixed autoload path so RPC NodePaths never depend on which scene is
|
|
# loaded (§1.3 of multiplayer-todo.md's derived decisions).
|
|
#
|
|
# server_relay = false is set the moment a peer exists: the default `true`
|
|
# lets any client rpc() any other client *through the server*, which this
|
|
# project's server-authoritative model must never allow — §2.1 calls this
|
|
# out as the single highest-value one-line security change in the document.
|
|
#
|
|
# IMPORTANT, learned the hard way (tests/net_smoke.gd): don't call
|
|
# shutdown()/close the peer the instant connected_to_server or peer_connected
|
|
# fires. ENet's connect handshake isn't fully settled on the *other* side the
|
|
# moment your own side's signal fires — the final ACK still needs a couple
|
|
# more poll() cycles to actually reach the wire. Closing immediately drops
|
|
# it and leaves the other side's handshake permanently incomplete (it will
|
|
# never see peer_connected/connected_to_server at all). Callers that shut
|
|
# down right after a fresh connection should let a frame or two pass first.
|
|
#
|
|
# Manual polling (task 1.3): SceneTree's automatic multiplayer poll runs on
|
|
# the *idle* frame, so an rpc() issued from _physics_process waits up to a
|
|
# full frame before it's actually pushed onto the wire — and the return leg
|
|
# pays the same tax again. set_multiplayer_poll_enabled(false) below turns
|
|
# that off; every caller that sends or expects to receive on a tight cadence
|
|
# must now call NetworkManager.poll() itself. The intended placement per
|
|
# multiplayer-todo.md §7 task 1.3 (client: end of _physics_process after
|
|
# sending input, plus top of both _process and _physics_process for receive;
|
|
# server: tick start to drain, tick end to flush) has no real per-tick caller
|
|
# yet — that lands with the input/snapshot pipeline (tasks 1.4+, Phase 2-3).
|
|
# Until then, anything driving a connection (tests/net_smoke.gd included)
|
|
# must poll() every frame itself or nothing will ever be sent or received.
|
|
|
|
signal client_connected(peer_id: int)
|
|
signal client_disconnected(peer_id: int)
|
|
signal connected_to_server()
|
|
signal connection_failed()
|
|
signal disconnected_from_server()
|
|
signal clock_updated(rtt_ms: float, offset_ms: float)
|
|
# Fires at the top of every shutdown() call, whether this process was
|
|
# hosting, joined, or already offline, and regardless of *why* (deliberate
|
|
# Leave/Cancel, or an incoming disconnect from the other side). Adversarial
|
|
# review found MatchNet.roster had no path that cleared it when a HOST
|
|
# stopped hosting — connected_to_server/disconnected_from_server only cover
|
|
# the client side — so a host -> lobby -> leave -> host-again cycle left a
|
|
# permanent phantom player. Listeners that need per-role cleanup should
|
|
# still use the more specific signals above; this one exists so "something
|
|
# is about to reset the connection, drop anything you were keeping" has
|
|
# exactly one place to hook regardless of role.
|
|
signal shutting_down()
|
|
|
|
const DEFAULT_PORT := 7777
|
|
const MAX_CLIENTS := 32
|
|
|
|
# Clock (task 1.8, §4.7): client pings the server once a second on the
|
|
# reliable control channel; clock_offset_ms is the min-RTT sample in a
|
|
# rolling window, because the lowest-RTT sample has the least queueing
|
|
# error. get_server_time_estimate_ms() is the thing every later phase
|
|
# (interpolation delay, tick_offset seeding) actually wants — everything
|
|
# else here exists to produce it.
|
|
const PING_INTERVAL_SEC := 1.0
|
|
const CLOCK_WINDOW_SEC := 5.0
|
|
|
|
var is_server := false
|
|
var is_client := false
|
|
var _peer: ENetMultiplayerPeer # keep a strong ref alongside multiplayer.multiplayer_peer
|
|
|
|
var rtt_ms := -1.0 # min-RTT sample currently in the window; -1 = no sample yet
|
|
var clock_offset_ms := 0.0 # add to a local Time.get_ticks_msec() reading to estimate the server's clock
|
|
var _clock_samples: Array[Dictionary] = []
|
|
var _ping_accum_sec := 0.0
|
|
|
|
|
|
func _ready() -> void:
|
|
get_tree().set_multiplayer_poll_enabled(false)
|
|
multiplayer.peer_connected.connect(_on_peer_connected)
|
|
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
|
multiplayer.connected_to_server.connect(_on_connected_to_server)
|
|
multiplayer.connection_failed.connect(_on_connection_failed)
|
|
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
# is_client turns true the instant join() is called, before the ENet
|
|
# handshake actually completes (or fails) — a slow or refused connect
|
|
# attempt would otherwise leave this trying to rpc_id() on a peer
|
|
# that's still CONNECTING (or already failed), which Godot logs as
|
|
# "Trying to call an RPC via a multiplayer peer which is not
|
|
# connected." every single frame. Require the real transport state.
|
|
if not is_client or _peer == null or _peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
|
|
return
|
|
_ping_accum_sec += delta
|
|
if _ping_accum_sec >= PING_INTERVAL_SEC:
|
|
_ping_accum_sec = 0.0
|
|
# Capture the timestamp now, before NetSim (task 2.8) can add any
|
|
# simulated delay — see net_sim.gd's header comment for why.
|
|
var send_ms := Time.get_ticks_msec()
|
|
NetSim.send(func() -> void: _ping.rpc_id(1, send_ms), 1)
|
|
|
|
|
|
# Estimate of what the server's Time.get_ticks_msec() reads right now.
|
|
# Meaningless before the first pong lands (clock_offset_ms is 0.0 until then
|
|
# — callers needing round-trip-confirmed freshness should check rtt_ms >= 0).
|
|
func get_server_time_estimate_ms() -> float:
|
|
return float(Time.get_ticks_msec()) + clock_offset_ms
|
|
|
|
|
|
# The single entry point every per-tick caller uses instead of relying on
|
|
# SceneTree's (now disabled) automatic poll. Safe to call with no peer set —
|
|
# polling the default OfflineMultiplayerPeer is a no-op.
|
|
func poll() -> void:
|
|
multiplayer.poll()
|
|
|
|
|
|
func host(port: int = DEFAULT_PORT, max_clients: int = MAX_CLIENTS) -> Error:
|
|
shutdown()
|
|
var peer := ENetMultiplayerPeer.new()
|
|
var err := peer.create_server(port, max_clients)
|
|
if err != OK:
|
|
push_error("NetworkManager.host: create_server failed (%s)" % error_string(err))
|
|
return err
|
|
_peer = peer
|
|
multiplayer.multiplayer_peer = peer
|
|
multiplayer.server_relay = false
|
|
is_server = true
|
|
is_client = false
|
|
return OK
|
|
|
|
|
|
func join(address: String, port: int = DEFAULT_PORT) -> Error:
|
|
shutdown()
|
|
var peer := ENetMultiplayerPeer.new()
|
|
var err := peer.create_client(address, port)
|
|
if err != OK:
|
|
push_error("NetworkManager.join: create_client failed (%s)" % error_string(err))
|
|
return err
|
|
_peer = peer
|
|
multiplayer.multiplayer_peer = peer
|
|
multiplayer.server_relay = false
|
|
is_server = false
|
|
is_client = true
|
|
return OK
|
|
|
|
|
|
func shutdown() -> void:
|
|
shutting_down.emit()
|
|
# MultiplayerAPI's default multiplayer_peer is an OfflineMultiplayerPeer
|
|
# sentinel, never null — closing that sentinel is a no-op, but assigning
|
|
# multiplayer_peer = null (rather than a fresh OfflineMultiplayerPeer)
|
|
# leaves the API in a state distinct from its own default, which is a
|
|
# known source of confusing follow-on bugs (godotengine/godot#81540).
|
|
# Always reset to a real OfflineMultiplayerPeer, never raw null.
|
|
var peer := multiplayer.multiplayer_peer
|
|
if peer != null and not (peer is OfflineMultiplayerPeer):
|
|
peer.close()
|
|
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
|
_peer = null
|
|
is_server = false
|
|
is_client = false
|
|
rtt_ms = -1.0
|
|
clock_offset_ms = 0.0
|
|
_clock_samples.clear()
|
|
_ping_accum_sec = 0.0
|
|
|
|
|
|
@rpc("any_peer", "call_remote", "reliable")
|
|
func _ping(client_send_ms: int) -> void:
|
|
if not multiplayer.is_server():
|
|
return
|
|
# Same rule as the client's send above: read the server's clock now, at
|
|
# true receipt time, before NetSim can delay the reply — otherwise the
|
|
# server's own outbound leg would be silently absorbed out of both the
|
|
# RTT sample and the offset estimate instead of adding to them.
|
|
var server_now := Time.get_ticks_msec()
|
|
var sender_id := multiplayer.get_remote_sender_id()
|
|
# A single poll() call can process several queued RPCs from the same
|
|
# peer in one batch — an earlier one in that same batch (e.g. task 3.4's
|
|
# abuse-triggered disconnect_peer(..., now=true), which removes the
|
|
# peer immediately rather than waiting for an acknowledged disconnect)
|
|
# can leave this ping's sender no longer a valid peer by the time its
|
|
# own turn in the batch comes up. NetSim's inactive/passthrough path
|
|
# (the common case — no CLI flags) dispatches immediately with no
|
|
# validation of its own, so check here rather than relying on it.
|
|
if sender_id not in multiplayer.get_peers():
|
|
return
|
|
NetSim.send(func() -> void: _pong.rpc_id(sender_id, client_send_ms, server_now), sender_id)
|
|
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _pong(client_send_ms: int, server_now_ms: int) -> void:
|
|
var now_ms := Time.get_ticks_msec()
|
|
var sample_rtt := float(now_ms - client_send_ms)
|
|
var sample_offset := float(server_now_ms) + sample_rtt / 2.0 - float(now_ms)
|
|
_clock_samples.append({"t": now_ms, "rtt": sample_rtt, "offset": sample_offset})
|
|
|
|
var cutoff := now_ms - int(CLOCK_WINDOW_SEC * 1000.0)
|
|
_clock_samples = _clock_samples.filter(func(s: Dictionary) -> bool: return s["t"] >= cutoff)
|
|
|
|
var best: Dictionary = _clock_samples[0]
|
|
for sample: Dictionary in _clock_samples:
|
|
if sample["rtt"] < best["rtt"]:
|
|
best = sample
|
|
rtt_ms = best["rtt"]
|
|
clock_offset_ms = best["offset"]
|
|
clock_updated.emit(rtt_ms, clock_offset_ms)
|
|
|
|
|
|
func _on_peer_connected(peer_id: int) -> void:
|
|
client_connected.emit(peer_id)
|
|
|
|
|
|
func _on_peer_disconnected(peer_id: int) -> void:
|
|
client_disconnected.emit(peer_id)
|
|
|
|
|
|
func _on_connected_to_server() -> void:
|
|
connected_to_server.emit()
|
|
|
|
|
|
func _on_connection_failed() -> void:
|
|
is_client = false
|
|
connection_failed.emit()
|
|
|
|
|
|
func _on_server_disconnected() -> void:
|
|
is_server = false
|
|
is_client = false
|
|
disconnected_from_server.emit()
|