diff --git a/compat/Compat.cs b/compat/Compat.cs index df48041..b0eb85c 100644 --- a/compat/Compat.cs +++ b/compat/Compat.cs @@ -873,6 +873,88 @@ private static int DoNonces() return 0; } + private static int DoDisconnectReentry(string inputDir) + { + NetcodeLog.Level = LogLevel.None; + foreach (string name in new[] { "same", "stop", "timeout", "loopback" }) + { + byte[] sent = Array.Empty(); + int callbacks = 0; + DisconnectReason seenReason = DisconnectReason.None; + ulong seenId = 0; + byte seenUser = 0; + Server? serverRef = null; + var config = new ServerConfig { ProtocolId = SessionProtocolId, OverrideSendAndReceive = true }; + SessionPrivateKey.CopyTo(config.PrivateKey, 0); + config.SendPacketOverride = (in Address to, ReadOnlySpan data) => sent = data.ToArray(); + config.ReceivePacketOverride = (ref Address from, Span data) => 0; + config.SendLoopbackPacket = (int slot, ReadOnlySpan data, ulong sequence) => { }; + config.OnClientConnectDisconnect = (slot, connected) => + { + if (connected) return; + callbacks++; + if (callbacks != 1) return; + seenReason = serverRef!.ClientDisconnectReason(slot); + seenId = serverRef.ClientId(slot); + seenUser = serverRef.ClientUserData(slot)[0]; + if (name == "same") serverRef.DisconnectClient(slot); + else if (name == "loopback") serverRef.DisconnectLoopbackClient(slot); + else serverRef.Stop(); + }; + using var server = new Server("127.0.0.1:40000", config, 0.0); + serverRef = server; + server.Start(1); + if (name == "loopback") + { + byte[] user = new byte[Protocol.UserDataBytes]; + user[0] = 0x5A; + server.ConnectLoopbackClient(0, 0x1111, user); + } + else + { + server.SetConnectTokenExpiryFloorForTest(1000); + byte[] challengeKey = new byte[Protocol.KeyBytes]; + FillPattern(challengeKey, 77); + server.SetChallengeKeyForTest(challengeKey); + Address from = Address.Parse("127.0.0.1:50000"); + ConnectTokenData token = ScenarioToken(0x1111, 0x5A, 1); + byte[] wire = new byte[Defines.MaxPacketBytes]; + int bytes = PacketIO.WriteConnectionRequestPacket(wire, token.VersionInfo, + token.ProtocolId, token.ExpireTimestamp, token.Nonce, token.PrivateData); + File.WriteAllBytes(Path.Combine(inputDir, $"{name}-request.bin"), wire.AsSpan(0, bytes).ToArray()); + server.ProcessPacketAtTimestamp(in from, wire.AsSpan(0, bytes), 1001, 0.0); + bool[] allowed = new bool[PacketType.NumPackets]; + allowed[PacketType.ConnectionChallenge] = true; + byte[] challengeWire = (byte[])sent.Clone(); + ReadPacketResult challenge = PacketIO.ReadPacket(challengeWire, true, + token.ServerToClientKey, SessionProtocolId, 1001, false, + ReadOnlySpan.Empty, allowed, null); + if (challenge.Type != PacketType.ConnectionChallenge) + throw new InvalidOperationException("reentry challenge was not received"); + byte[] payload = challengeWire.AsSpan(challenge.DataOffset, challenge.DataLength).ToArray(); + bytes = PacketIO.WriteEncryptedPacket(wire, PacketType.ConnectionResponse, + payload, 1, token.ClientToServerKey, SessionProtocolId); + File.WriteAllBytes(Path.Combine(inputDir, $"{name}-response.bin"), wire.AsSpan(0, bytes).ToArray()); + server.ProcessPacketAtTimestamp(in from, wire.AsSpan(0, bytes), 1001, 0.1); + if (!server.ClientConnected(0)) throw new InvalidOperationException("reentry client was not admitted"); + } + if (name == "loopback") server.DisconnectLoopbackClient(0); + else if (name == "timeout") server.Update(16.2); + else server.DisconnectClient(0); + int connectedCount = server.NumConnectedClients; + int mappings = server.NumEncryptionMappings; + string slot = server.ClientConnected(0) ? "connected" : "clear"; + Console.WriteLine($"{name} callbacks {callbacks} saw {(int)seenReason} {seenId} {seenUser} connected {connectedCount} mappings {mappings} slot {slot}"); + if (name == "stop") + { + server.Start(1); + if (server.NumConnectedClients != 0 || server.ClientConnected(0)) + throw new InvalidOperationException("restart retained a disconnecting slot"); + } + } + return 0; + } + private static int DoTokenLifetimes() { NetcodeLog.Level = LogLevel.None; @@ -1007,6 +1089,61 @@ private static int DoClientAddress() return 0; } + private static int DoReplayFloor(string inputDir) + { + NetcodeLog.Level = LogLevel.None; + ulong[] sequences = { 0, 1, 255, 256, 257, ulong.MaxValue - 257, + ulong.MaxValue - 256, ulong.MaxValue - 255, ulong.MaxValue - 1, ulong.MaxValue }; + var replay = new ReplayProtection(); + for (int i = 0; i < sequences.Length; i++) + { + ulong sequence = sequences[i]; + Console.WriteLine($"before {sequence} -> {(replay.AlreadyReceived(sequence) ? 1 : 0)}"); + replay.AdvanceSequence(sequence); + Console.WriteLine($"after {sequence} -> {(replay.AlreadyReceived(sequence) ? 1 : 0)}"); + for (int j = 0; j < i; j++) + Console.WriteLine($"probe {sequences[j]} -> {(replay.AlreadyReceived(sequences[j]) ? 1 : 0)}"); + } + + byte[] key = new byte[Protocol.KeyBytes]; + FillPattern(key, 5); + byte[] payload = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0, 4), 10); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4, 4), 16); + byte[] wire = new byte[Defines.MaxPacketBytes]; + int bytes = PacketIO.WriteEncryptedPacket(wire, PacketType.ConnectionKeepAlive, + payload, ulong.MaxValue, key, SessionProtocolId); + File.WriteAllBytes(Path.Combine(inputDir, "keepalive-max.bin"), wire.AsSpan(0, bytes).ToArray()); + bool[] allowed = new bool[PacketType.NumPackets]; + allowed[PacketType.ConnectionKeepAlive] = true; + replay.Reset(); + replay.AdvanceSequence(ulong.MaxValue); + ReadPacketResult result = PacketIO.ReadPacket(wire.AsSpan(0, bytes), true, key, + SessionProtocolId, 1000, false, ReadOnlySpan.Empty, allowed, replay); + Console.WriteLine($"keepalive {ulong.MaxValue} -> {(result.Type < 0 ? "refused" : "accepted")}"); + + ConnectTokenData token = ScenarioToken(0x1111, 0x5A, 1); + byte[] privateToken = (byte[])token.PrivateData.Clone(); + if (!ConnectTokenPrivate.Decrypt(privateToken, token.VersionInfo, token.ProtocolId, + token.ExpireTimestamp, token.Nonce, SessionPrivateKey)) return 1; + ulong[] expiries = { 1029, 1030, 1031 }; + Array.Clear(allowed); + allowed[PacketType.ConnectionRequest] = true; + foreach (ulong expiry in expiries) + { + byte[] encrypted = (byte[])privateToken.Clone(); + if (!ConnectTokenPrivate.Encrypt(encrypted, token.VersionInfo, token.ProtocolId, + expiry, token.Nonce, SessionPrivateKey)) return 1; + bytes = PacketIO.WriteConnectionRequestPacket(wire, token.VersionInfo, + token.ProtocolId, expiry, token.Nonce, encrypted); + File.WriteAllBytes(Path.Combine(inputDir, $"request-{expiry}.bin"), wire.AsSpan(0, bytes).ToArray()); + result = PacketIO.ReadPacket(wire.AsSpan(0, bytes), true, ReadOnlySpan.Empty, + SessionProtocolId, 1000, true, SessionPrivateKey, allowed, null, 1030, 31); + Console.WriteLine($"request {expiry} -> {(result.Type == PacketType.ConnectionRequest ? "accepted" : "refused")}"); + } + return 0; + } + private static int Main(string[] args) { NetcodeLog.Level = LogLevel.Error; @@ -1021,9 +1158,11 @@ private static int Main(string[] args) ["diffpackets"] => DoDiffPackets(), ["drain"] => DoDrain(), ["nonces"] => DoNonces(), + ["disconnect-reentry", var inputDir] => DoDisconnectReentry(inputDir), ["challenge-mapping", var inputDir] => DoChallengeMapping(inputDir), ["token-lifetimes"] => DoTokenLifetimes(), ["client-address"] => DoClientAddress(), + ["replay-floor", var inputDir] => DoReplayFloor(inputDir), _ => Usage(), }; } diff --git a/compat/c/compat.c b/compat/c/compat.c index 5f35fb1..ebe8393 100644 --- a/compat/c/compat.c +++ b/compat/c/compat.c @@ -1202,6 +1202,170 @@ static int do_challenge_mapping( const char * input_dir ) return 1; } +struct reentry_case_t +{ + struct netcode_server_t * server; + const char * name; + int callbacks; + int reason; + uint64_t id; + uint8_t user; +}; + +static void reentry_callback( void * context, int slot, int connected ) +{ + struct reentry_case_t * state = (struct reentry_case_t * ) context; + if ( connected ) return; + state->callbacks++; + if ( state->callbacks != 1 ) return; + state->reason = netcode_server_client_disconnect_reason( state->server, slot ); + state->id = netcode_server_client_id( state->server, slot ); + state->user = ( (const uint8_t *) netcode_server_client_user_data( state->server, slot ) )[0]; + if ( strcmp( state->name, "same" ) == 0 ) netcode_server_disconnect_client( state->server, slot ); + else if ( strcmp( state->name, "loopback" ) == 0 ) netcode_server_disconnect_loopback_client( state->server, slot ); + else netcode_server_stop( state->server ); +} + +static void reentry_loopback_send( void * context, int slot, const uint8_t * data, int bytes, uint64_t sequence ) +{ + (void) context; (void) slot; (void) data; (void) bytes; (void) sequence; +} + +static int do_disconnect_reentry( const char * input_dir ) +{ + const char * names[] = { "same", "stop", "timeout", "loopback" }; + netcode_log_level( NETCODE_LOG_LEVEL_NONE ); + for ( int i = 0; i < 4; i++ ) + { + struct reentry_case_t state; + memset( &state, 0, sizeof( state ) ); + state.name = names[i]; + struct netcode_server_config_t config; + netcode_default_server_config( &config ); + config.protocol_id = SESSION_PROTOCOL_ID; + config.override_send_and_receive = 1; + config.send_packet_override = scenario_send; + config.receive_packet_override = scenario_receive; + config.send_loopback_packet_callback = reentry_loopback_send; + config.connect_disconnect_callback = reentry_callback; + config.callback_context = &state; + memcpy( config.private_key, session_private_key, NETCODE_KEY_BYTES ); + state.server = netcode_server_create( "127.0.0.1:40000", &config, 0.0 ); + if ( !state.server ) return 0; + netcode_server_start( state.server, 1 ); + if ( strcmp( state.name, "loopback" ) == 0 ) + { + uint8_t user[NETCODE_USER_DATA_BYTES]; + memset( user, 0, sizeof( user ) ); + user[0] = 0x5a; + netcode_server_connect_loopback_client( state.server, 0, 0x1111, user ); + } + else + { + fill_pattern( state.server->challenge_key, NETCODE_KEY_BYTES, 77 ); + state.server->min_connect_token_expire_timestamp = 1000; + struct netcode_address_t from; + netcode_parse_address( "127.0.0.1:50000", &from ); + struct netcode_connect_token_t token; + scenario_token( &token, 0x1111, 0x5a, 1 ); + char path[64]; + snprintf( path, sizeof( path ), "%s-request.bin", state.name ); + scenario_request( state.server, &from, &token, 0.0, input_dir, path ); + struct netcode_connection_challenge_packet_t challenge = scenario_challenge( &token ); + snprintf( path, sizeof( path ), "%s-response.bin", state.name ); + scenario_response( state.server, &from, &token, &challenge, 1, 0.1, input_dir, path ); + if ( !state.server->client_connected[0] ) return 0; + } + if ( strcmp( state.name, "loopback" ) == 0 ) netcode_server_disconnect_loopback_client( state.server, 0 ); + else if ( strcmp( state.name, "timeout" ) == 0 ) netcode_server_update( state.server, 16.2 ); + else netcode_server_disconnect_client( state.server, 0 ); + printf( "%s callbacks %d saw %d %" PRIu64 " %u connected %d mappings %d slot %s\n", + state.name, state.callbacks, state.reason, state.id, state.user, + state.server->num_connected_clients, state.server->encryption_manager.num_encryption_mappings, + state.server->client_connected[0] ? "connected" : "clear" ); + if ( strcmp( state.name, "stop" ) == 0 ) + { + netcode_server_start( state.server, 1 ); + if ( state.server->num_connected_clients != 0 || state.server->client_connected[0] ) return 0; + } + netcode_server_destroy( state.server ); + } + return 1; +} + +static int do_replay_floor( const char * input_dir ) +{ + netcode_log_level( NETCODE_LOG_LEVEL_NONE ); + uint64_t sequences[] = { 0, 1, 255, 256, 257, UINT64_MAX - 257, + UINT64_MAX - 256, UINT64_MAX - 255, UINT64_MAX - 1, UINT64_MAX }; + struct netcode_replay_protection_t replay; + netcode_replay_protection_reset( &replay ); + for ( int i = 0; i < 10; i++ ) + { + uint64_t sequence = sequences[i]; + printf( "before %" PRIu64 " -> %d\n", sequence, netcode_replay_protection_already_received( &replay, sequence ) ); + netcode_replay_protection_advance_sequence( &replay, sequence ); + printf( "after %" PRIu64 " -> %d\n", sequence, netcode_replay_protection_already_received( &replay, sequence ) ); + for ( int j = 0; j < i; j++ ) + printf( "probe %" PRIu64 " -> %d\n", sequences[j], netcode_replay_protection_already_received( &replay, sequences[j] ) ); + } + + uint8_t key[NETCODE_KEY_BYTES]; + fill_pattern( key, sizeof( key ), 5 ); + struct netcode_connection_keep_alive_packet_t keepalive; + memset( &keepalive, 0, sizeof( keepalive ) ); + keepalive.packet_type = NETCODE_CONNECTION_KEEP_ALIVE_PACKET; + keepalive.client_index = 10; + keepalive.max_clients = 16; + uint8_t wire[NETCODE_MAX_PACKET_BYTES]; + int bytes = netcode_write_packet( &keepalive, wire, sizeof( wire ), UINT64_MAX, key, SESSION_PROTOCOL_ID ); + if ( bytes <= 0 || !write_file( input_dir, "keepalive-max.bin", wire, bytes ) ) return 0; + uint8_t allowed[NETCODE_CONNECTION_NUM_PACKETS] = { 0 }; + allowed[NETCODE_CONNECTION_KEEP_ALIVE_PACKET] = 1; + netcode_replay_protection_reset( &replay ); + netcode_replay_protection_advance_sequence( &replay, UINT64_MAX ); + uint64_t packet_sequence; + void * packet = netcode_read_packet( wire, bytes, &packet_sequence, key, SESSION_PROTOCOL_ID, + 1000, 0, UINT64_MAX, NULL, allowed, &replay, NULL, NULL ); + printf( "keepalive %" PRIu64 " -> %s\n", UINT64_MAX, packet ? "accepted" : "refused" ); + if ( packet ) free( packet ); + + struct netcode_connect_token_t token; + scenario_token( &token, 0x1111, 0x5a, 1 ); + uint8_t private_token[NETCODE_CONNECT_TOKEN_PRIVATE_BYTES]; + memcpy( private_token, token.private_data, sizeof( private_token ) ); + if ( netcode_decrypt_connect_token_private( private_token, sizeof( private_token ), + token.version_info, token.protocol_id, token.expire_timestamp, + token.nonce, session_private_key ) != NETCODE_OK ) return 0; + uint64_t expiries[] = { 1029, 1030, 1031 }; + memset( allowed, 0, sizeof( allowed ) ); + allowed[NETCODE_CONNECTION_REQUEST_PACKET] = 1; + for ( int i = 0; i < 3; i++ ) + { + uint64_t expiry = expiries[i]; + struct netcode_connection_request_packet_t request; + memset( &request, 0, sizeof( request ) ); + request.packet_type = NETCODE_CONNECTION_REQUEST_PACKET; + memcpy( request.version_info, token.version_info, NETCODE_VERSION_INFO_BYTES ); + request.protocol_id = token.protocol_id; + request.connect_token_expire_timestamp = expiry; + memcpy( request.connect_token_nonce, token.nonce, NETCODE_CONNECT_TOKEN_NONCE_BYTES ); + memcpy( request.connect_token_data, private_token, sizeof( private_token ) ); + if ( netcode_encrypt_connect_token_private( request.connect_token_data, + NETCODE_CONNECT_TOKEN_PRIVATE_BYTES, request.version_info, request.protocol_id, + expiry, request.connect_token_nonce, session_private_key ) != NETCODE_OK ) return 0; + bytes = netcode_write_packet( &request, wire, sizeof( wire ), 0, key, SESSION_PROTOCOL_ID ); + char name[32]; + snprintf( name, sizeof( name ), "request-%" PRIu64 ".bin", expiry ); + if ( bytes <= 0 || !write_file( input_dir, name, wire, bytes ) ) return 0; + packet = netcode_read_packet( wire, bytes, &packet_sequence, NULL, SESSION_PROTOCOL_ID, + 1000, 1030, 31, session_private_key, allowed, NULL, NULL, NULL ); + printf( "request %" PRIu64 " -> %s\n", expiry, packet ? "accepted" : "refused" ); + if ( packet ) free( packet ); + } + return 1; +} + /* Global nonce differential (subcommand "nonces"). Two servers started from fresh, then one stopped and started again, plus a client that connects, @@ -1459,6 +1623,10 @@ int main( int argc, char ** argv ) ok = do_nonces(); else if ( argc == 3 && strcmp( argv[1], "challenge-mapping" ) == 0 ) ok = do_challenge_mapping( argv[2] ); + else if ( argc == 3 && strcmp( argv[1], "disconnect-reentry" ) == 0 ) + ok = do_disconnect_reentry( argv[2] ); + else if ( argc == 3 && strcmp( argv[1], "replay-floor" ) == 0 ) + ok = do_replay_floor( argv[2] ); else if ( argc == 2 && strcmp( argv[1], "token-lifetimes" ) == 0 ) ok = do_token_lifetimes(); else if ( argc == 2 && strcmp( argv[1], "client-address" ) == 0 ) diff --git a/scripts/interop.sh b/scripts/interop.sh index a257ae2..5858ced 100755 --- a/scripts/interop.sh +++ b/scripts/interop.sh @@ -159,4 +159,31 @@ if ! diff -u "$WORK/c-client-address.txt" "$WORK/cs-client-address.txt"; then fi echo " client-address differential matched" +echo "== leg 11: disconnect callback reentry differential (fixed packets, override transport)" +mkdir "$WORK/c-reentry" "$WORK/cs-reentry" +$CS disconnect-reentry "$WORK/cs-reentry" > "$WORK/cs-reentry.txt" +"$WORK/compat-c" disconnect-reentry "$WORK/c-reentry" > "$WORK/c-reentry.txt" +for case_name in same stop timeout; do + cmp "$WORK/c-reentry/$case_name-request.bin" "$WORK/cs-reentry/$case_name-request.bin" + cmp "$WORK/c-reentry/$case_name-response.bin" "$WORK/cs-reentry/$case_name-response.bin" +done +if ! diff -u "$WORK/c-reentry.txt" "$WORK/cs-reentry.txt"; then + echo " disconnect callback reentry differential differed; first differing lines above" + exit 1 +fi +echo " disconnect callback reentry differential matched" + +echo "== leg 12: replay sentinel and restart floor differential (fixed packets)" +mkdir "$WORK/c-replay" "$WORK/cs-replay" +$CS replay-floor "$WORK/cs-replay" > "$WORK/cs-replay.txt" +"$WORK/compat-c" replay-floor "$WORK/c-replay" > "$WORK/c-replay.txt" +for f in keepalive-max.bin request-1029.bin request-1030.bin request-1031.bin; do + cmp "$WORK/c-replay/$f" "$WORK/cs-replay/$f" +done +if ! diff -u "$WORK/c-replay.txt" "$WORK/cs-replay.txt"; then + echo " replay/floor differential differed; first differing lines above" + exit 1 +fi +echo " replay/floor differential matched" + echo "INTEROP GATE PASSED" diff --git a/src/Netcode.cs b/src/Netcode.cs index 75afbe2..c132f67 100644 --- a/src/Netcode.cs +++ b/src/Netcode.cs @@ -1275,6 +1275,10 @@ public void Reset() public bool AlreadyReceived(ulong sequence) { + // The top sequence marks an empty slot, so it is always already received. + if (sequence == ulong.MaxValue) + return true; + // written so it cannot overflow: "sequence + BUFFER_SIZE <= most_recent" // wraps for sequence values near UINT64_MAX and falsely rejects them diff --git a/src/Server.cs b/src/Server.cs index 97663d4..1a5ac6b 100644 --- a/src/Server.cs +++ b/src/Server.cs @@ -207,6 +207,7 @@ private static bool[] BuildAllowedPackets() private readonly bool[] _clientLoopback = new bool[Protocol.MaxClients]; private readonly bool[] _clientConfirmed = new bool[Protocol.MaxClients]; private readonly DisconnectReason[] _clientDisconnectReason = new DisconnectReason[Protocol.MaxClients]; + private readonly bool[] _clientDisconnecting = new bool[Protocol.MaxClients]; private readonly int[] _clientEncryptionIndex = new int[Protocol.MaxClients]; private readonly ulong[] _clientId = new ulong[Protocol.MaxClients]; private readonly ulong[] _clientSequence = new ulong[Protocol.MaxClients]; @@ -428,10 +429,20 @@ public void Stop() // loopback clients are not disconnected above, but they must not survive a server stop for (int i = 0; i < _maxClients; i++) { - if (_clientConnected[i] && _clientLoopback[i]) + if (_clientConnected[i] && _clientLoopback[i] && !_clientDisconnecting[i]) DisconnectLoopbackClient(i); } + // A callback that called Stop already ran for its slot. Clear it without calling back again. + for (int i = 0; i < _maxClients; i++) + { + if (_clientConnected[i]) + { + _clientReplayProtection[i].Reset(); + ResetClientSlot(i); + } + } + _running = false; _maxClients = 0; _numConnectedClients = 0; @@ -559,6 +570,7 @@ private void ResetClientSlot(int clientIndex) _clientPacketQueue[clientIndex].Clear(); _clientConnected[clientIndex] = false; + _clientDisconnecting[clientIndex] = false; _clientLoopback[clientIndex] = false; _clientConfirmed[clientIndex] = false; _clientId[clientIndex] = 0; @@ -579,8 +591,13 @@ private void DisconnectClientInternal(int clientIndex, bool sendDisconnectPacket // record why before the callback fires, so the reason can be queried from inside it _clientDisconnectReason[clientIndex] = disconnectReason; + _clientDisconnecting[clientIndex] = true; + _config.OnClientConnectDisconnect?.Invoke(clientIndex, false); + if (!_running || !_clientConnected[clientIndex]) + return; + if (sendDisconnectPackets) { NetcodeLog.Debug($"server sent disconnect packets to client {clientIndex}\n"); @@ -596,7 +613,8 @@ private void DisconnectClientInternal(int clientIndex, bool sendDisconnectPacket _clientReplayProtection[clientIndex].Reset(); - _encryptionManager.ClientIndex[_clientEncryptionIndex[clientIndex]] = -1; + if (_clientEncryptionIndex[clientIndex] >= 0) + _encryptionManager.ClientIndex[_clientEncryptionIndex[clientIndex]] = -1; _encryptionManager.RemoveEncryptionMapping(in _clientAddress[clientIndex], _time); @@ -618,6 +636,9 @@ public void DisconnectClient(int clientIndex) if (_clientLoopback[clientIndex]) return; + if (_clientDisconnecting[clientIndex]) + return; + DisconnectClientInternal(clientIndex, sendDisconnectPackets: true, DisconnectReason.ServerDisconnect); } @@ -629,7 +650,7 @@ public void DisconnectAllClients() for (int i = 0; i < _maxClients; i++) { - if (_clientConnected[i] && !_clientLoopback[i]) + if (_clientConnected[i] && !_clientLoopback[i] && !_clientDisconnecting[i]) DisconnectClientInternal(i, sendDisconnectPackets: true, DisconnectReason.ServerDisconnect); } } @@ -1263,12 +1284,20 @@ public void DisconnectLoopbackClient(int clientIndex) if (!_clientConnected[clientIndex] || !_clientLoopback[clientIndex]) return; + if (_clientDisconnecting[clientIndex]) + return; + NetcodeLog.Info($"server disconnected loopback client {clientIndex}\n"); _clientDisconnectReason[clientIndex] = DisconnectReason.ServerDisconnect; + _clientDisconnecting[clientIndex] = true; + _config.OnClientConnectDisconnect?.Invoke(clientIndex, false); + if (!_running || !_clientConnected[clientIndex]) + return; + ResetClientSlot(clientIndex); } diff --git a/tests/ClientServer.cs b/tests/ClientServer.cs index 586d57f..3560ad9 100644 --- a/tests/ClientServer.cs +++ b/tests/ClientServer.cs @@ -1273,6 +1273,67 @@ public static void TestServerClientDisconnectReason() Check.That(reasonInCallback == DisconnectReason.TimedOut); } + public static void TestServerDisconnectCallbackReentryIsHarmless() + { + NetworkSimulator simulator = new NetworkSimulator(); + double time = 0.0; + int callbacks = 0; + DisconnectReason seenReason = DisconnectReason.None; + ulong seenId = 0; + byte seenUser = 0; + var config = CreateServerConfig(simulator); + Server? serverRef = null; + Action? onDisconnect = null; + config.SendLoopbackPacket = (int slot, ReadOnlySpan data, ulong sequence) => { }; + config.OnClientConnectDisconnect = (slot, connected) => + { + if (connected) return; + callbacks++; + seenReason = serverRef!.ClientDisconnectReason(slot); + seenId = serverRef.ClientId(slot); + seenUser = serverRef.ClientUserData(slot)[0]; + // Cap recursion in the red test so the defect fails as a normal assertion/exception. + if (callbacks == 1) onDisconnect?.Invoke(slot); + }; + + using var client = new Client("[::]:50000", new ClientConfig { Simulator = simulator }, time); + using var server = new Server("[::1]:40000", config, time); + serverRef = server; + StartServerForTests(server, 1); + client.Connect(GenerateConnectToken("[::1]:40000", 0xA1)); + ConnectLoop(simulator, client, server, ref time, 0.1); + Check.That(client.State == ClientState.Connected); + byte expectedUser = server.ClientUserData(0)[0]; + + onDisconnect = slot => server.DisconnectClient(slot); + server.DisconnectClient(0); + Check.That(callbacks == 1); + Check.That(seenReason == DisconnectReason.ServerDisconnect); + Check.That(seenId == 0xA1 && seenUser == expectedUser); + Check.That(server.NumConnectedClients == 0 && !server.ClientConnected(0)); + + client.Disconnect(); + client.Connect(GenerateConnectToken("[::1]:40000", 0xA2)); + ConnectLoop(simulator, client, server, ref time, 0.1); + Check.That(client.State == ClientState.Connected); + callbacks = 0; + onDisconnect = _ => server.Stop(); + server.DisconnectClient(0); + Check.That(callbacks == 1 && !server.Running); + StartServerForTests(server, 1); + Check.That(server.NumConnectedClients == 0 && !server.ClientConnected(0)); + + byte[] loopbackUser = new byte[Protocol.UserDataBytes]; + loopbackUser[0] = 0x5A; + server.ConnectLoopbackClient(0, 0xA3, loopbackUser); + callbacks = 0; + onDisconnect = slot => server.DisconnectLoopbackClient(slot); + server.DisconnectLoopbackClient(0); + Check.That(callbacks == 1 && seenReason == DisconnectReason.ServerDisconnect); + Check.That(seenId == 0xA3 && seenUser == 0x5A); + Check.That(server.NumConnectedClients == 0 && !server.ClientConnected(0)); + } + public static void TestClientReconnect() { NetworkSimulator simulator = CreateSimulator(); diff --git a/tests/Tests.cs b/tests/Tests.cs index c1277cf..9fc75fa 100644 --- a/tests/Tests.cs +++ b/tests/Tests.cs @@ -150,6 +150,7 @@ private static int Main(string[] args) RunTest("test_client_side_disconnect", ClientServerTests.TestClientSideDisconnect); RunTest("test_server_side_disconnect", ClientServerTests.TestServerSideDisconnect); RunTest("test_server_client_disconnect_reason", ClientServerTests.TestServerClientDisconnectReason); + RunTest("test_server_disconnect_callback_reentry_is_harmless", ClientServerTests.TestServerDisconnectCallbackReentryIsHarmless); RunTest("test_receive_drain_skips_empty_and_stops_at_cap", ClientServerTests.TestReceiveDrainSkipsEmptyAndStopsAtCap); RunTest("test_client_reconnect", ClientServerTests.TestClientReconnect); RunTest("test_disable_timeout", ClientServerTests.TestDisableTimeout); @@ -866,6 +867,16 @@ public static void TestConnectionKeepAlivePacket() Check.That(result.Type == PacketType.ConnectionKeepAlive); Check.That(result.DataLength == 8); Check.That(buffer.AsSpan(result.DataOffset, 8).SequenceEqual(packetData)); + + // UINT64_MAX is the empty replay-slot marker, so it is never admitted. + var replay = new ReplayProtection(); + replay.AdvanceSequence(ulong.MaxValue); + bytesWritten = PacketIO.WriteEncryptedPacket(buffer, PacketType.ConnectionKeepAlive, + packetData, ulong.MaxValue, packetKey, TestConstants.TestProtocolId); + result = PacketIO.ReadPacket(buffer.AsSpan(0, bytesWritten), true, packetKey, + TestConstants.TestProtocolId, 1000, false, ReadOnlySpan.Empty, + AllAllowed, replay); + Check.That(result.Type == -1); } public static void TestConnectionPayloadPacket() @@ -1151,6 +1162,11 @@ public static void TestReplayProtection() // while packets that fell out of the window are rejected as before Check.That(replayProtection.AlreadyReceived(ulong.MaxValue - 1 - Defines.ReplayProtectionBufferSize)); + + // The maximum sequence is the empty-slot sentinel and must always be rejected. + replayProtection.Reset(); + replayProtection.AdvanceSequence(ulong.MaxValue); + Check.That(replayProtection.AlreadyReceived(ulong.MaxValue)); } public static void TestNetworkSimulatorDeterminism()