TCP实现P2P通信、TCP穿越NAT的方法、TCP打洞 下载源代码 闲话少说,我们先看一下技术背景: 这里需要介绍一下NAT的类型: 我们先假设一下:有一个服务器S在公网上有一个IP,两个私网分别由NAT-A和NAT-B连接到公网,NAT-A后面有一台客户端A,NAT-B后面有一台客户端B,现在,我们需要借助S将A和B建立直接的TCP连接,即由B向A打一个洞,让A可以沿这个洞直接连接到B主机,就好像NAT-B不存在一样。 整个实现过程靠文字恐怕很难讲清楚,再加上我的语言表达能力很差(高考语文才考75分,总分150分,惭愧),所以只好用代码来说明问题了。 // 服务器地址和端口号定义 #define SRV_TCP_MAIN_PORT 4000 // 服务器主连接的端口号 #define SRV_TCP_HOLE_PORT 8000 // 服务器响应客户端打洞申请的端口号这两个端口是固定的,服务器S启动时就开始侦听这两个端口了。 // // 将新客户端登录信息发送给所有已登录的客户端,但不发送给自己 // BOOL SendNewUserLoginNotifyToAll ( LPCTSTR lpszClientIP, UINT nClientPort, DWORD dwID ) { ASSERT ( lpszClientIP && nClientPort > 0 ); g_CSFor_PtrAry_SockClient.Lock(); for ( int i=0; i<g_PtrAry_SockClient.GetSize(); i++ ) { CSockClient *pSockClient = (CSockClient*)g_PtrAry_SockClient.GetAt(i); if ( pSockClient && pSockClient->m_bMainConn && pSockClient->m_dwID > 0 && pSockClient->m_dwID != dwID ) { if ( !pSockClient->SendNewUserLoginNotify ( lpszClientIP, nClientPort, dwID ) ) { g_CSFor_PtrAry_SockClient.Unlock(); return FALSE; } } } g_CSFor_PtrAry_SockClient.Unlock (); return TRUE; }当有新的客户端连接到服务器时,服务器负责将该客户端的信息(IP地址、端口号)发送给其他客户端。 // // 执行者:客户端A // 有新客户端B登录了,我(客户端A)连接服务器端口 SRV_TCP_HOLE_PORT ,申请与客户端B建立直接的TCP连接 // BOOL Handle_NewUserLogin ( CSocket &MainSock, t_NewUserLoginPkt *pNewUserLoginPkt ) { printf ( "New user ( %s:%u:%u ) login server\n", pNewUserLoginPkt->szClientIP, pNewUserLoginPkt->nClientPort, pNewUserLoginPkt->dwID ); BOOL bRet = FALSE; DWORD dwThreadID = 0; t_ReqConnClientPkt ReqConnClientPkt; CSocket Sock; CString csSocketAddress; char szRecvBuffer[NET_BUFFER_SIZE] = {0}; int nRecvBytes = 0; // 创建打洞Socket,连接服务器协助打洞的端口号 SRV_TCP_HOLE_PORT try { if ( !Sock.Socket () ) { printf ( "Create socket failed : %s\n", hwFormatMessage(GetLastError()) ); goto finished; } UINT nOptValue = 1; if ( !Sock.SetSockOpt ( SO_REUSEADDR, &nOptValue , sizeof(UINT) ) ) { printf ( "SetSockOpt socket failed : %s\n", hwFormatMessage(GetLastError()) ); goto finished; } if ( !Sock.Bind ( 0 ) ) { printf ( "Bind socket failed : %s\n", hwFormatMessage(GetLastError()) ); goto finished; } if ( !Sock.Connect ( g_pServerAddess, SRV_TCP_HOLE_PORT ) ) { printf ( "Connect to [%s:%d] failed : %s\n", g_pServerAddess, SRV_TCP_HOLE_PORT, hwFormatMessage(GetLastError()) ); goto finished; } } catch ( CException e ) { char szError[255] = {0}; e.GetErrorMessage( szError, sizeof(szError) ); printf ( "Exception occur, %s\n", szError ); goto finished; } g_pSock_MakeHole = &Sock; ASSERT ( g_nHolePort == 0 ); VERIFY ( Sock.GetSockName ( csSocketAddress, g_nHolePort ) ); // 创建一个线程来侦听端口 g_nHolePort 的连接请求 dwThreadID = 0; g_hThread_Listen = ::CreateThread ( NULL, 0, ::ThreadProc_Listen, LPVOID(NULL), 0, &dwThreadID ); if (!HANDLE_IS_VALID(g_hThread_Listen) ) return FALSE; Sleep ( 3000 ); // 我(客户端A)向服务器协助打洞的端口号 SRV_TCP_HOLE_PORT 发送申请,希望与新登录的客户端B建立连接 // 服务器会将我的打洞用的外部IP和端口号告诉客户端B ASSERT ( g_WelcomePkt.dwID > 0 ); ReqConnClientPkt.dwInviterID = g_WelcomePkt.dwID; ReqConnClientPkt.dwInvitedID = pNewUserLoginPkt->dwID; if ( Sock.Send ( &ReqConnClientPkt, sizeof(t_ReqConnClientPkt) ) != sizeof(t_ReqConnClientPkt) ) goto finished; // 等待服务器回应,将客户端B的外部IP地址和端口号告诉我(客户端A) nRecvBytes = Sock.Receive ( szRecvBuffer, sizeof(szRecvBuffer) ); if ( nRecvBytes > 0 ) { ASSERT ( nRecvBytes == sizeof(t_SrvReqDirectConnectPkt) ); PACKET_TYPE *pePacketType = (PACKET_TYPE*)szRecvBuffer; ASSERT ( pePacketType && *pePacketType == PACKET_TYPE_TCP_DIRECT_CONNECT ); Sleep ( 1000 ); Handle_SrvReqDirectConnect ( (t_SrvReqDirectConnectPkt*)szRecvBuffer ); printf ( "Handle_SrvReqDirectConnect end\n" ); } // 对方断开连接了 else { goto finished; } bRet = TRUE; finished: g_pSock_MakeHole = NULL; return bRet; }这里假设客户端A先启动,当客户端B启动后客户端A将收到服务器S的新客户端登录的通知,并得到客户端B的公网IP和端口,客户端A启动线程连接S的【协助打洞】端口(本地端口号可以用GetSocketName()函数取得,假设为M),请求S协助TCP打洞,然后启动线程侦听该本地端口(前面假设的M)上的连接请求,然后等待服务器的回应。 // // 客户端A请求我(服务器)协助连接客户端B,这个包应该在打洞Socket中收到 // BOOL CSockClient::Handle_ReqConnClientPkt(t_ReqConnClientPkt *pReqConnClientPkt) { ASSERT ( !m_bMainConn ); CSockClient *pSockClient_B = FindSocketClient ( pReqConnClientPkt->dwInvitedID ); if ( !pSockClient_B ) return FALSE; printf ( "%s:%u:%u invite %s:%u:%u connection\n", m_csPeerAddress, m_nPeerPort, m_dwID, pSockClient_B->m_csPeerAddress, pSockClient_B->m_nPeerPort, pSockClient_B->m_dwID ); // 客户端A想要和客户端B建立直接的TCP连接,服务器负责将A的外部IP和端口号告诉给B t_SrvReqMakeHolePkt SrvReqMakeHolePkt; SrvReqMakeHolePkt.dwInviterID = pReqConnClientPkt->dwInviterID; SrvReqMakeHolePkt.dwInviterHoleID = m_dwID; SrvReqMakeHolePkt.dwInvitedID = pReqConnClientPkt->dwInvitedID; STRNCPY_CS ( SrvReqMakeHolePkt.szClientHoleIP, m_csPeerAddress ); SrvReqMakeHolePkt.nClientHolePort = m_nPeerPort; if ( pSockClient_B->SendChunk ( &SrvReqMakeHolePkt, sizeof(t_SrvReqMakeHolePkt), 0 ) != sizeof(t_SrvReqMakeHolePkt) ) return FALSE; // 等待客户端B打洞完成,完成以后通知客户端A直接连接客户端外部IP和端口号 if ( !HANDLE_IS_VALID(m_hEvtWaitClientBHole) ) return FALSE; if ( WaitForSingleObject ( m_hEvtWaitClientBHole, 6000*1000 ) == WAIT_OBJECT_0 ) { if ( SendChunk ( &m_SrvReqDirectConnectPkt, sizeof(t_SrvReqDirectConnectPkt), 0 ) == sizeof(t_SrvReqDirectConnectPkt) ) return TRUE; } return FALSE; }服务器S收到客户端A的协助打洞请求后通知客户端B,要求客户端B向客户端A打洞,即让客户端B尝试与客户端A的公网IP和端口进行connect。 // // 执行者:客户端B // 处理服务器要我(客户端B)向另外一个客户端(A)打洞,打洞操作在线程中进行。 // 先连接服务器协助打洞的端口号 SRV_TCP_HOLE_PORT ,通过服务器告诉客户端A我(客户端B)的外部IP地址和端口号,然后启动线程进行打洞, // 客户端A在收到这些信息以后会发起对我(客户端B)的外部IP地址和端口号的连接(这个连接在客户端B打洞完成以后进行,所以 // 客户端B的NAT不会丢弃这个SYN包,从而连接能建立) // BOOL Handle_SrvReqMakeHole ( CSocket &MainSock, t_SrvReqMakeHolePkt *pSrvReqMakeHolePkt ) { ASSERT ( pSrvReqMakeHolePkt ); // 创建Socket,连接服务器协助打洞的端口号 SRV_TCP_HOLE_PORT,连接建立以后发送一个断开连接的请求给服务器,然后连接断开 // 这里连接的目的是让服务器知道我(客户端B)的外部IP地址和端口号,以通知客户端A CSocket Sock; try { if ( !Sock.Create () ) { printf ( "Create socket failed : %s\n", hwFormatMessage(GetLastError()) ); return FALSE; } if ( !Sock.Connect ( g_pServerAddess, SRV_TCP_HOLE_PORT ) ) { printf ( "Connect to [%s:%d] failed : %s\n", g_pServerAddess, SRV_TCP_HOLE_PORT, hwFormatMessage(GetLastError()) ); return FALSE; } } catch ( CException e ) { char szError[255] = {0}; e.GetErrorMessage( szError, sizeof(szError) ); printf ( "Exception occur, %s\n", szError ); return FALSE; } CString csSocketAddress; ASSERT ( g_nHolePort == 0 ); VERIFY ( Sock.GetSockName ( csSocketAddress, g_nHolePort ) ); // 连接服务器协助打洞的端口号 SRV_TCP_HOLE_PORT,发送一个断开连接的请求,然后将连接断开,服务器在收到这个包的时候也会将 // 连接断开 t_ReqSrvDisconnectPkt ReqSrvDisconnectPkt; ReqSrvDisconnectPkt.dwInviterID = pSrvReqMakeHolePkt->dwInvitedID; ReqSrvDisconnectPkt.dwInviterHoleID = pSrvReqMakeHolePkt->dwInviterHoleID; ReqSrvDisconnectPkt.dwInvitedID = pSrvReqMakeHolePkt->dwInvitedID; ASSERT ( ReqSrvDisconnectPkt.dwInvitedID == g_WelcomePkt.dwID ); if ( Sock.Send ( &ReqSrvDisconnectPkt, sizeof(t_ReqSrvDisconnectPkt) ) != sizeof(t_ReqSrvDisconnectPkt) ) return FALSE; Sleep ( 100 ); Sock.Close (); // 创建一个线程来向客户端A的外部IP地址、端口号打洞 t_SrvReqMakeHolePkt *pSrvReqMakeHolePkt_New = new t_SrvReqMakeHolePkt; if ( !pSrvReqMakeHolePkt_New ) return FALSE; memcpy ( pSrvReqMakeHolePkt_New, pSrvReqMakeHolePkt, sizeof(t_SrvReqMakeHolePkt) ); DWORD dwThreadID = 0; g_hThread_MakeHole = ::CreateThread ( NULL, 0, ::ThreadProc_MakeHole, LPVOID(pSrvReqMakeHolePkt_New), 0, &dwThreadID ); if (!HANDLE_IS_VALID(g_hThread_MakeHole) ) return FALSE; // 创建一个线程来侦听端口 g_nHolePort 的连接请求 dwThreadID = 0; g_hThread_Listen = ::CreateThread ( NULL, 0, ::ThreadProc_Listen, LPVOID(NULL), 0, &dwThreadID ); if (!HANDLE_IS_VALID(g_hThread_Listen) ) return FALSE; // 等待打洞和侦听完成 HANDLE hEvtAry[] = { g_hEvt_ListenFinished, g_hEvt_MakeHoleFinished }; if ( ::WaitForMultipleObjects ( LENGTH(hEvtAry), hEvtAry, TRUE, 30*1000 ) == WAIT_TIMEOUT ) return FALSE; t_HoleListenReadyPkt HoleListenReadyPkt; HoleListenReadyPkt.dwInvitedID = pSrvReqMakeHolePkt->dwInvitedID; HoleListenReadyPkt.dwInviterHoleID = pSrvReqMakeHolePkt->dwInviterHoleID; HoleListenReadyPkt.dwInvitedID = pSrvReqMakeHolePkt->dwInvitedID; if ( MainSock.Send ( &HoleListenReadyPkt, sizeof(t_HoleListenReadyPkt) ) != sizeof(t_HoleListenReadyPkt) ) { printf ( "Send HoleListenReadyPkt to %s:%u failed : %s\n", g_WelcomePkt.szClientIP, g_WelcomePkt.nClientPort, hwFormatMessage(GetLastError()) ); return FALSE; } return TRUE; }客户端B收到服务器S的打洞通知后,先连接S的【协助打洞】端口号(本地端口号可以用GetSocketName()函数取得,假设为X),启动线程尝试连接客户端A的公网IP和端口号,根据路由器不同,连接情况各异,如果运气好直接连接就成功了,即使连接失败,但打洞便完成了。同时还要启动线程在相同的端口(即与S的【协助打洞】端口号建立连接的本地端口号X)上侦听到来的连接,等待客户端A直接连接该端口号。 // // 执行者:客户端A // 服务器要求主动端(客户端A)直接连接被动端(客户端B)的外部IP和端口号 // BOOL Handle_SrvReqDirectConnect ( t_SrvReqDirectConnectPkt *pSrvReqDirectConnectPkt ) { ASSERT ( pSrvReqDirectConnectPkt ); printf ( "You can connect direct to ( IP:%s PORT:%d ID:%u )\n", pSrvReqDirectConnectPkt->szInvitedIP, pSrvReqDirectConnectPkt->nInvitedPort, pSrvReqDirectConnectPkt->dwInvitedID ); // 直接与客户端B建立TCP连接,如果连接成功说明TCP打洞已经成功了。 CSocket Sock; try { if ( !Sock.Socket () ) { printf ( "Create socket failed : %s\n", hwFormatMessage(GetLastError()) ); return FALSE; } UINT nOptValue = 1; if ( !Sock.SetSockOpt ( SO_REUSEADDR, &nOptValue , sizeof(UINT) ) ) { printf ( "SetSockOpt socket failed : %s\n", hwFormatMessage(GetLastError()) ); return FALSE; } if ( !Sock.Bind ( g_nHolePort ) ) { printf ( "Bind socket failed : %s\n", hwFormatMessage(GetLastError()) ); return FALSE; } for ( int ii=0; ii<100; ii++ ) { if ( WaitForSingleObject ( g_hEvt_ConnectOK, 0 ) == WAIT_OBJECT_0 ) break; DWORD dwArg = 1; if ( !Sock.IOCtl ( FIONBIO, &dwArg ) ) { printf ( "IOCtl failed : %s\n", hwFormatMessage(GetLastError()) ); } if ( !Sock.Connect ( pSrvReqDirectConnectPkt->szInvitedIP, pSrvReqDirectConnectPkt->nInvitedPort ) ) { printf ( "Connect to [%s:%d] failed : %s\n", pSrvReqDirectConnectPkt->szInvitedIP, pSrvReqDirectConnectPkt->nInvitedPort, hwFormatMessage(GetLastError()) ); Sleep (100); } else break; } if ( WaitForSingleObject ( g_hEvt_ConnectOK, 0 ) != WAIT_OBJECT_0 ) { if ( HANDLE_IS_VALID ( g_hEvt_ConnectOK ) ) SetEvent ( g_hEvt_ConnectOK ); printf ( "Connect to [%s:%d] successfully !!!\n", pSrvReqDirectConnectPkt->szInvitedIP, pSrvReqDirectConnectPkt->nInvitedPort ); // 接收测试数据 printf ( "Receiving data ...\n" ); char szRecvBuffer[NET_BUFFER_SIZE] = {0}; int nRecvBytes = 0; for ( int i=0; i<1000; i++ ) { nRecvBytes = Sock.Receive ( szRecvBuffer, sizeof(szRecvBuffer) ); if ( nRecvBytes > 0 ) { printf ( "-->>> Received Data : %s\n", szRecvBuffer ); memset ( szRecvBuffer, 0, sizeof(szRecvBuffer) ); SLEEP_BREAK ( 1 ); } else { SLEEP_BREAK ( 300 ); } } } } catch ( CException e ) { char szError[255] = {0}; e.GetErrorMessage( szError, sizeof(szError) ); printf ( "Exception occur, %s\n", szError ); return FALSE; } return TRUE; } 在客户端B打洞和侦听准备好以后,服务器S回复客户端A,客户端A便直接与客户端B的公网IP和端口进行连接,收发数据可以正常进行,为了测试是否真正地直接TCP连接,在数据收发过程中可以将服务器S强行终止,看是否数据收发还正常进行着。 程序执行步骤和方法:
程序执行成功后的界面:客户端出现“Send Data”或者“Received Data”表示穿越NAT的TCP连接已经建立起来,数据收发已经OK。 ![]() 服务器S ![]() 客户端A ![]() 客户端B 本代码在Windows XP、一个天威局域网、一个电信局域网、一个电话拨号网络中测试通过。 |
|