TCP實現P2P通訊、TCP穿越NAT的方法、TCP打洞
Internet的迅速發展以及IPv4 地址數量的限制使得網路地址翻譯(NAT,Network Address Trans2lation)裝置得到廣泛應用。NAT裝置允許處於同一NAT後的多臺主機共享一個公網(本文將處於同一NAT後的網路稱為私網,處於NAT前的網路稱為公網) IP 地址。一個私網IP 地址通過NAT裝置與公網的其他主機通訊。公網和私網IP地址域,如下圖所示:
廣域網與私網示意圖
一般來說都是由私網內主機(例如上圖中“電腦A-01”)主動發起連線,資料包經過NAT地址轉換後送給公網上的伺服器(例如上圖中的“Server”),連線建立以後可雙向傳送資料,NAT裝置允許私網內主機主動向公網內主機發送資料,但卻禁止反方向的主動傳遞,但在一些特殊的場合需要不同私網內的主機進行互聯(例如P2P軟體、網路會議、視訊傳輸等),TCP穿越NAT的問題必須解決。網上關於UDP穿越NAT的文章很多,而且還有配套原始碼,但是我個人認為UDP資料雖然速度快,但是沒有保障,而且NAT為UDP準備的臨時埠號有生命週期的限制,使用起來不夠方便,在需要保證傳輸質量的應用上TCP連線還是首選(例如:檔案傳輸)。
網上也有不少關於TCP穿越NAT(即TCP打洞)的介紹文章,但不幸我還沒找到相關的原始碼可以參考,我利用空餘時間寫了一個可以實現TCP穿越NAT,讓不同的私網內主機建立直接的TCP通訊的原始碼。
這裡需要介紹一下NAT的型別:
NAT裝置的型別對於TCP穿越NAT,有著十分重要的影響,根據埠對映方式,NAT可分為如下4類,前3種NAT型別可統稱為cone型別。
(1)全克隆( Full Cone) : NAT把所有來自相同內部IP地址和埠的請求對映到相同的外部IP地址和埠。任何一個外部主機均可通過該對映傳送IP包到該內部主機。
(2)限制性克隆(Restricted Cone) : NAT把所有來自相同內部IP地址和埠的請求對映到相同的外部IP地址和埠。但是,只有當內部主機先給IP地址為X的外部主機發送IP包,該外部主機才能向該內部主機發送IP包。
(3)埠限制性克隆( Port Restricted Cone) :埠限制性克隆與限制性克隆類似,只是多了埠號的限制,即只有內部主機先向IP地址為X,埠號為P的外部主機發送1個IP包,該外部主機才能夠把源埠號為P的IP包傳送給該內部主機。
(4)對稱式NAT ( Symmetric NAT) :這種型別的NAT與上述3種類型的不同,在於當同一內部主機使用相同的埠與不同地址的外部主機進行通訊時, NAT對該內部主機的對映會有所不同。對稱式NAT不保證所有會話中的私有地址和公開IP之間繫結的一致性。相反,它為每個新的會話分配一個新的埠號。
我們先假設一下:有一個伺服器S在公網上有一個IP,兩個私網分別由NAT-A和NAT-B連線到公網,NAT-A後面有一臺客戶端A,NAT-B後面有一臺客戶端B,現在,我們需要藉助S將A和B建立直接的TCP連線,即由B向A打一個洞,讓A可以沿這個洞直接連線到B主機,就好像NAT-B不存在一樣。
實現過程如下(請參照原始碼):
1、 S啟動兩個網路偵聽,一個叫【主連線】偵聽,一個叫【協助打洞】的偵聽。
2、 A和B分別與S的【主連線】保持聯絡。
3、 當A需要和B建立直接的TCP連線時,首先連線S的【協助打洞】埠,併發送協助連線申請。同時在該埠號上啟動偵聽。注意由於要在相同的網路終端上繫結到不同的套接字上,所以必須為這些套接字設定 SO_REUSEADDR 屬性(即允許重用),否則偵聽會失敗。
4、 S的【協助打洞】連線收到A的申請後通過【主連線】通知B,並將A經過NAT-A轉換後的公網IP地址和埠等資訊告訴B。
5、 B收到S的連線通知後首先與S的【協助打洞】埠連線,隨便傳送一些資料後立即斷開,這樣做的目的是讓S能知道B經過NAT-B轉換後的公網IP和埠號。
6、 B嘗試與A的經過NAT-A轉換後的公網IP地址和埠進行connect,根據不同的路由器會有不同的結果,有些路由器在這個操作就能建立連線(例如我用的TPLink R402),大多數路由器對於不請自到的SYN請求包直接丟棄而導致connect失敗,但NAT-A會紀錄此次連線的源地址和埠號,為接下來真正的連線做好了準備,這就是所謂的打洞,即B向A打了一個洞,下次A就能直接連線到B剛才使用的埠號了。
7、 客戶端B打洞的同時在相同的埠上啟動偵聽。B在一切準備就緒以後通過與S的【主連線】回覆訊息“我已經準備好”,S在收到以後將B經過NAT-B轉換後的公網IP和埠號告訴給A。
8、 A收到S回覆的B的公網IP和埠號等資訊以後,開始連線到B公網IP和埠號,由於在步驟6中B曾經嘗試連線過A的公網IP地址和埠,NAT-A紀錄了此次連線的資訊,所以當A主動連線B時,NAT-B會認為是合法的SYN資料,並允許通過,從而直接的TCP連線建立起來了。
整個實現過程靠文字恐怕很難講清楚,再加上我的語言表達能力很差(高考語文才考75分,總分150分,慚愧),所以只好用程式碼來說明問題了。
// 伺服器地址和埠號定義
// 伺服器主連線的埠號
#define SRV_TCP_MAIN_PORT 4000
// 伺服器響應客戶端打洞申請的埠號
//這兩個埠是固定的,伺服器S啟動時
//就開始偵聽這兩個埠了。
#define SRV_TCP_HOLE_PORT 8000
// 將新客戶端登入資訊傳送給所有已登入的客戶端,但不傳送給自己
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;
}
// 執行者:客戶端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", 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",
hwFormatMessage(GetLastError()) );
goto finished;
}
UINT nOptValue = 1;
if ( !Sock.SetSockOpt ( SO_REUSEADDR, &nOptValue , sizeof(UINT) ) )
{
printf ( "SetSockOpt socket failed : %s",
hwFormatMessage(GetLastError()) );
goto finished;
}
if ( !Sock.Bind ( 0 ) )
{
printf ( "Bind socket failed : %s",
hwFormatMessage(GetLastError()) );
goto finished;
}
if ( !Sock.Connect ( g_pServerAddess, SRV_TCP_HOLE_PORT ) )
{
printf ( "Connect to [%s:%d] failed : %s",
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", 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" );
}
// 對方斷開連線了
else
{
goto finished;
}
bRet = TRUE;
finished:
g_pSock_MakeHole = NULL;
return bRet;
}
// 客戶端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",
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;
}
// 執行者:客戶端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",
hwFormatMessage(GetLastError()) );
return FALSE;
}
if ( !Sock.Connect ( g_pServerAddess, SRV_TCP_HOLE_PORT ) )
{
printf ( "Connect to [%s:%d] failed : %s",
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", 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",
g_WelcomePkt.szClientIP, g_WelcomePkt.nClientPort,
hwFormatMessage(GetLastError()) );
return FALSE;
}
return TRUE;
}
// 執行者:客戶端A
// 伺服器要求主動端(客戶端A)直接連線被動端(客戶端B)的外部IP和埠號
BOOL Handle_SrvReqDirectConnect ( t_SrvReqDirectConnectPkt *pSrvReqDirectConnectPkt )
{
ASSERT ( pSrvReqDirectConnectPkt );
printf ( "You can connect direct to ( IP:%s PORT:%d ID:%u )",
pSrvReqDirectConnectPkt->szInvitedIP,
pSrvReqDirectConnectPkt->nInvitedPort,
pSrvReqDirectConnectPkt->dwInvitedID );
// 直接與客戶端B建立TCP連線,如果連線成功說明TCP打洞已經成功了。
CSocket Sock;
try
{
if ( !Sock.Socket () )
{
printf ( "Create socket failed : %s",
hwFormatMessage(GetLastError()) );
return FALSE;
}
UINT nOptValue = 1;
if ( !Sock.SetSockOpt ( SO_REUSEADDR,
&nOptValue , sizeof(UINT) ) )
{
printf ( "SetSockOpt socket failed : %s",
hwFormatMessage(GetLastError()) );
return FALSE;
}
if ( !Sock.Bind ( g_nHolePort ) )
{
printf ( "Bind socket failed : %s",
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",
hwFormatMessage(GetLastError()) );
}
if ( !Sock.Connect (
pSrvReqDirectConnectPkt->szInvitedIP,
pSrvReqDirectConnectPkt->nInvitedPort ) )
{
printf ( "Connect to [%s:%d] failed : %s",
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 !!!",
pSrvReqDirectConnectPkt->szInvitedIP,
pSrvReqDirectConnectPkt->nInvitedPort );
// 接收測試資料
printf ( "Receiving data ..." );
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", 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", szError );
return FALSE;
}
return TRUE;
}
在客戶端B打洞和偵聽準備好以後,伺服器S回覆客戶端A,客戶端A便直接與客戶端B的公網IP和埠進行連線,收發資料可以正常進行,為了測試是否真正地直接TCP連線,在資料收發過程中可以將伺服器S強行終止,看是否資料收發還正常進行著。程式執行步驟和方法:
要準備好環境,如果要真實測試的話需要用2個連到公網上的區域網,1臺具有公網地址的電腦(為了協助我測試,小曹、小妞可費了不少心,我還霸佔了他們家的電腦,在此表示感謝)。
如果不是這樣的環境,程式執行可能會不正常,因為我暫時未做相同區域網的處理。
在具有公網地址的電腦上執行“TcpHoleSrv.exe”程式,假設這臺電腦的公網IP地址是“129.208.12.38”。
在區域網A中的一臺電腦上執行“TcpHoleClt-A.exe 129.208.12.38”
在區域網B中的一臺電腦上執行“TcpHoleClt-B.exe 129.208.12.38”
程式執行成功後的介面:客戶端出現“Send Data”或者“Received Data”表示穿越NAT的TCP連線已經建立起來,資料收發已經OK。
伺服器S
客戶端A
客戶端B
本程式碼在Windows XP、一個天威區域網、一個電信區域網、一個電話撥號網路中測試通過。
由於時間和水平的關係,程式碼和文章寫得都不咋的,但願能起到拋磚引玉的作用。程式碼部分只是實現了不同區域網之間的客戶端相互連線的問題,至於相同區域網內的主機或者其中一臺客戶端本身就具有公網IP的問題這裡暫時未做考慮(因為那些處理實在太簡單了,比較一下掩碼或者公網IP就能判斷出來的);另外程式的防錯性程式碼重用性也做得不好,只是實現了功能,我想當有新的客戶端連線到伺服器時,伺服器負責將該客戶端的資訊(IP地址、埠號)傳送給其他客戶端。這裡假設客戶端A先啟動,當客戶端B啟動後客戶端A將收到伺服器S的新客戶端登入的通知,並得到客戶端B的公網IP和埠,客戶端A啟動執行緒連線S的【協助打洞】埠(本地埠號可以用GetSocketName()函式取得,假設為M),請求S協助TCP打洞,然後啟動執行緒偵聽該本地埠(前面假設的M)上的連線請求,然後等待伺服器的迴應。
伺服器S收到客戶端A的協助打洞請求後通知客戶端B,要求客戶端B向客戶端A打洞,即讓客戶端B嘗試與客戶端A的公網IP和埠進行connect。
客戶端B收到伺服器S的打洞通知後,先連線S的【協助打洞】埠號(本地埠號可以用GetSocketName()函式取得,假設為X),啟動執行緒嘗試連線客戶端A的公網IP和埠號,根據路由器不同,連線情況各異,如果運氣好直接連線就成功了,即使連線失敗,但打洞便完成了。同時還要啟動執行緒在相同的埠(即與S的【協助打洞】埠號建立連線的本地埠號X)上偵聽到來的連線,等待客戶端A直接連線該埠號。