P2P NAT 打洞 穿透


路由設備通過NAT杜絕”陌生人“訪問你的PC,NAT全稱是Network Address Translation,翻譯過來就叫地址轉換協議,有了它,再也不用爲豔照發愁了。

NAT的工作粗略的有兩點:

1.在內網的地址端口與公網的地址端口間建立映射。

2.爲內網的地址建立信任鏈表。

第一點很好理解,假設你的內網IP是192.168.1.2, 路由設備的外網地址是225.211.224.11,現在要上某度查“冠西哥豔照門”,首先瀏覽器得訪問某度的地址,假設是202.96.134.33, 瀏覽器發送一個請求過去,ip 包頭裏包含目標地址和來源地址,這裏目標地址就是202.96.134.33,你的PC沒有直接外網,IP包頭當然就是192.168.1.2,假設不經轉換,某度就會把你的請求返回給它的員工了,因爲192.168.1.2是個局域網地址,要想如願以償的看到冠西哥的豔照,就得把來源地址改成公網IP:225.211.224.11,於是便有了NAT。NAT將會將你丟出去的每個IP包的來源地址改成公網IP,並將爲你的每一個傳輸層端口分配一個公網端口。

這有點不大明白了,爲什麼還要重新分配端口呢,比哪我的TCP內網使用8090端口,NAT上也用8090與之對應不就可以了麼。

問題是如果有兩臺或者幾百臺內網機器會怎樣呢,都對應同樣的端口麼,你不反NAT將返回的結果返回給你的同事麼,呵呵。

第二點的理解其實也很容易,有些噁心的公司喜歡收集大家的口味,你懂的。萬幸有NAT,將它們拒之門外,當然,有時候你願意分享你那獨到的口味,比如你願意讓某度知道你喜歡冠希哥,用192.168.1.2:8090 訪問某度202.96.134.33:80 , 這時候某度就可以將結果發給你了,你又要把口味發到某博:180.149.134.17:80,這樣某博也能發信息給你了,其實NAT已經爲192.168.1.2:8090建立了一個信任鏈表,包括:225.211.224.11:80,180.149.134.17:80,意思是這兩個地址發來的信息都會無條件的轉發給你,別人發來的一概不收。

是不是在說我跑題了,這和P2P有啥關係。

正是有了NAT,P2P就難多了,隨着NAT的推廣,連企鵝的標杆軟件QQ都改用TCP了,當然企鵝還有其他不得已的苦衷,比如某部規定聊天軟件不準用P2P。

正是有了NAT,現在P2P在國內被一個粗的不能再粗的詞替代了:打洞。

哪裏有洞打哪裏,但是NAT的洞從外面是打不開的,要從裏面打,從剛纔的例子中,你能會想到如果A要發信息給B,首先得讓B發個消息給A,這樣B那邊的NAT將會將A的地址放在信任列表裏,不錯,孺子可教也。。。

但是。。。現實是很殘酷的,NAT,尼瑪也是分很多種的。

主要分爲以下四類:

1.Full cone NAT

cone翻譯是圓錐的意思,假設你訪問了某網址,內網地址A_IN:192.168.1.2.8090,映射外網地址A_OUT:225.211.224.11:25511,就等於用圓錐開了個口子,外網的所有地址都可以通過A_OUT訪問你的設備,下面是開解點的:



這樣的路由器你敢買麼,嘿嘿,幸好市面上的多是第二種。

2.Restricted cone NAT( Address-Restricted cone NAT )

restricted 翻譯爲受限,意即根據地址限制,只要你訪問了某度202.96.134.33:80, 則某度的任意端口都可以發數據給你,且看下圖:


這樣的路由器,一向膽小的你還是不敢買是吧,嘿嘿。

3.Port-Restricted cone NAT

端口限制,即如果你訪問了某網址的80端口,它返回的信息一定也得是80端口,別的端口都會被拒絕,且看下圖:


放心了吧,嘿嘿。

4.Symmetric NAT

跟端口限制差不多,不同的地方在於以下NAT映射出來的外網端口。

Port-Restricted 情況下,外網端口由內網端口決定,一一對應,Symmetric NAT情況下由內網端口與外網地址端口共同決定。

怎麼理解呢,比如你訪問某度,NAT的內外網端口分別是8090和25511,下次訪問某博,內網端口如果是8090,外網端口如果還是25511,那就是Port-Restricted ,如果不是,那就是Symmetric NAT。且看下圖:

Port-Restricted :

Symmetric NAT:


對NAT是不是有了一個初步的瞭解了呢,知道應該買哪類了吧,哈哈。


既然P2P可以直接在客戶端間建立連接,爲啥像電驢,QQ這樣的P2P軟件還要登錄呢。

打個比方,假設有兩個用戶,A,B,相關信息如下:


如果A要跟B打洞,如何知道B的外網IP呢,A只能通過B的UDID 去服務器查詢一下對吧。

好,我們得出結論,打洞需要一臺服務器做牽線作用。

打洞的過程咱們分類別來介紹吧,假設服務器端爲S: 125.125.236.25。

Full cone NAT:

按前文的介紹,只要內部突破了一個端口,外網任意地址都能訪問,其流程就變成:

1)A,B在S上登錄,A內網端口爲8090,外網端口7051,B內網端口3352,外網端口6543。

2)A向S查詢B的地址。

3)A向B發送請求,使用7051端口,訪問B的6543端口

4)打洞成功。

何其簡單,可惜這樣的設備現在太少了。


Restricted cone NAT( Address-Restricted cone NAT ):

Port-Restricted cone NAT:

1)A,B在S上登錄,A內網端口爲8090,外網端口7051,B內網端口3352,外網端口6543。

2)A向S查詢B的地址。

3)S把A的地址告訴B。

4)A用8090端口訪問B的6543端口。

5)B用3352端口訪問A的7051端口。

6)打洞成功。

過程是這樣的,但爲什麼要這樣做呢。

看看第4步,A用8090端口訪問B的6543端口,實際是經過NAT交互後,變成7051端口向外發請求,B端NAT會陰斷請求,但是會在A端開通一個通道:A:7051/B:6543。

上圖:


數據流:A(8090)->A(7051)->B(6543)

結果:A(7051)可以接受B(6543)的數據,即:


A端開了一個面向B 6543的錐形(望文生義哈)。

同理第5步也建在B端建立了一個通道:B:6543/A:7051

雙向都建立了通道,就意味着打洞成功了。

最後一種情況:Symmetric NAT,這種類型的P2P打洞我表示壓力很大,且看過程:

1)A,B在S上登錄,A內網端口爲8090,外網端口7051,B內網端口3352,外網端口6543。

2)A向S查詢B的地址。

3)S把A的地址告訴B。

4)A用8090端口訪問B的6543端口。

這一步就出問題了,因爲這裏A內網的8090端口對應外網的端口已經不是7051了,到底多少,S不知道,B也不知道,A也不知道。

好,打洞過程的知道就先說到這。




公網和私網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分,慚愧),所以只好用代碼來說明問題了。



這兩個端口是固定的,服務器S啓動時就開始偵聽這兩個端口了。

// 服務器地址和端口號定義
#define SRV_TCP_MAIN_PORT		4000	// 服務器主連接的端口號
#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; im_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強行終止,看是否數據收發還正常進行着。

程序執行步驟和方法:

1.要準備好環境,如果要真實測試的話需要用2個連到公網上的局域網,1臺具有公網地址的電腦(爲了協助我測試,小曹、小妞可費了不少心,我還霸佔了他們家的電腦,在此表示感謝)。如果不是這樣的環境,程序執行可能會不正常,因爲我暫時未做相同局域網的處理。

2.在具有公網地址的電腦上執行“TcpHoleSrv.exe”程序,假設這臺電腦的公網IP地址是“129.208.12.38”。

3.在局域網A中的一臺電腦上執行“TcpHoleClt-A.exe 129.208.12.38”

4.在局域網B中的一臺電腦上執行“TcpHoleClt-B.exe 129.208.12.38”

程序執行成功後的界面:客戶端出現“Send Data”或者“Received Data”表示穿越NAT的TCP連接已經建立起來,數據收發已經OK。

服務器S

客戶端A

客戶端B

本代碼在Windows XP、一個天威局域網、一個電信局域網、一個電話撥號網絡中測試通過。

由於時間和水平的關係,代碼和文章寫得都不咋的,但願能起到拋磚引玉的作用。代碼部分只是實現了不同局域網之間的客戶端相互連接的問題,至於相同局域網內的主機或者其中一臺客戶端本身就具有公網IP的問題這裏暫時未做考慮(因爲那些處理實在太簡單了,比較一下掩碼或者公網IP就能判斷出來的);另外程序的防錯性代碼重用性也做得不好,只是實現了功能,我想

















發佈了16 篇原創文章 · 獲贊 10 · 訪問量 3萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章