From 19be4d372a09df2f450b30823a40f08173cb8171 Mon Sep 17 00:00:00 2001 From: Fletcher Dunn Date: Wed, 27 May 2026 16:08:50 -0700 Subject: [PATCH] ICE client: more progress on TURN server support Add ICELocalCandidate. This was deleted earlier, because it had a bunch of extra stuff that we don't care about when comparing if two local candidates are the same. Essentially if we send diretcly from our socket, then we don't really care what kind of candidate it is to the peer. But we do really need to distinguish between local candidates that are relayed. Add ICESessionInterface::SendPacketGather, which knows how to wrap a data packet in a k_nTURN_SendIndication Check for k_nTURN_DataIndication from TURN servers. This doesn't work fully right now. --- .../steamnetworkingsockets_ice_client.cpp | 186 +++++++++++++++--- .../steamnetworkingsockets_ice_client.h | 22 ++- 2 files changed, 175 insertions(+), 33 deletions(-) diff --git a/src/steamnetworkingsockets/clientlib/steamnetworkingsockets_ice_client.cpp b/src/steamnetworkingsockets/clientlib/steamnetworkingsockets_ice_client.cpp index c50fb76..b0a39e1 100644 --- a/src/steamnetworkingsockets/clientlib/steamnetworkingsockets_ice_client.cpp +++ b/src/steamnetworkingsockets/clientlib/steamnetworkingsockets_ice_client.cpp @@ -865,6 +865,7 @@ inline bool IPAddrEqualIgnoringPort( const SteamNetworkingIPAddr &a, const Steam CSteamNetworkingSocketsSTUNRequest::CSteamNetworkingSocketsSTUNRequest( ICESessionInterface *pInterface ) : m_pInterface( pInterface ) + , m_addrRelay{} { CCrypto::GenerateRandomBlock( m_nTransactionID, 12 ); } @@ -920,7 +921,10 @@ void CSteamNetworkingSocketsSTUNRequest::Think( SteamNetworkingMicroseconds usec if ( retryTimeout > 60000000 ) // Max timeout of 60s. retryTimeout = 60000000; - if ( m_pInterface->m_pSocket->BSendRawPacket( m_packet, m_cbPacketSize, m_remoteAddr ) ) + iovec temp; + temp.iov_base = m_packet; + temp.iov_len = m_cbPacketSize; + if ( m_pInterface->SendPacketGather( 1, &temp, m_cbPacketSize, m_remoteAddr, m_addrRelay ) ) { m_usecLastSentTime = usecNow; SetNextThinkTime( usecNow + retryTimeout ); @@ -988,6 +992,73 @@ void ICESessionInterface::QueueAllocateRequest( const SteamNetworkingIPAddr &add m_pPendingSTUNRequest->Queue( k_nTURN_AllocateRequest, nEncoding | kSTUNPacketEncodingFlags_NoMappedAddress, addrTURNServer, cb, &reqTransport, 1 ); } +// Send a gathered packet to the pair, routing via TURN Send Indication if the +// local candidate is a relay. For non-relay pairs this is a thin wrapper around +// BSendRawPacketGather. +bool ICESessionInterface::SendPacketGather( int nChunks, const iovec *pChunks, int cbPayload, const SteamNetworkingIPAddr &addrPeer, const SteamNetworkingIPAddr &addrRelay ) +{ + if ( addrRelay.IsIPv6AllZeros() ) + return m_pSocket->BSendRawPacketGather( nChunks, pChunks, addrPeer ); + + // Relay: wrap in a TURN Send Indication. + if ( nChunks > 3 ) + { + AssertMsg( false, "Too many chunks to send via TURN relay (max 3)" ); + return false; + } + + // Build Send Indication: STUN header + XOR-PEER-ADDRESS + DATA. + const int cbPad = ( 4 - ( cbPayload & 3 ) ) & 3; + const int cbPeerAttr = addrPeer.IsIPv4() ? 12 : 24; + const int cbAttrs = cbPeerAttr + 4 + cbPayload + cbPad; + uint8 hdrBuf[ 20 + 24 + 8 ]; // header + max peer attr + DATA attr header + uint32 *p = (uint32 *)hdrBuf; + + // STUN header + p[0] = htonl( ( k_nTURN_SendIndication << 16 ) | (uint16)cbAttrs ); + p[1] = htonl( k_nSTUN_CookieValue ); + p[2] = p[3] = p[4] = 0; // transaction ID = zeros; indications don't need one + p += 5; + + // XOR-PEER-ADDRESS + const uint32 nXORPort = (uint32)addrPeer.m_port ^ ( k_nSTUN_CookieValue >> 16 ); + if ( addrPeer.IsIPv4() ) + { + p[0] = htonl( ( k_nTURN_Attr_XORPeerAddress << 16 ) | 8 ); + p[1] = htonl( ( 0x01u << 16 ) | nXORPort ); + p[2] = htonl( addrPeer.GetIPv4() ^ k_nSTUN_CookieValue ); + p += 3; + } + else + { + p[0] = htonl( ( k_nTURN_Attr_XORPeerAddress << 16 ) | 20 ); + p[1] = htonl( ( 0x02u << 16 ) | nXORPort ); + V_memcpy( &p[2], addrPeer.m_ipv6, 16 ); + p[2] ^= htonl( k_nSTUN_CookieValue ); + // Transaction ID is zeros so no additional XOR needed for IPv6 + p += 6; + } + + // DATA attribute type+length (payload and padding follow in separate iovecs) + p[0] = htonl( ( k_nTURN_Attr_Data << 16 ) | (uint16)cbPayload ); + + static const uint32 k_zeroPad = 0; + iovec relayChunks[5]; + relayChunks[0].iov_base = hdrBuf; + relayChunks[0].iov_len = (size_t)( (uint8*)(p+1) - hdrBuf ); + int nRelayChunks = 1; + for ( int i = 0; i < nChunks; ++i ) + relayChunks[nRelayChunks++] = pChunks[i]; + if ( cbPad ) + { + relayChunks[nRelayChunks].iov_base = (void*)&k_zeroPad; + relayChunks[nRelayChunks].iov_len = cbPad; + ++nRelayChunks; + } + + return m_pSocket->BSendRawPacketGather( nRelayChunks, relayChunks, addrRelay ); +} + ///////////////////////////////////////////////////////////////////////////// // // CSteamNetworkingICESession @@ -1079,9 +1150,8 @@ bool CSteamNetworkingICESession::SendPacketGather( int nChunks, const iovec *pCh { if ( !m_pSelectedCandidatePair ) return false; - ICESessionInterface *pInterface = m_pSelectedCandidatePair->m_pInterface; - - return pInterface->m_pSocket->BSendRawPacketGather( nChunks, pChunks, m_pSelectedCandidatePair->m_remoteCandidate.m_addr ); + return m_pSelectedCandidatePair->m_localCandidate.m_pInterface->SendPacketGather( nChunks, pChunks, cbSendTotal, + m_pSelectedCandidatePair->m_remoteCandidate.m_addr, m_pSelectedCandidatePair->m_localCandidate.m_addrTURNServer ); } void CSteamNetworkingICESession::SetRemoteUsername( const char *pszUsername ) @@ -1210,10 +1280,10 @@ void CSteamNetworkingICESession::InvalidateInterfaceList() void CSteamNetworkingICESession::SetSelectedCandidatePair( ICECandidatePair *pPair ) { - SpewMsg( "\n\nSelected candidate %s -> %s.\n\n", SteamNetworkingIPAddrRender( pPair->m_pInterface->m_pSocket->m_boundAddr ).c_str(), SteamNetworkingIPAddrRender( pPair->m_remoteCandidate.m_addr ).c_str() ); + SpewMsg( "\n\nSelected candidate %s -> %s.\n\n", SteamNetworkingIPAddrRender( pPair->m_localCandidate.m_pInterface->m_pSocket->m_boundAddr ).c_str(), SteamNetworkingIPAddrRender( pPair->m_remoteCandidate.m_addr ).c_str() ); m_pSelectedCandidatePair = pPair; if ( m_pCallbacks ) - m_pCallbacks->OnConnectionSelected( *pPair->m_pInterface, pPair->m_remoteCandidate ); + m_pCallbacks->OnConnectionSelected( *pPair->m_localCandidate.m_pInterface, pPair->m_remoteCandidate ); } void CSteamNetworkingICESession::InternalDeleteCandidatePair( ICECandidatePair *pPair ) @@ -1290,7 +1360,7 @@ void CSteamNetworkingICESession::GatherInterfaces() for ( int j = len( m_vecCandidatePairs ) - 1; j >= 0; --j ) { ICECandidatePair *pPair = m_vecCandidatePairs[j]; - if ( pPair->m_pInterface == intf ) + if ( pPair->m_localCandidate.m_pInterface == intf ) { InternalDeleteCandidatePair( pPair ); erase_at( m_vecCandidatePairs, j ); @@ -1380,6 +1450,42 @@ not_stun: header.m_nTransactionID[2] = pWords[4]; } + // TURN Data Indications are the most common STUN-framed packet once a relay is active. + // Handle them first. They are server-initiated (no matching transaction ID) so the + // normal response-routing path below would just drop them. + if ( header.m_nMessageType == k_nTURN_DataIndication ) + { + // Must originate from the TURN server we allocated with; discard anything else. + SteamNetworkingIPAddr fromAddr; + ConvertNetAddr_tToSteamNetworkingIPAddr( info.m_adrFrom, &fromAddr ); + if ( !( fromAddr == pInterface->m_addrTURNServer ) ) + return; + + // TODO: avoid heap allocation per packet + CUtlVector vecAttrs; + ParseSTUNAttributes( info, nullptr, 0, &vecAttrs ); + + const STUNAttribute *pPeerAttr = FindAttributeOfType( vecAttrs.Base(), vecAttrs.Count(), k_nTURN_Attr_XORPeerAddress ); + const STUNAttribute *pDataAttr = FindAttributeOfType( vecAttrs.Base(), vecAttrs.Count(), k_nTURN_Attr_Data ); + if ( pPeerAttr == nullptr || pDataAttr == nullptr ) + return; + + SteamNetworkingIPAddr peerAddr; + peerAddr.Clear(); + if ( !ReadXORAddressAttribute( pPeerAttr, &header, &peerAddr ) ) + return; + + // Re-enter with the inner payload, as if it arrived directly from the peer. + RecvPktInfo_t innerInfo; + innerInfo.m_pPkt = reinterpret_cast( pDataAttr->m_pData ); + innerInfo.m_cbPkt = (int)pDataAttr->m_nLength; + innerInfo.m_usecNow = info.m_usecNow; + innerInfo.m_pSock = info.m_pSock; + ConvertSteamNetworkingIPAddrToNetAdr_t( peerAddr, &innerInfo.m_adrFrom ); + OnPacketReceived( innerInfo, pInterface ); + return; + } + // STUN responses: route to the matching in-flight request by transaction ID. if ( header.m_nMessageType != k_nSTUN_BindingRequest ) { @@ -1470,7 +1576,7 @@ not_stun: for ( ICECandidatePair *pPair : m_vecCandidatePairs ) { if ( pPair->m_remoteCandidate.m_addr == fromAddr - && pPair->m_pInterface == pInterface ) + && pPair->m_localCandidate.m_pInterface == pInterface ) { pThisPair = pPair; break; @@ -1508,7 +1614,7 @@ not_stun: } pRemoteCandidate = push_back_get_ptr( m_vecPeerCandidates, ICEPeerCandidate( newRemoteCandidate, SteamNetworkingIPAddrRender( fromAddr ).c_str() ) ); } - pThisPair = new ICECandidatePair( pInterface, *pRemoteCandidate, m_role ); + pThisPair = new ICECandidatePair( ICELocalCandidate{ pInterface, {} }, *pRemoteCandidate, m_role ); m_vecCandidatePairs.push_back( pThisPair ); } @@ -1856,7 +1962,7 @@ void CSteamNetworkingICESession::Think_KeepAliveOnCandidates( SteamNetworkingMic if ( m_pSelectedCandidatePair != nullptr ) { - UpdateKeepalive( m_pSelectedCandidatePair->m_pInterface ); + UpdateKeepalive( m_pSelectedCandidatePair->m_localCandidate.m_pInterface ); } else { @@ -1873,26 +1979,43 @@ void CSteamNetworkingICESession::Think_TestPeerConnectivity() { m_bCandidatePairsNeedUpdate = false; - // For every interface, for every peer candidate, make sure the pair is present. + // For every local candidate (host + relay if available), for every peer candidate, + // make sure the pair is present. for ( const std::unique_ptr &pIntf : m_vecInterfaces ) { - for ( ICEPeerCandidate &remoteCandidate : m_vecPeerCandidates ) + ICELocalCandidate localCandidates[2]; + int nLocalCandidates = 0; + + // Host candidate โ€” always present. + localCandidates[nLocalCandidates++] = { pIntf.get(), {} }; + + // Relay candidate โ€” only when an allocation has succeeded. + if ( !pIntf->m_addrTURNServer.IsIPv6AllZeros() && !pIntf->m_bRelayFailed ) + localCandidates[nLocalCandidates++] = { pIntf.get(), pIntf->m_addrTURNServer }; + + for ( int iLocal = 0; iLocal < nLocalCandidates; ++iLocal ) { - if ( pIntf->m_pSocket->m_boundAddr.IsIPv4() != remoteCandidate.m_addr.IsIPv4() ) - continue; - bool bFound = false; - for ( ICECandidatePair *pPair : m_vecCandidatePairs ) + const ICELocalCandidate &localCand = localCandidates[iLocal]; + for ( ICEPeerCandidate &remoteCandidate : m_vecPeerCandidates ) { - if ( pPair->m_pInterface == pIntf.get() && pPair->m_remoteCandidate.m_addr == remoteCandidate.m_addr ) + if ( localCand.m_pInterface->m_pSocket->m_boundAddr.IsIPv4() != remoteCandidate.m_addr.IsIPv4() ) + continue; + bool bFound = false; + for ( ICECandidatePair *pPair : m_vecCandidatePairs ) { - bFound = true; - break; + if ( pPair->m_localCandidate.m_pInterface == localCand.m_pInterface + && pPair->m_localCandidate.m_addrTURNServer == localCand.m_addrTURNServer + && pPair->m_remoteCandidate.m_addr == remoteCandidate.m_addr ) + { + bFound = true; + break; + } + } + if ( !bFound ) + { + ICECandidatePair *pNewCandidatePair = new ICECandidatePair( localCand, remoteCandidate, m_role ); + m_vecCandidatePairs.push_back( pNewCandidatePair ); } - } - if ( !bFound ) - { - ICECandidatePair *pNewCandidatePair = new ICECandidatePair( pIntf.get(), remoteCandidate, m_role ); - m_vecCandidatePairs.push_back( pNewCandidatePair ); } } } @@ -1957,7 +2080,7 @@ void CSteamNetworkingICESession::Think_TestPeerConnectivity() if ( pPairToCheck != nullptr ) { // Trigger the connectivity check here... - ICESessionInterface * const pIntf = pPairToCheck->m_pInterface; + ICESessionInterface * const pIntf = pPairToCheck->m_localCandidate.m_pInterface; pPairToCheck->m_nState = kICECandidatePairState_InProgress; pPairToCheck->m_pPeerRequest = new CSteamNetworkingSocketsSTUNRequest( pIntf ); @@ -1980,7 +2103,7 @@ void CSteamNetworkingICESession::Think_TestPeerConnectivity() { // RFC 8445 section 7.2.2: priority attr uses peer-reflexive type preference (110). - uPriority = htonl( ( 110u << 24 ) | ( ( pPairToCheck->m_pInterface->m_nPriority & 0xFFFF ) << 8 ) | 255u ); + uPriority = htonl( ( 110u << 24 ) | ( ( pPairToCheck->m_localCandidate.m_pInterface->m_nPriority & 0xFFFF ) << 8 ) | 255u ); extraAttrs[nExtraAttrs].m_nType = k_nSTUN_Attr_Priority; extraAttrs[nExtraAttrs].m_nLength = 4; extraAttrs[nExtraAttrs].m_pData = &uPriority; @@ -2017,7 +2140,9 @@ void CSteamNetworkingICESession::Think_TestPeerConnectivity() } pPairToCheck->m_pPeerRequest->m_strPassword = m_strRemotePassword; + pPairToCheck->m_pPeerRequest->Queue( k_nSTUN_BindingRequest, m_nEncoding | kSTUNPacketEncodingFlags_NoMappedAddress, pPairToCheck->m_remoteCandidate.m_addr, &CSteamNetworkingICESession::STUNRequestCallback_PeerConnectivityCheck, extraAttrs, nExtraAttrs ); + pPairToCheck->m_pPeerRequest->m_addrRelay = pPairToCheck->m_localCandidate.m_addrTURNServer; m_vecPendingPeerRequests.push_back( pPairToCheck->m_pPeerRequest ); } } @@ -2212,14 +2337,15 @@ EICECandidateType CalcICECandidateType( ICECandidateKind kind, const SteamNetwor // CSteamNetworkingICESession::ICECandidatePair // ///////////////////////////////////////////////////////////////////////////// -CSteamNetworkingICESession::ICECandidatePair::ICECandidatePair( ICESessionInterface *pInterface, const ICEPeerCandidate& remoteCandidate, EICERole role ) - : m_pInterface( pInterface ), +CSteamNetworkingICESession::ICECandidatePair::ICECandidatePair( const ICELocalCandidate& localCandidate, const ICEPeerCandidate& remoteCandidate, EICERole role ) + : m_localCandidate( localCandidate ), m_remoteCandidate( remoteCandidate ), m_nState( kICECandidatePairState_Frozen ), m_bNominated( false ) { - // Use the highest possible local priority: host-type preference (126) on this interface. - const uint32 nLocalPriority = ( 126u << 24 ) + ( ( pInterface->m_nPriority & 0xFFFF ) << 8 ) + 255u; + // RFC 8445 ยง5.1.2 type preference: host=126, srflx=100, relay=0. + const uint32 nTypePreference = localCandidate.IsRelay() ? 0u : 126u; + const uint32 nLocalPriority = ( nTypePreference << 24 ) + ( ( localCandidate.m_pInterface->m_nPriority & 0xFFFF ) << 8 ) + 255u; const uint64 D = ( role == k_EICERole_Controlling ) ? nLocalPriority : remoteCandidate.m_nPriority; const uint64 G = ( role == k_EICERole_Controlling ) ? remoteCandidate.m_nPriority : nLocalPriority; m_nPriority = ( 1ull << 32 ) * MIN( G, D ) + 2 * MAX( G, D ) + ( G > D ? 1 : 0 ); diff --git a/src/steamnetworkingsockets/clientlib/steamnetworkingsockets_ice_client.h b/src/steamnetworkingsockets/clientlib/steamnetworkingsockets_ice_client.h index 2dfb491..db54c4e 100644 --- a/src/steamnetworkingsockets/clientlib/steamnetworkingsockets_ice_client.h +++ b/src/steamnetworkingsockets/clientlib/steamnetworkingsockets_ice_client.h @@ -102,6 +102,10 @@ namespace SteamNetworkingSocketsLib { // new CreatePermission sweep is needed. int m_nTURNPermissionRevision = 0; + /// Send a packet through this interface to the destination remote address. + /// If relay address is non-zero, send via Send Indication to the TURN server + bool SendPacketGather( int nChunks, const iovec *pChunks, int cbPayload, const SteamNetworkingIPAddr &addrPeer, const SteamNetworkingIPAddr &addrRelay ); + // Build and dispatch a local candidate discovery notification. // Computes the RFC 5245 candidate-attribute string and the family-specific // EICECandidateType, then calls m_session's OnLocalCandidateDiscovered callback. @@ -121,6 +125,17 @@ namespace SteamNetworkingSocketsLib { ICESessionInterface& operator=( const ICESessionInterface& ) = delete; }; + /// Identifies one local candidate: a socket (interface) plus an optional TURN relay. + /// An interface with a relay allocation produces two local candidates โ€” one host + /// (m_addrTURNServer all-zeros, send directly from the socket) and one relay + /// (m_addrTURNServer non-zero, send via Send Indication to the TURN server). + struct ICELocalCandidate + { + ICESessionInterface *m_pInterface; + SteamNetworkingIPAddr m_addrTURNServer; // all-zeros = host candidate + bool IsRelay() const { return !m_addrTURNServer.IsIPv6AllZeros(); } + }; + // Parsed representation of an RFC 5245 candidate-attribute line. // https://datatracker.ietf.org/doc/html/rfc5245#section-15.1 struct RFC5245CandidateAttr { @@ -191,7 +206,8 @@ namespace SteamNetworkingSocketsLib { // The local interface this request was sent from. Set at construction, never null. ICESessionInterface * const m_pInterface; uint32 m_nTransactionID[3]; // generated at construction - SteamNetworkingIPAddr m_remoteAddr; + SteamNetworkingIPAddr m_remoteAddr; // Address of the peer + SteamNetworkingIPAddr m_addrRelay; int m_nRetryCount; int m_nMaxRetries; RecvSTUNPacketCallback_t m_callback = nullptr; @@ -281,11 +297,11 @@ namespace SteamNetworkingSocketsLib { ICECandidatePairState m_nState; bool m_bNominated; uint64 m_nPriority; - ICESessionInterface *m_pInterface; // local socket; identifies which local address to send from + ICELocalCandidate m_localCandidate; ICEPeerCandidate m_remoteCandidate; CSteamNetworkingSocketsSTUNRequest *m_pPeerRequest; int m_nLastRecordedPing; - ICECandidatePair( ICESessionInterface *pInterface, const ICEPeerCandidate& remoteCandidate, EICERole role ); + ICECandidatePair( const ICELocalCandidate& localCandidate, const ICEPeerCandidate& remoteCandidate, EICERole role ); }; CSteamNetworkingICESessionCallbacks *m_pCallbacks;