diff --git a/compat/Compat.cs b/compat/Compat.cs index df53b17..df48041 100644 --- a/compat/Compat.cs +++ b/compat/Compat.cs @@ -873,6 +873,140 @@ private static int DoNonces() return 0; } + private static int DoTokenLifetimes() + { + NetcodeLog.Level = LogLevel.None; + byte[] sent = Array.Empty(); + 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; + using var server = new Server("127.0.0.1:40000", config, 0.0); + server.Start(4); + server.SetConnectTokenExpiryFloorForTest(1000); + Address x = Address.Parse("127.0.0.1:50000"); + + ConnectTokenData Token(int timeout) + { + ConnectTokenData token = ScenarioToken(0x1111, 0xA1, 1); + if (!ConnectTokenPrivate.Decrypt(token.PrivateData, token.VersionInfo, token.ProtocolId, + token.ExpireTimestamp, token.Nonce, SessionPrivateKey)) throw new InvalidOperationException(); + var privateToken = new ConnectTokenPrivate(); + if (!privateToken.Read(token.PrivateData)) throw new InvalidOperationException(); + privateToken.TimeoutSeconds = timeout; + privateToken.Write(token.PrivateData); + if (!ConnectTokenPrivate.Encrypt(token.PrivateData, token.VersionInfo, token.ProtocolId, + token.ExpireTimestamp, token.Nonce, SessionPrivateKey)) throw new InvalidOperationException(); + token.TimeoutSeconds = timeout; + return token; + } + + void Request(in Address from, ConnectTokenData token, double logicalTime) + { + byte[] wire = new byte[Defines.MaxPacketBytes]; + int bytes = PacketIO.WriteConnectionRequestPacket(wire, token.VersionInfo, token.ProtocolId, + token.ExpireTimestamp, token.Nonce, token.PrivateData); + server.ProcessPacketAtTimestamp(in from, wire.AsSpan(0, bytes), 1001, logicalTime); + } + + foreach (int timeout in new[] { -1, 0, 1, 5, 30 }) + { + ConnectTokenData token = Token(timeout); + Request(in x, token, 0.0); + int offset = -1; + for (int t = 0; t <= 40; t++) + { + server.Update(t); + if (server.NumEncryptionMappings == 0) { offset = t; break; } + } + Console.WriteLine($"timeout {timeout} -> {(offset < 0 ? "never" : offset.ToString())}"); + server.Stop(); + server.Start(4); + server.SetConnectTokenExpiryFloorForTest(1000); + + Request(in x, token, 0.0); + byte[] challenge = (byte[])sent.Clone(); + bool[] allowed = new bool[PacketType.NumPackets]; + allowed[PacketType.ConnectionChallenge] = true; + ReadPacketResult packet = PacketIO.ReadPacket(challenge, true, token.ServerToClientKey, + SessionProtocolId, 1001, false, ReadOnlySpan.Empty, allowed, null); + if (packet.Type != PacketType.ConnectionChallenge) return 1; + byte[] responseData = challenge.AsSpan(packet.DataOffset, packet.DataLength).ToArray(); + byte[] response = new byte[Defines.MaxPacketBytes]; + int responseBytes = PacketIO.WriteEncryptedPacket(response, PacketType.ConnectionResponse, + responseData, 1, token.ClientToServerKey, SessionProtocolId); + server.Update(0.1); + server.ProcessPacketAtTimestamp(in x, response.AsSpan(0, responseBytes), 1001, 0.1); + Console.WriteLine($"response {timeout} -> {(server.NumConnectedClients == 1 ? "connects" : "refused")}"); + server.Stop(); + server.Start(4); + server.SetConnectTokenExpiryFloorForTest(1000); + } + + ulong[] expiries = { ulong.MaxValue, 1060, 1031, 1030, 1030, 1001, 1000 }; + ulong[] floors = { 1000, 1000, 1000, 1000, 1030, 1000, 1000 }; + string[] expiryNames = { "max", "plus60", "plus31", "plus30", "floor", "plus1", "expired" }; + ConnectTokenData expiryToken = Token(15); + for (int i = 0; i < expiries.Length; i++) + { + byte[] privateData = (byte[])expiryToken.PrivateData.Clone(); + if (!ConnectTokenPrivate.Decrypt(privateData, expiryToken.VersionInfo, expiryToken.ProtocolId, + expiryToken.ExpireTimestamp, expiryToken.Nonce, SessionPrivateKey)) return 1; + if (!ConnectTokenPrivate.Encrypt(privateData, expiryToken.VersionInfo, expiryToken.ProtocolId, + expiries[i], expiryToken.Nonce, SessionPrivateKey)) return 1; + byte[] wire = new byte[Defines.MaxPacketBytes]; + int bytes = PacketIO.WriteConnectionRequestPacket(wire, expiryToken.VersionInfo, + expiryToken.ProtocolId, expiries[i], expiryToken.Nonce, privateData); + bool[] allowed = new bool[PacketType.NumPackets]; + allowed[PacketType.ConnectionRequest] = true; + ReadPacketResult packet = PacketIO.ReadPacket(wire.AsSpan(0, bytes), true, + ReadOnlySpan.Empty, SessionProtocolId, 1000, true, + SessionPrivateKey, allowed, null, floors[i], 30); + Console.WriteLine($"expiry {expiryNames[i]} -> {(packet.Type == PacketType.ConnectionRequest ? "accepted" : "refused")}"); + } + + return 0; + } + + private static int DoClientAddress() + { + NetcodeLog.Level = LogLevel.None; + var config = new ClientConfig + { + OverrideSendAndReceive = true, + SendPacketOverride = (in Address to, ReadOnlySpan data) => { }, + ReceivePacketOverride = (ref Address from, Span data) => 0, + }; + using var client = new Client("0.0.0.0:50000", config, 0.0); + client.SocketAvailabilityOverride = type => Client.CanReach(type, true, false, false); + + (string name, string[] addresses)[] cases = + { + ("v6", new[] { "[::1]:40000" }), + ("v6v4", new[] { "[::1]:40000", "127.0.0.1:40000" }), + ("v4", new[] { "127.0.0.1:40000" }), + ("v4v6", new[] { "127.0.0.1:40000", "[::1]:40000" }), + }; + for (int i = 0; i < cases.Length; i++) + { + (string name, string[] addresses) = cases[i]; + ConnectTokenData connectToken = ScenarioToken(0x1111, 0xA1, i + 1); + connectToken.NumServerAddresses = addresses.Length; + for (int j = 0; j < addresses.Length; j++) + connectToken.ServerAddresses[j] = Address.Parse(addresses[j]); + byte[] token = new byte[Protocol.ConnectTokenBytes]; + connectToken.Write(token); + client.Connect(token); + if (name == "v4v6" && !client.ConnectToNextServer()) return 1; + int index = client.State == ClientState.SendingConnectionRequest + ? client.ServerAddressIndex + : -1; + Console.WriteLine($"{name} state {Client.StateName(client.State)} address {(index < 0 ? "none" : index.ToString())}"); + } + + return 0; + } + private static int Main(string[] args) { NetcodeLog.Level = LogLevel.Error; @@ -888,6 +1022,8 @@ private static int Main(string[] args) ["drain"] => DoDrain(), ["nonces"] => DoNonces(), ["challenge-mapping", var inputDir] => DoChallengeMapping(inputDir), + ["token-lifetimes"] => DoTokenLifetimes(), + ["client-address"] => DoClientAddress(), _ => Usage(), }; } diff --git a/compat/c/compat.c b/compat/c/compat.c index 8666144..5f35fb1 100644 --- a/compat/c/compat.c +++ b/compat/c/compat.c @@ -1052,7 +1052,7 @@ static void scenario_process( struct netcode_server_t * server, struct netcode_a { uint8_t wire[NETCODE_MAX_PACKET_BYTES]; int bytes = netcode_write_packet( packet, wire, sizeof( wire ), sequence, key, SESSION_PROTOCOL_ID ); - if ( !write_file( input_dir, name, wire, bytes ) ) abort(); + if ( input_dir && !write_file( input_dir, name, wire, bytes ) ) abort(); uint8_t allowed[NETCODE_CONNECTION_NUM_PACKETS] = { 0 }; allowed[NETCODE_CONNECTION_REQUEST_PACKET] = 1; allowed[NETCODE_CONNECTION_RESPONSE_PACKET] = 1; @@ -1289,6 +1289,146 @@ static int do_nonces( void ) return 1; } +static int do_token_lifetimes( void ) +{ + netcode_log_level( NETCODE_LOG_LEVEL_NONE ); + 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; + memcpy( config.private_key, session_private_key, NETCODE_KEY_BYTES ); + struct netcode_server_t * server = netcode_server_create( "127.0.0.1:40000", &config, 0.0 ); + if ( !server ) return 0; + netcode_server_start( server, 4 ); + server->min_connect_token_expire_timestamp = 1000; + struct netcode_address_t x; + netcode_parse_address( "127.0.0.1:50000", &x ); + + int timeouts[] = { -1, 0, 1, 5, 30 }; + for ( int i = 0; i < 5; i++ ) + { + struct netcode_connect_token_t token; + scenario_token( &token, 0x1111, 0xa1, 1 ); + struct netcode_connect_token_private_t private_token; + if ( netcode_decrypt_connect_token_private( token.private_data, NETCODE_CONNECT_TOKEN_PRIVATE_BYTES, + token.version_info, token.protocol_id, token.expire_timestamp, token.nonce, session_private_key ) != NETCODE_OK ) return 0; + if ( netcode_read_connect_token_private( token.private_data, NETCODE_CONNECT_TOKEN_PRIVATE_BYTES, &private_token ) != NETCODE_OK ) return 0; + private_token.timeout_seconds = timeouts[i]; + netcode_write_connect_token_private( &private_token, token.private_data, NETCODE_CONNECT_TOKEN_PRIVATE_BYTES ); + if ( netcode_encrypt_connect_token_private( token.private_data, NETCODE_CONNECT_TOKEN_PRIVATE_BYTES, + token.version_info, token.protocol_id, token.expire_timestamp, token.nonce, session_private_key ) != NETCODE_OK ) return 0; + token.timeout_seconds = timeouts[i]; + scenario_request( server, &x, &token, 0.0, NULL, NULL ); + int offset = -1; + for ( int t = 0; t <= 40; t++ ) + { + netcode_server_update( server, (double) t ); + int active = 0; + for ( int j = 0; j < server->encryption_manager.num_encryption_mappings; j++ ) + if ( server->encryption_manager.address[j].type != NETCODE_ADDRESS_NONE && + !netcode_encryption_manager_entry_expired( &server->encryption_manager, j, (double) t ) ) + active++; + if ( active == 0 ) { offset = t; break; } + } + if ( offset < 0 ) printf( "timeout %d -> never\n", timeouts[i] ); + else printf( "timeout %d -> %d\n", timeouts[i], offset ); + netcode_server_stop( server ); + netcode_server_start( server, 4 ); + server->min_connect_token_expire_timestamp = 1000; + + scenario_request( server, &x, &token, 0.0, NULL, NULL ); + struct netcode_connection_challenge_packet_t challenge = scenario_challenge( &token ); + netcode_server_update( server, 0.1 ); + scenario_response( server, &x, &token, &challenge, 1, 0.1, NULL, NULL ); + printf( "response %d -> %s\n", timeouts[i], server->num_connected_clients == 1 ? "connects" : "refused" ); + netcode_server_stop( server ); + netcode_server_start( server, 4 ); + server->min_connect_token_expire_timestamp = 1000; + } + + const uint64_t expiries[] = { UINT64_MAX, 1060, 1031, 1030, 1030, 1001, 1000 }; + const uint64_t floors[] = { 1000, 1000, 1000, 1000, 1030, 1000, 1000 }; + const char * expiry_names[] = { "max", "plus60", "plus31", "plus30", "floor", "plus1", "expired" }; + for ( int i = 0; i < 7; i++ ) + { + struct netcode_connect_token_t token; + scenario_token( &token, 0x1111, 0xa1, 1 ); + if ( netcode_decrypt_connect_token_private( token.private_data, NETCODE_CONNECT_TOKEN_PRIVATE_BYTES, + token.version_info, token.protocol_id, token.expire_timestamp, token.nonce, session_private_key ) != NETCODE_OK ) return 0; + token.expire_timestamp = expiries[i]; + if ( netcode_encrypt_connect_token_private( token.private_data, NETCODE_CONNECT_TOKEN_PRIVATE_BYTES, + token.version_info, token.protocol_id, token.expire_timestamp, token.nonce, session_private_key ) != NETCODE_OK ) return 0; + 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 = token.expire_timestamp; + memcpy( request.connect_token_nonce, token.nonce, NETCODE_CONNECT_TOKEN_NONCE_BYTES ); + memcpy( request.connect_token_data, token.private_data, NETCODE_CONNECT_TOKEN_PRIVATE_BYTES ); + uint8_t wire[NETCODE_MAX_PACKET_BYTES]; + int bytes = netcode_write_packet( &request, wire, sizeof( wire ), 0, token.client_to_server_key, SESSION_PROTOCOL_ID ); + uint64_t sequence = 0; + uint8_t allowed[NETCODE_CONNECTION_NUM_PACKETS] = { 0 }; + allowed[NETCODE_CONNECTION_REQUEST_PACKET] = 1; + void * packet = netcode_read_packet( wire, bytes, &sequence, NULL, SESSION_PROTOCOL_ID, + 1000, floors[i], 30, session_private_key, allowed, NULL, NULL, NULL ); + printf( "expiry %s -> %s\n", expiry_names[i], packet ? "accepted" : "refused" ); + free( packet ); + } + netcode_server_destroy( server ); + return 1; +} + +static int do_client_address( void ) +{ + netcode_log_level( NETCODE_LOG_LEVEL_NONE ); + struct netcode_client_config_t config; + netcode_default_client_config( &config ); + config.override_send_and_receive = 1; + config.send_packet_override = scenario_send; + config.receive_packet_override = scenario_receive; + struct netcode_client_t * client = netcode_client_create( "0.0.0.0:50000", &config, 0.0 ); + if ( !client ) return 0; + // The nonzero handle is only an in-memory family marker. Override transport + // remains on outside the two direct address-selection calls. + client->socket_holder.ipv4.handle = 1; + + const char * lists[4][2] = { { "[::1]:40000", NULL }, { "[::1]:40000", "127.0.0.1:40000" }, + { "127.0.0.1:40000", NULL }, { "127.0.0.1:40000", "[::1]:40000" } }; + const char * names[4] = { "v6", "v6v4", "v4", "v4v6" }; + for ( int i = 0; i < 4; i++ ) + { + uint8_t connect_token[NETCODE_CONNECT_TOKEN_BYTES]; + struct netcode_connect_token_t token; + scenario_token( &token, 0x1111, 0xa1, (uint8_t) ( i + 1 ) ); + int num = lists[i][1] ? 2 : 1; + token.num_server_addresses = num; + for ( int j = 0; j < num; j++ ) + if ( netcode_parse_address( lists[i][j], &token.server_addresses[j] ) != NETCODE_OK ) return 0; + netcode_write_connect_token( &token, connect_token, sizeof( connect_token ) ); + netcode_client_disconnect( client ); + client->config.override_send_and_receive = 0; + netcode_client_connect( client, connect_token ); + client->config.override_send_and_receive = 1; + if ( i == 3 ) + { + client->config.override_send_and_receive = 0; + if ( !netcode_client_connect_to_next_server( client ) ) return 0; + client->config.override_send_and_receive = 1; + } + int state = netcode_client_state( client ); + printf( "%s state %s address %s\n", names[i], netcode_client_state_name( state ), + state == NETCODE_CLIENT_STATE_SENDING_CONNECTION_REQUEST ? + ( client->server_address_index == 1 ? "1" : "0" ) : "none" ); + } + client->socket_holder.ipv4.handle = 0; + netcode_client_destroy( client ); + return 1; +} + int main( int argc, char ** argv ) { if ( netcode_init() != NETCODE_OK ) @@ -1319,6 +1459,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 == 2 && strcmp( argv[1], "token-lifetimes" ) == 0 ) + ok = do_token_lifetimes(); + else if ( argc == 2 && strcmp( argv[1], "client-address" ) == 0 ) + ok = do_client_address(); else printf( "usage: compat-c goldens | verify | mint
| server | client | diffpackets | drain | nonces\n" ); diff --git a/scripts/interop.sh b/scripts/interop.sh index d1cbf2e..a257ae2 100755 --- a/scripts/interop.sh +++ b/scripts/interop.sh @@ -4,7 +4,7 @@ # scripts/interop.sh [path-to-netcode-clone] # # Builds the C half of the compat harness against the REAL netcode.c (plus its -# vendored libsodium), then runs four legs: +# vendored libsodium), then runs ten legs: # # 1. golden byte identity — both sides write the same token/packet goldens from # the same fixed keys/nonces/timestamps; every file must be byte identical. @@ -40,6 +40,12 @@ if ! git -C "$NETCODE" merge-base --is-ancestor 1c58478d HEAD; then exit 1 fi +if ! git -C "$NETCODE" merge-base --is-ancestor 067b1959 HEAD || + ! git -C "$NETCODE" merge-base --is-ancestor 39f04965 HEAD; then + echo "INTEROP FAILED: C reference must contain both token-lifetime and socket-family fixes." + exit 1 +fi + WORK="$(mktemp -d)" SERVER_PID="" @@ -135,4 +141,22 @@ if ! diff -u "$WORK/c-nonces.txt" "$WORK/cs-nonces.txt"; then fi echo " global nonce differential matched" +echo "== leg 9: token-lifetimes differential (preconnect mapping lifetime)" +$CS token-lifetimes > "$WORK/cs-lifetimes.txt" +"$WORK/compat-c" token-lifetimes > "$WORK/c-lifetimes.txt" +if ! diff -u "$WORK/c-lifetimes.txt" "$WORK/cs-lifetimes.txt"; then + echo " token-lifetimes differential differed; first differing lines above" + exit 1 +fi +echo " token-lifetimes differential matched" + +echo "== leg 10: client-address differential (socket-family refusal)" +$CS client-address > "$WORK/cs-client-address.txt" +"$WORK/compat-c" client-address > "$WORK/c-client-address.txt" +if ! diff -u "$WORK/c-client-address.txt" "$WORK/cs-client-address.txt"; then + echo " client-address differential differed; first differing lines above" + exit 1 +fi +echo " client-address differential matched" + echo "INTEROP GATE PASSED" diff --git a/src/Client.cs b/src/Client.cs index 54a6e2d..3abb946 100644 --- a/src/Client.cs +++ b/src/Client.cs @@ -251,6 +251,8 @@ public Client(string bindAddress1, string? bindAddress2, ClientConfig? config, d public ulong NextPacketSequence => _sequence; /// The server address the client is connecting or connected to. public Address ServerAddress => _serverAddress; + internal int ServerAddressIndex => _serverAddressIndex; + internal Func? SocketAvailabilityOverride { get; set; } /// The port the client socket is bound to. public ushort Port => @@ -333,6 +335,32 @@ private void ClearConnectToken() Array.Clear(_connectToken.ServerAddresses); } + // a client bound for one address family has no socket for the other; a + // simulator or override client has no sockets at all and reaches every address + internal static bool CanReach(AddressType addressType, bool hasIpv4Socket, bool hasIpv6Socket, bool socketless) + { + if (socketless) + return true; + return addressType == AddressType.IPv4 ? hasIpv4Socket : + addressType == AddressType.IPv6 ? hasIpv6Socket : false; + } + + private bool HasSocketFor(AddressType addressType) + { + if (SocketAvailabilityOverride != null) + return SocketAvailabilityOverride(addressType); + return CanReach(addressType, _socketIpv4 != null, _socketIpv6 != null, + _config.Simulator != null || _config.OverrideSendAndReceive); + } + + private int FirstReachableServerAddressIndex() + { + for (int i = 0; i < _connectToken.NumServerAddresses; i++) + if (HasSocketFor(_connectToken.ServerAddresses[i].Type)) + return i; + return -1; + } + /// /// Begin connecting with a 2048-byte connect token obtained from the backend. /// On an invalid token the client transitions to InvalidConnectToken (no throw). @@ -362,8 +390,16 @@ public void Connect(ReadOnlySpan connectToken) connectTokenMac.CopyTo(_previousConnectTokenMac); _hasPreviousConnectTokenMac = true; - _serverAddressIndex = 0; - _serverAddress = _connectToken.ServerAddresses[0]; + int reachableIndex = FirstReachableServerAddressIndex(); + if (reachableIndex < 0) + { + NetcodeLog.Error("error: client connect refused. no server address in the connect token has a socket\n"); + SetState(ClientState.InvalidConnectToken); + return; + } + + _serverAddressIndex = reachableIndex; + _serverAddress = _connectToken.ServerAddresses[reachableIndex]; if (NetcodeLog.Enabled(LogLevel.Info)) { @@ -646,7 +682,7 @@ private void SendPackets() } } - private bool ConnectToNextServer() + internal bool ConnectToNextServer() { if (_serverAddressIndex + 1 >= _connectToken.NumServerAddresses) { @@ -654,8 +690,19 @@ private bool ConnectToNextServer() return false; } - _serverAddressIndex++; - _serverAddress = _connectToken.ServerAddresses[_serverAddressIndex]; + int index = _serverAddressIndex + 1; + while (index < _connectToken.NumServerAddresses && !HasSocketFor(_connectToken.ServerAddresses[index].Type)) + index++; + + if (index >= _connectToken.NumServerAddresses) + { + NetcodeLog.Error("error: client connect refused. no server address in the connect token can be reached\n"); + SetState(ClientState.InvalidConnectToken); + return true; + } + + _serverAddressIndex = index; + _serverAddress = _connectToken.ServerAddresses[index]; ResetBeforeNextConnect(); diff --git a/src/Netcode.cs b/src/Netcode.cs index a662309..75afbe2 100644 --- a/src/Netcode.cs +++ b/src/Netcode.cs @@ -71,6 +71,7 @@ internal static class Defines public const int SocketRcvbufSize = 4 * 1024 * 1024; public const double PacketSendRate = 10.0; public const int NumDisconnectPackets = 10; + public const int PreconnectMappingSeconds = 10; // "NETCODE 1.02" ASCII with null terminator — 13 bytes public static ReadOnlySpan VersionInfo => "NETCODE 1.02\0"u8; @@ -1012,7 +1013,8 @@ public static ReadPacketResult ReadPacket( ReadOnlySpan privateKey, ReadOnlySpan allowedPackets, ReplayProtection? replayProtection, - ulong minConnectTokenExpireTimestamp = 0) + ulong minConnectTokenExpireTimestamp = 0, + ulong maxConnectTokenLifetime = 0) { ReadPacketResult result = default; result.Type = -1; @@ -1066,12 +1068,18 @@ public static ReadPacketResult ReadPacket( return result; } - if (packetExpireTimestamp < minConnectTokenExpireTimestamp) + if (packetExpireTimestamp <= minConnectTokenExpireTimestamp) { NetcodeLog.Debug("ignored connection request packet. connect token predates the server start\n"); return result; } + if (maxConnectTokenLifetime > 0 && packetExpireTimestamp - currentTimestamp > maxConnectTokenLifetime) + { + NetcodeLog.Debug("ignored connection request packet. connect token lifetime exceeds the maximum\n"); + return result; + } + ReadOnlySpan packetNonce = buffer.Slice(1 + Defines.VersionInfoBytes + 8 + 8, Defines.ConnectTokenNonceBytes); int tokenOffset = 1 + Defines.VersionInfoBytes + 8 + 8 + Defines.ConnectTokenNonceBytes; @@ -1682,6 +1690,20 @@ public int GetTimeout(int index) return 0; return Timeout[index]; } + + public int CountActive(double time) + { + int count = 0; + for (int i = 0; i < NumEncryptionMappings; i++) + if (Address[i].Type != AddressType.None && !EntryExpired(i, time)) + count++; + return count; + } + + public static double PreconnectExpireTime(double time, int timeoutSeconds) + { + return time + (timeoutSeconds > 0 ? timeoutSeconds : Defines.PreconnectMappingSeconds); + } } // ------------------------------------------------------------------ diff --git a/src/Server.cs b/src/Server.cs index bd6b6f3..97663d4 100644 --- a/src/Server.cs +++ b/src/Server.cs @@ -362,6 +362,9 @@ internal ulong GlobalSequence set => _globalSequence = value; } + // for tests: the number of active (non-expired) encryption mappings at the current time + internal int NumEncryptionMappings => _encryptionManager.CountActive(_time); + /// The port the server socket is bound to. public ushort Port => _address.Type == AddressType.IPv4 @@ -727,7 +730,7 @@ private void ProcessConnectionRequestPacket(in Address from, Span decrypte return; } - double expireTime = connectTokenPrivate.TimeoutSeconds >= 0 ? _time + connectTokenPrivate.TimeoutSeconds : -1.0; + double expireTime = EncryptionManager.PreconnectExpireTime(_time, connectTokenPrivate.TimeoutSeconds); if (!_encryptionManager.AddEncryptionMapping( in from, @@ -934,7 +937,8 @@ private void ReadAndProcessPacket(in Address from, Span packetData, ulong _privateKey, AllowedPackets, clientIndex != -1 ? _clientReplayProtection[clientIndex] : null, - _minConnectTokenExpireTimestamp); + _minConnectTokenExpireTimestamp, + (ulong)_maxConnectTokenLifetime); if (packet.Type < 0) return; diff --git a/tests/ClientServer.cs b/tests/ClientServer.cs index 82730c8..586d57f 100644 --- a/tests/ClientServer.cs +++ b/tests/ClientServer.cs @@ -45,6 +45,16 @@ private static ServerConfig CreateServerConfig(NetworkSimulator? simulator) return config; } + private static void StartServerForTests(Server server, int maxClients) + { + server.Start(maxClients); + // Session tests mint tokens in the server-start second. Place their test + // floor one second earlier so a 30-second token can exercise the session; + // the exact floor boundary has a separate packet-reader regression test. + server.SetConnectTokenExpiryFloorForTest( + (ulong)DateTimeOffset.UtcNow.ToUnixTimeSeconds() + Protocol.DefaultMaxConnectTokenLifetime - 1); + } + private static Server OverrideServer(string address) { var config = new ServerConfig @@ -147,7 +157,7 @@ public static void TestServerRestartGlobalSequence() Check.That(server.GlobalSequence == 1UL << 63); - server.Start(1); + StartServerForTests(server, 1); Check.That(server.GlobalSequence >= 1UL << 63); @@ -155,7 +165,7 @@ public static void TestServerRestartGlobalSequence() server.Stop(); - server.Start(1); + StartServerForTests(server, 1); Check.That(server.GlobalSequence >= 1UL << 63); } @@ -169,7 +179,7 @@ public static void TestGlobalSequenceDiffersBetweenServers() using var server1 = OverrideServer("127.0.0.1:40000"); using var server2 = OverrideServer("127.0.0.1:40001"); server1.Start(1); - server2.Start(1); + StartServerForTests(server2, 1); Check.That(server1.GlobalSequence != server2.GlobalSequence); Check.That(server1.GlobalSequence >= 1UL << 63); @@ -230,7 +240,7 @@ public static void TestClientReconnectUsedConnectToken() double deltaTime = 1.0 / 10.0; using var server = new Server("[::1]:40000", CreateServerConfig(simulator), time); using var client = new Client("[::]:50000", new ClientConfig { Simulator = simulator }, time); - server.Start(1); + StartServerForTests(server, 1); byte[] connectToken = GenerateConnectToken("[::1]:40000", 0x1234); client.Connect(connectToken); @@ -289,7 +299,7 @@ public static void TestConnectionResponseUsesItsMappingChallenge() config.SendPacketOverride = (in Address to, ReadOnlySpan data) => sent = data.ToArray(); config.ReceivePacketOverride = (ref Address from, Span data) => 0; using var server = new Server("127.0.0.1:40000", config, 0.0); - server.Start(4); + StartServerForTests(server, 4); Address x = Address.Parse("127.0.0.1:50000"); Address y = Address.Parse("127.0.0.1:50001"); @@ -369,7 +379,7 @@ public static void TestClientErrorConnectTokenPredatesServerStart() serverConfig.MaxConnectTokenLifetime = 30; using var server = new Server("[::1]:40000", serverConfig, time); using var client = new Client("[::]:50000", new ClientConfig { Simulator = simulator }, time); - server.Start(1); + StartServerForTests(server, 1); client.Connect(GenerateConnectToken("[::1]:40000", 0x1234, expiry: 20)); ConnectLoop(simulator, client, server, ref time, deltaTime); @@ -393,7 +403,7 @@ public static void TestClientServerConnect() using var client = new Client("[::]:50000", new ClientConfig { Simulator = simulator }, time); using var server = new Server("[::1]:40000", CreateServerConfig(simulator), time); - server.Start(1); + StartServerForTests(server, 1); ulong clientId = new TestRng(0xC11E17).NextUInt64(); client.Connect(GenerateConnectToken("[::1]:40000", clientId)); @@ -467,7 +477,7 @@ private static void ClientServerSocketConnectTo(string clientAddress, string? cl using var client = new Client(clientAddress, clientAddress2, null, time); using var server = new Server(serverAddress, serverAddress2, CreateServerConfig(null), time); - server.Start(1); + StartServerForTests(server, 1); ulong clientId = new TestRng((ulong)Environment.TickCount64 | 1).NextUInt64(); client.Connect(GenerateConnectToken(connectAddress, clientId)); @@ -538,7 +548,7 @@ public static void TestClientServerKeepAlive() using var client = new Client("[::]:50000", new ClientConfig { Simulator = simulator }, time); using var server = new Server("[::1]:40000", CreateServerConfig(simulator), time); - server.Start(1); + StartServerForTests(server, 1); client.Connect(GenerateConnectToken("[::1]:40000", 0x1234)); @@ -581,7 +591,7 @@ public static void TestClientServerMultipleClients() for (int i = 0; i < maxClients.Length; i++) { - server.Start(maxClients[i]); + StartServerForTests(server, maxClients[i]); var clients = new Client[maxClients[i]]; for (int j = 0; j < maxClients[i]; j++) @@ -717,7 +727,7 @@ public static void TestClientServerMultipleServers() using var client = new Client("[::]:50000", new ClientConfig { Simulator = simulator }, time); using var server = new Server("[::1]:40000", CreateServerConfig(simulator), time); - server.Start(1); + StartServerForTests(server, 1); // the first two addresses point at servers that do not exist; the client works // through them and connects to the third @@ -765,6 +775,68 @@ public static void TestClientErrorInvalidConnectToken() Check.That(client.State == ClientState.InvalidConnectToken); } + public static void TestClientCanReach() + { + // a client bound for one address family has no socket for the other; a + // simulator or override client reaches every address + + Check.That(Client.CanReach(AddressType.IPv4, true, false, false)); + Check.That(!Client.CanReach(AddressType.IPv6, true, false, false)); + Check.That(Client.CanReach(AddressType.IPv6, false, true, false)); + Check.That(!Client.CanReach(AddressType.IPv4, false, true, false)); + Check.That(Client.CanReach(AddressType.IPv4, false, false, true)); + Check.That(Client.CanReach(AddressType.IPv6, false, false, true)); + Check.That(!Client.CanReach(AddressType.None, true, true, false)); + } + + public static void TestClientRefusesServerAddressWithoutSocket() + { + // an IPv4-only client given a token whose only server address is IPv6 must + // refuse it; a token with a reachable IPv4 address must settle on it + + var config = new ClientConfig + { + OverrideSendAndReceive = true, + SendPacketOverride = (in Address to, ReadOnlySpan data) => { }, + ReceivePacketOverride = (ref Address from, Span data) => 0, + }; + using var client = new Client("0.0.0.0:50000", config, 0.0); + client.SocketAvailabilityOverride = type => Client.CanReach(type, true, false, false); + + byte[] Token(string[] addresses, ulong clientId) + { + Address[] parsed = Array.ConvertAll(addresses, Address.Parse); + byte[] nonce = new byte[Defines.ConnectTokenNonceBytes]; + nonce[0] = (byte)clientId; + byte[] token = new byte[Protocol.ConnectTokenBytes]; + Check.That(ConnectTokenGenerator.GenerateInternal(parsed, parsed, 1000, 1030, 15, + clientId, TestConstants.TestProtocolId, TestConstants.PrivateKey, + new byte[Protocol.UserDataBytes], nonce, token)); + return token; + } + + byte[] token6 = Token(new[] { "[::1]:40000" }, 0x1); + client.Connect(token6); + Check.That(client.State == ClientState.InvalidConnectToken); + + byte[] tokenMixed = Token(new[] { "[::1]:40000", "127.0.0.1:40000" }, 0x2); + client.Connect(tokenMixed); + Check.That(client.State == ClientState.SendingConnectionRequest); + Check.That(client.ServerAddressIndex == 1); + Check.That(client.ServerAddress.Equals(Address.Parse("127.0.0.1:40000"))); + + byte[] token4 = Token(new[] { "127.0.0.1:40000" }, 0x3); + client.Connect(token4); + Check.That(client.State == ClientState.SendingConnectionRequest); + Check.That(client.ServerAddressIndex == 0); + Check.That(client.ServerAddress.Equals(Address.Parse("127.0.0.1:40000"))); + + byte[] token4Then6 = Token(new[] { "127.0.0.1:40000", "[::1]:40000" }, 0x4); + client.Connect(token4Then6); + Check.That(client.ConnectToNextServer()); + Check.That(client.State == ClientState.InvalidConnectToken); + } + public static void TestClientErrorConnectionTimedOut() { // connect a client to a server, then stop updating the server. the client @@ -778,7 +850,7 @@ public static void TestClientErrorConnectionTimedOut() using var client = new Client("[::]:50000", new ClientConfig { Simulator = simulator }, time); using var server = new Server("[::1]:40000", CreateServerConfig(simulator), time); - server.Start(1); + StartServerForTests(server, 1); client.Connect(GenerateConnectToken("[::1]:40000", 0x2)); @@ -812,7 +884,7 @@ public static void TestClientErrorConnectionResponseTimeout() using var client = new Client("[::]:50000", new ClientConfig { Simulator = simulator }, time); using var server = new Server("[::1]:40000", CreateServerConfig(simulator), time); - server.Start(1); + StartServerForTests(server, 1); // the server ignores connection response packets, so the client hangs in // SendingConnectionResponse until it times out @@ -846,7 +918,7 @@ public static void TestClientErrorConnectionRequestTimeout() using var client = new Client("[::]:50000", new ClientConfig { Simulator = simulator }, time); using var server = new Server("[::1]:40000", CreateServerConfig(simulator), time); - server.Start(1); + StartServerForTests(server, 1); // the server ignores connection request packets, so the client hangs in // SendingConnectionRequest until it times out @@ -883,7 +955,7 @@ public static void TestClientErrorConnectionDenied() using var client = new Client("[::]:50000", new ClientConfig { Simulator = simulator }, time); using var server = new Server("[::1]:40000", CreateServerConfig(simulator), time); - server.Start(1); + StartServerForTests(server, 1); client.Connect(GenerateConnectToken("[::1]:40000", 0x5)); @@ -926,7 +998,7 @@ public static void TestClientSideDisconnect() using var client = new Client("[::]:50000", new ClientConfig { Simulator = simulator }, time); using var server = new Server("[::1]:40000", CreateServerConfig(simulator), time); - server.Start(1); + StartServerForTests(server, 1); client.Connect(GenerateConnectToken("[::1]:40000", 0x7)); @@ -966,7 +1038,7 @@ public static void TestServerSideDisconnect() using var client = new Client("[::]:50000", new ClientConfig { Simulator = simulator }, time); using var server = new Server("[::1]:40000", CreateServerConfig(simulator), time); - server.Start(1); + StartServerForTests(server, 1); client.Connect(GenerateConnectToken("[::1]:40000", 0x8)); @@ -1050,7 +1122,7 @@ public static void TestReceiveDrainSkipsEmptyAndStopsAtCap() config.ReceivePacketOverride = script.Receive; using var server = new Server("127.0.0.1:40000", config, 0.0); - server.Start(1); + StartServerForTests(server, 1); server.Update(1.0); @@ -1074,7 +1146,7 @@ public static void TestReceiveDrainSkipsEmptyAndStopsAtCap() config.ReceivePacketOverride = script.Receive; using var server = new Server("127.0.0.1:40000", config, 0.0); - server.Start(1); + StartServerForTests(server, 1); server.Update(1.0); @@ -1148,7 +1220,7 @@ public static void TestServerClientDisconnectReason() using var server = new Server("[::1]:40000", serverConfig, time); serverRef = server; - server.Start(1); + StartServerForTests(server, 1); // server-side disconnect @@ -1211,7 +1283,7 @@ public static void TestClientReconnect() using var client = new Client("[::]:50000", new ClientConfig { Simulator = simulator }, time); using var server = new Server("[::1]:40000", CreateServerConfig(simulator), time); - server.Start(1); + StartServerForTests(server, 1); ulong clientId = 0xCAFE; @@ -1268,7 +1340,7 @@ public static void TestDisableTimeout() using var client = new Client("[::]:50000", new ClientConfig { Simulator = simulator }, time); using var server = new Server("[::1]:40000", CreateServerConfig(simulator), time); - server.Start(1); + StartServerForTests(server, 1); // timeout of -1 disables timeouts entirely (dev only) @@ -1317,6 +1389,79 @@ public static void TestDisableTimeout() Check.That(clientNumPacketsReceived >= 10 && serverNumPacketsReceived >= 10); } + public static void TestPreconnectMappingLifetime() + { + // a pre-connect encryption mapping has its own bounded lifetime: a negative + // timeout must not hold a slot forever, and a zero timeout must not expire the + // mapping before the response can arrive. + + byte[] sent = Array.Empty(); + var config = new ServerConfig { ProtocolId = TestConstants.TestProtocolId, OverrideSendAndReceive = true }; + TestConstants.PrivateKey.CopyTo(config.PrivateKey, 0); + config.SendPacketOverride = (in Address to, ReadOnlySpan data) => sent = data.ToArray(); + config.ReceivePacketOverride = (ref Address from, Span data) => 0; + using var server = new Server("127.0.0.1:40000", config, 0.0); + StartServerForTests(server, 4); + server.SetConnectTokenExpiryFloorForTest(1000); + Address x = Address.Parse("127.0.0.1:50000"); + + ConnectTokenData Token(int timeout) + { + byte[] bytes = new byte[Protocol.ConnectTokenBytes]; + byte[] userData = new byte[Protocol.UserDataBytes]; + Span
addresses = stackalloc Address[1] { Address.Parse("127.0.0.1:40000") }; + Span nonce = stackalloc byte[Defines.ConnectTokenNonceBytes]; + new TestRng(0x51).Fill(nonce); + Check.That(ConnectTokenGenerator.GenerateInternal(addresses, addresses, 1000, 1030, timeout, + 0x1111, TestConstants.TestProtocolId, TestConstants.PrivateKey, userData, nonce, bytes)); + var token = new ConnectTokenData(); + Check.That(token.Read(bytes)); + return token; + } + + void Request(in Address from, ConnectTokenData token, double logicalTime) + { + byte[] wire = new byte[Defines.MaxPacketBytes]; + int bytes = PacketIO.WriteConnectionRequestPacket(wire, token.VersionInfo, token.ProtocolId, + token.ExpireTimestamp, token.Nonce, token.PrivateData); + server.ProcessPacketAtTimestamp(in from, wire.AsSpan(0, bytes), 1001, logicalTime); + } + + byte[] Challenge(ConnectTokenData token) + { + byte[] wire = (byte[])sent.Clone(); + bool[] allowed = new bool[PacketType.NumPackets]; + allowed[PacketType.ConnectionChallenge] = true; + ReadPacketResult packet = PacketIO.ReadPacket(wire, true, token.ServerToClientKey, + TestConstants.TestProtocolId, 1001, false, ReadOnlySpan.Empty, allowed, null); + Check.That(packet.Type == PacketType.ConnectionChallenge); + return wire.AsSpan(packet.DataOffset, packet.DataLength).ToArray(); + } + + void Response(in Address from, ConnectTokenData token, byte[] challenge, double logicalTime) + { + byte[] wire = new byte[Defines.MaxPacketBytes]; + int bytes = PacketIO.WriteEncryptedPacket(wire, PacketType.ConnectionResponse, + challenge, 1, token.ClientToServerKey, TestConstants.TestProtocolId); + server.ProcessPacketAtTimestamp(in from, wire.AsSpan(0, bytes), 1001, logicalTime); + } + + // timeout -1: the mapping must expire after the preconnect lifetime, not never + ConnectTokenData a = Token(-1); + Request(in x, a, 0.0); + Check.That(server.NumEncryptionMappings == 1); + server.Update(11.0); + Check.That(server.NumEncryptionMappings == 0); + + // timeout 0: the mapping must survive one update so the response connects + ConnectTokenData b = Token(0); + Request(in x, b, 0.0); + Check.That(server.NumEncryptionMappings == 1); + byte[] challengeB = Challenge(b); + Response(in x, b, challengeB, 0.1); + Check.That(server.NumConnectedClients == 1); + } + public static void TestLoopback() { NetworkSimulator simulator = CreateSimulator(); @@ -1363,7 +1508,7 @@ public static void TestLoopback() loopbackClientRef = loopbackClient; serverRef = server; - server.Start(2); + StartServerForTests(server, 2); // connect the loopback client to slot 0 @@ -1520,7 +1665,7 @@ public static void TestServerLoopbackRequiresCallback() // in every build, rather than calling null on the next send. using var server = new Server("127.0.0.1:40000", CreateServerConfig(null)); - server.Start(1); + StartServerForTests(server, 1); server.ConnectLoopbackClient(0, 0x11111111, ReadOnlySpan.Empty); diff --git a/tests/Hostile.cs b/tests/Hostile.cs index b8b2b61..06f1171 100644 --- a/tests/Hostile.cs +++ b/tests/Hostile.cs @@ -430,11 +430,13 @@ public static void TestZeroAllocationSteadyState() using var server = new Server("127.0.0.1:40000", serverConfig, 0.0); server.Start(1); + server.SetConnectTokenExpiryFloorForTest( + (ulong)DateTimeOffset.UtcNow.ToUnixTimeSeconds() + Protocol.DefaultMaxConnectTokenLifetime - 1); byte[] connectToken = new byte[Protocol.ConnectTokenBytes]; byte[] userData = new byte[Protocol.UserDataBytes]; string[] address = { "127.0.0.1:40000" }; - Check.That(ConnectTokenGenerator.Generate(address, address, 60, 15, 0x1234, TestConstants.TestProtocolId, TestConstants.PrivateKey, userData, connectToken)); + Check.That(ConnectTokenGenerator.Generate(address, address, 30, 15, 0x1234, TestConstants.TestProtocolId, TestConstants.PrivateKey, userData, connectToken)); client.Connect(connectToken); diff --git a/tests/Tests.cs b/tests/Tests.cs index 187c181..c1277cf 100644 --- a/tests/Tests.cs +++ b/tests/Tests.cs @@ -141,6 +141,8 @@ private static int Main(string[] args) RunTest("test_client_server_multiple_servers", ClientServerTests.TestClientServerMultipleServers); RunTest("test_client_error_connect_token_expired", ClientServerTests.TestClientErrorConnectTokenExpired); RunTest("test_client_error_invalid_connect_token", ClientServerTests.TestClientErrorInvalidConnectToken); + RunTest("test_client_can_reach", ClientServerTests.TestClientCanReach); + RunTest("test_client_refuses_server_address_without_socket", ClientServerTests.TestClientRefusesServerAddressWithoutSocket); RunTest("test_client_error_connection_timed_out", ClientServerTests.TestClientErrorConnectionTimedOut); RunTest("test_client_error_connection_response_timeout", ClientServerTests.TestClientErrorConnectionResponseTimeout); RunTest("test_client_error_connection_request_timeout", ClientServerTests.TestClientErrorConnectionRequestTimeout); @@ -151,6 +153,7 @@ private static int Main(string[] args) RunTest("test_receive_drain_skips_empty_and_stops_at_cap", ClientServerTests.TestReceiveDrainSkipsEmptyAndStopsAtCap); RunTest("test_client_reconnect", ClientServerTests.TestClientReconnect); RunTest("test_disable_timeout", ClientServerTests.TestDisableTimeout); + RunTest("test_preconnect_mapping_lifetime", ClientServerTests.TestPreconnectMappingLifetime); RunTest("test_loopback", ClientServerTests.TestLoopback); RunTest("test_client_create_missing_override_callback", ClientServerTests.TestClientCreateMissingOverrideCallback); RunTest("test_server_create_missing_override_callback", ClientServerTests.TestServerCreateMissingOverrideCallback); @@ -752,6 +755,38 @@ public static void TestConnectionRequestPacket() var outputToken = new ConnectTokenPrivate(); Check.That(outputToken.Read(buffer.AsSpan(result.DataOffset, result.DataLength))); Check.That(outputToken.ClientId == TestConstants.TestClientId); + + // a never-expiring or over-long token must be refused by the lifetime check, + // not accepted because its expiry passes the restart floor + + ulong currentTimestamp = 1000; + const ulong MaxLifetime = Protocol.DefaultMaxConnectTokenLifetime; + + int ReadRequest(ulong expire, ulong maxLifetime, ulong minExpire = 0) + { + byte[] enc = (byte[])connectTokenData.Clone(); + Check.That(ConnectTokenPrivate.Encrypt(enc, Defines.VersionInfo, TestConstants.TestProtocolId, expire, nonce, key)); + byte[] req = new byte[2048]; + int n = PacketIO.WriteConnectionRequestPacket(req, Defines.VersionInfo, TestConstants.TestProtocolId, expire, nonce, enc); + ReadPacketResult r = PacketIO.ReadPacket( + req.AsSpan(0, n), + hasReadPacketKey: true, new byte[Protocol.KeyBytes], + TestConstants.TestProtocolId, + currentTimestamp, + hasPrivateKey: true, key, + AllAllowed, null, + minConnectTokenExpireTimestamp: minExpire, + maxConnectTokenLifetime: maxLifetime); + return r.Type; + } + + Check.That(ReadRequest(ulong.MaxValue, MaxLifetime) == -1); + Check.That(ReadRequest(currentTimestamp + 2 * MaxLifetime, MaxLifetime) == -1); + Check.That(ReadRequest(currentTimestamp + MaxLifetime + 1, MaxLifetime) == -1); + Check.That(ReadRequest(currentTimestamp + MaxLifetime, MaxLifetime) == PacketType.ConnectionRequest); + Check.That(ReadRequest(currentTimestamp + MaxLifetime, MaxLifetime, currentTimestamp + MaxLifetime) == -1); + Check.That(ReadRequest(currentTimestamp + 1, MaxLifetime) == PacketType.ConnectionRequest); + Check.That(ReadRequest(currentTimestamp, MaxLifetime) == -1); } public static void TestEmptyPacket(int packetType) @@ -1048,6 +1083,15 @@ public static void TestEncryptionManager() encryptionManager.SetExpireTime(index, -1.0); Check.That(encryptionManager.FindEncryptionMapping(in addresses[0], time) == index); + + // preconnect expire time: a positive timeout is the mapping lifetime, otherwise + // the bounded preconnect lifetime applies + + Check.That(EncryptionManager.PreconnectExpireTime(100.0, -1) == 110.0); + Check.That(EncryptionManager.PreconnectExpireTime(100.0, 0) == 110.0); + Check.That(EncryptionManager.PreconnectExpireTime(100.0, 1) == 101.0); + Check.That(EncryptionManager.PreconnectExpireTime(100.0, 5) == 105.0); + Check.That(EncryptionManager.PreconnectExpireTime(100.0, 30) == 130.0); } public static void TestReplayProtection()