P2P之UDP穿透NAT的原理和實現

P2P之UDP穿透NAT的原理和實現(附原始碼) 論壇上經常有對P2P原理的討論,不過討論歸討論,非常少有實質的東西產生(原始碼)。呵呵,在這裏我就用自己實現的一個原始碼來說明UDP穿越NAT的原理。 首先先介紹一些基本概念: NAT(Network Address Translators),網絡地址轉換:網絡地址轉換是在IP地址日益缺乏的情況下產生的,他的主要目的就是爲了能夠地址重用。NAT分爲兩大類,基本 的NAT和NAPT(Network Address/Port Translator)。 最開始NAT是運行在路由器上的一個功能模塊。 最先提出的是基本的NAT,他的產生基於如下事實:一個私有網絡(域)中的節點中只有非常少的節點需要和外網連接(呵呵,這是在上世紀90年代中期提出的)。那麼這個子網中其實只有少數的節點需要全球唯一的IP地址,其他的節點的IP地址應該是能重用的。 因此,基本的NAT實現的功能非常簡單,在子網內使用一個保留的IP子網段,這些IP對外是不可見的。子網內只有少數一些IP地址能對應到真正全球唯一的 IP地址。如果這些節點需要訪問外部網絡,那麼基本NAT就負責將這個節點的子網內IP轉化爲一個全球唯一的IP然後發送出去。(基本的NAT會改動IP 包中的原IP地址,不過不會改動IP包中的端口) 關於基本的NAT能參看RFC 1631 另外一種NAT叫做NAPT,從名稱上我們也能看得出,NAPT不僅會改動經過這個NAT設備的IP數據報的IP地址,還會改動IP數據報的 TCP/UDP端口。基本NAT的設備可能我們見的不多(呵呵,我沒有見到過),NAPT纔是我們真正討論的主角。看下圖: Server S1 18.181.0.31:1235 | ^ Session 1 (A-S1) ^ | | 18.181.0.31:1235 | | v 155.99.25.11:62000 v | | NAT 155.99.25.11 | ^ Session 1 (A-S1) ^ | | 18.181.0.31:1235 | | v 10.0.0.1:1234 v | | Client A 10.0.0.1:1234 有一個私有網絡10.*.*.*,Client A是其中的一臺計算機,這個網絡的網關(一個NAT設備)的外網IP是155.99.25.11(應該更有一個內網的IP地址,比如 10.0.0.10)。如果Client A中的某個進程(這個進程創建了一個UDP Socket,這個Socket綁定1234端口)想訪問外網主機18.181.0.31的1235端口,那麼當數據包通過NAT時會發生什麼事情呢? 首先NAT會改動這個數據包的原IP地址,改爲155.99.25.11。接着NAT會爲這個傳輸創建一個Session(Session是個抽象的概 念,如果是TCP,也許Session是由一個SYN包開始,以一個FIN包結束。而UDP呢,以這個IP的這個端口的第一個UDP開始,結束呢,呵呵, 也許是幾分鐘,也許是幾小時,這要看具體的實現了)並且給這個Session分配一個端口,比如62000,然後改動這個數據包的源端口爲62000。所 以本來是(10.0.0.1:1234->18.181.0.31:1235)的數據包到了互連網上變爲了(155.99.25.11:62000 ->18.181.0.31:1235)。 一旦NAT創建了一個Session後,NAT會記住62000端口對應的是10.0.0.1的1234端口,以後從18.181.0.31發送到 62000端口的數據會被NAT自動的轉發到10.0.0.1上。(注意:這裏是說18.181.0.31發送到62000端口的數據會被轉發,其他的 IP發送到這個端口的數據將被NAT拋棄)這樣Client A就和Server S1建立以了一個連接。 呵呵,上面的基礎知識可能非常多人都知道了,那麼下面是關鍵的部分了。 看看下面的情況: Server S1 Server S2 18.181.0.31:1235 138.76.29.7:1235 | | | | +----------------------+----------------------+ | ^ Session 1 (A-S1) ^ | ^ Session 2 (A-S2) ^ | 18.181.0.31:1235 | | | 138.76.29.7:1235 | v 155.99.25.11:62000 v | v 155.99.25.11:62000 v | Cone NAT 155.99.25.11 | ^ Session 1 (A-S1) ^ | ^ Session 2 (A-S2) ^ | 18.181.0.31:1235 | | | 138.76.29.7:1235 | v 10.0.0.1:1234 v | v 10.0.0.1:1234 v | Client A 10.0.0.1:1234 接上面的例子,如果Client A的原來那個Socket(綁定了1234端口的那個UDP Socket)又接着向另外一個Server S2發送了一個UDP包,那麼這個UDP包在通過NAT時會怎麼樣呢? 這時可能會有兩種情況發生,一種是NAT再次創建一個Session,並且再次爲這個Session分配一個端口號(比如:62001)。另外一種是 NAT再次創建一個Session,不過不會新分配一個端口號,而是用原來分配的端口號62000。前一種NAT叫做Symmetric NAT,後一種叫做Cone NAT。我們期望我們的NAT是第二種,呵呵,如果你的NAT剛好是第一種,那麼非常可能會有非常多P2P軟件失靈。(能慶幸的是,目前絕大多數的NAT屬 於後者,即Cone NAT) 好了,我們看到,通過NAT,子網內的計算機向外連結是非常容易的(NAT相當於透明的,子網內的和外網的計算機不用知道NAT的情況)。 不過如果外部的計算機想訪問子網內的計算機就比較困難了(而這正是P2P所需要的)。 那麼我們如果想從外部發送一個數據報給內網的計算機有什麼辦法呢?首先,我們必須在內網的NAT上打上一個“洞”(也就是前面我們說的在NAT上建立一個 Session),這個洞不能由外部來打,只能由內網內的主機來打。而且這個洞是有方向的,比如從內部某臺主機(比如:192.168.0.10)向外部 的某個IP(比如:219.237.60.1)發送一個UDP包,那麼就在這個內網的NAT設備上打了一個方向爲219.237.60.1的“洞”,(這 就是稱爲UDP Hole Punching的技術)以後219.237.60.1就能通過這個洞和內網的192.168.0.10聯繫了。(不過其他的IP不能利用這個洞)。 呵呵,目前該輪到我們的正題P2P了。有了上面的理論,實現兩個內網的主機通訊就差最後一步了:那就是雞生蛋還是蛋生雞的問題了,兩邊都無法主動發出連接請求,誰也不知道誰的公網地址,那我們怎麼來打這個洞呢?我們需要一箇中間人來聯繫這兩個內網主機。 目前我們來看看一個P2P軟件的流程,以下圖爲例: Server S (219.237.60.1) | | +----------------------+----------------------+ | | NAT A (外網IP:202.187.45.3) NAT B (外網IP:187.34.1.56) | (內網IP:192.168.0.1) | (內網IP:192.168.0.1) | | Client A (192.168.0.20:4000) Client B (192.168.0.10:40000) 首先,Client A登錄服務器,NAT A爲這次的Session分配了一個端口60000,那麼Server S收到的Client A的地址是202.187.45.3:60000,這就是Client A的外網地址了。同樣,Client B登錄Server S,NAT B給此次Session分配的端口是40000,那麼Server S收到的B的地址是187.34.1.56:40000。 此時,Client A和Client B都能和Server S通信了。如果Client A此時想直接發送信息給Client B,那麼他能從Server S那兒獲得B的公網地址187.34.1.56:40000,是不是Client A向這個地址發送信息Client B就能收到了呢?答案是不行,因爲如果這樣發送信息,NAT B會將這個信息丟棄(因爲這樣的信息是不請自來的,爲了安全,大多數NAT都會執行丟棄動作)。目前我們需要的是在NAT B上打一個方向爲202.187.45.3(即Client A的外網地址)的洞,那麼Client A發送到187.34.1.56:40000的信息,Client B就能收到了。這個打洞命令由誰來發呢,呵呵,當然是Server S。 總結一下這個過程:如果Client A想向Client B發送信息,那麼Client A發送命令給Server S,請求Server S命令Client B向Client A方向打洞。呵呵,是不是非常繞口,不過沒關係,想一想就非常清晰了,何況更有原始碼呢(侯老師說過:在原始碼面前沒有祕密 8)),然後Client A就能通過Client B的外網地址和Client B通信了。 注意:以上過程只適合於Cone NAT的情況,如果是Symmetric NAT,那麼當Client B向Client A打洞的端口已重新分配了,Client B將無法知道這個端口(如果Symmetric NAT的端口是順序分配的,那麼我們或許能猜測這個端口號,可是由於可能導致失敗的因素太多,我們不推薦這種猜測端口的方法)。 下面是個模擬P2P聊天的過程的原始碼,過程非常簡單,P2PServer運行在一個擁有公網IP的計算機上,P2PClient運行在兩個不同的NAT 後(注意,如果兩個客戶端運行在一個NAT後,本程式非常可能不能運行正常,這取決於你的NAT是否支持loopback translation,詳見 http://midcom-p2p.sourceforge.net/draft-ford-midcom-p2p-01.txt , 當然,此問題能通過雙方先嚐試連接對方的內網IP來解決,不過這個代碼只是爲了驗證原理,並沒有處理這些問題),後登錄的計算機能獲得先登錄計算機的 用戶名,後登錄的計算機通過send username message的格式來發送消息。如果發送成功,說明你已取得了直接和對方連接的成功。 程式目前支持三個命令:send , getu , exit send格式:send username message 功能:發送信息給username getu格式:getu 功能:獲得當前服務器用戶列表 exit格式:exit 功能:註銷和服務器的連接(服務器不會自動監測客戶是否吊線) 代碼非常短,相信非常容易懂,如果有什麼問題,能給我發郵件 [email protected] 或在CSDN上發送短消息。同時,歡迎轉發此文,但希望保留作者版權8-)。 最後感謝CSDN網友 PiggyXP 和 Seilfer的測試幫助 P2PServer.c /* P2P 程式服務端 * * 文件名:P2PServer.c * * 日期:2004-5-21 * * 作者:shootingstars( [email protected] ) * */ #pragma comment(lib, "ws2_32.lib") #include "windows.h" #include "..\proto.h" #include "..\Exception.h" UserList ClientList; void InitWinSock() { WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { printf("視窗系統 sockets 2.2 startup"); throw Exception(""); } else{ printf("Using %s (Status: %s)\n", wsaData.szDescription, wsaData.szSystemStatus); printf("with API versions %d.%d to %d.%d\n\n", LOBYTE(wsaData.wVersion), HIBYTE(wsaData.wVersion), LOBYTE(wsaData.wHighVersion), HIBYTE(wsaData.wHighVersion)); } } SOCKET mksock(int type) { SOCKET sock = socket(AF_INET, type, 0); if (sock userName), username) == 0 ) return *(*UserIterator); } throw Exception("not find this user"); } int main(int argc, char* argv[]) { try{ InitWinSock(); SOCKET PrimaryUDP; PrimaryUDP = mksock(SOCK_DGRAM); sockaddr_in local; local.sin_family=AF_INET; local.sin_port= htons(SERVER_PORT); local.sin_addr.s_addr = htonl(INADDR_ANY); int nResult=bind(PrimaryUDP,(sockaddr*)&local,sizeof(sockaddr)); if(nResult==SOCKET_ERROR) throw Exception("bind error"); sockaddr_in sender; stMessage recvbuf; memset(&recvbuf,0,sizeof(stMessage)); // 開始主循環. // 主循環負責下面幾件事情: // 一:讀取客戶端登陸和登出消息,記錄客戶列表 // 二:轉發客戶p2p請求 for(;;) { int dwSender = sizeof(sender); int ret = recvfrom(PrimaryUDP, (char *)&recvbuf, sizeof(stMessage), 0, (sockaddr *)&sender, &dwSender); if(ret userName, recvbuf.message.loginmember.userName); currentuser->ip = ntohl(sender.sin_addr.S_un.S_addr); currentuser->port = ntohs(sender.sin_port); ClientList.push_back(currentuser); // 發送已登陸的客戶信息 int nodecount = (int)ClientList.size(); sendto(PrimaryUDP, (const char*)&nodecount, sizeof(int), 0, (const sockaddr*)&sender, sizeof(sender)); for(UserList::iterator UserIterator=ClientList.begin(); UserIterator!=ClientList.end(); ++UserIterator) { sendto(PrimaryUDP, (const char*)(*UserIterator), sizeof(stUserListNode), 0, (const sockaddr*)&sender, sizeof(sender)); } break; } case LOGOUT: { // 將此客戶信息刪除 printf("has a user logout : %s\n", recvbuf.message.logoutmember.userName); UserList::iterator removeiterator = NULL; for(UserList::iterator UserIterator=ClientList.begin(); UserIterator!=ClientList.end(); ++UserIterator) { if( strcmp( ((*UserIterator)->userName), recvbuf.message.logoutmember.userName) == 0 ) { removeiterator = UserIterator; break; } } if(removeiterator != NULL) ClientList.remove(*removeiterator); break; } case P2PTRANS: { // 某個客戶希望服務端向另外一個客戶發送一個打洞消息 printf("%s wants to p2p %s\n",inet_ntoa(sender.sin_addr),recvbuf.message.translatemessage.userName); stUserListNode node = GetUser(recvbuf.message.translatemessage.userName); sockaddr_in remote; remote.sin_family=AF_INET; remote.sin_port= htons(node.port); remote.sin_addr.s_addr = htonl(node.ip); in_addr tmp; tmp.S_un.S_addr = htonl(node.ip); printf("the address is %s,and port is %d\n",inet_ntoa(tmp), node.port); stP2PMessage transMessage; transMessage.iMessageType = P2PSOMEONEWANTTOCALLYOU; transMessage.iStringLen = ntohl(sender.sin_addr.S_un.S_addr); transMessage.Port = ntohs(sender.sin_port); sendto(PrimaryUDP,(const char*)&transMessage, sizeof(transMessage), 0, (const sockaddr *)&remote, sizeof(remote)); break; } case GETALLUSER: { int command = GETALLUSER; sendto(PrimaryUDP, (const char*)&command, sizeof(int), 0, (const sockaddr*)&sender, sizeof(sender)); int nodecount = (int)ClientList.size(); sendto(PrimaryUDP, (const char*)&nodecount, sizeof(int), 0, (const sockaddr*)&sender, sizeof(sender)); for(UserList::iterator UserIterator=ClientList.begin(); UserIterator!=ClientList.end(); ++UserIterator) { sendto(PrimaryUDP, (const char*)(*UserIterator), sizeof(stUserListNode), 0, (const sockaddr*)&sender, sizeof(sender)); } break; } } } } } catch(Exception &e) { printf(e.GetMessage()); return 1; } return 0; } /* P2P 程式客戶端 * * 文件名:P2PClient.c * * 日期:2004-5-21 * * 作者:shootingstars( [email protected] ) * */ #pragma comment(lib,"ws2_32.lib") #include "windows.h" #include "..\proto.h" #include "..\Exception.h" #include using namespace std; UserList ClientList; #define COMMANDMAXC 256 #define MAXRETRY 5 SOCKET PrimaryUDP; char UserName[10]; char ServerIP[20]; bool RecvedACK; void InitWinSock() { WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { printf("視窗系統 sockets 2.2 startup"); throw Exception(""); } else{ printf("Using %s (Status: %s)\n", wsaData.szDescription, wsaData.szSystemStatus); printf("with API versions %d.%d to %d.%d\n\n", LOBYTE(wsaData.wVersion), HIBYTE(wsaData.wVersion), LOBYTE(wsaData.wHighVersion), HIBYTE(wsaData.wHighVersion)); } } SOCKET mksock(int type) { SOCKET sock = socket(AF_INET, type, 0); if (sock userName), username) == 0 ) return *(*UserIterator); } throw Exception("not find this user"); } void BindSock(SOCKET sock) { sockaddr_in sin; sin.sin_addr.S_un.S_addr = INADDR_ANY; sin.sin_family = AF_INET; sin.sin_port = 0; if (bind(sock, (struct sockaddr*)&sin, sizeof(sin)) userNameip); coutportuserName), UserName) == 0 ) { UserIP = (*UserIterator)->ip; UserPort = (*UserIterator)->port; FindUser = true; } } if(!FindUser) return false; strcpy(realmessage, Message); for(int i=0;iuserNameip); coutport>ServerIP; cout>UserName; ConnectToServer(PrimaryUDP, UserName, ServerIP); HANDLE threadhandle = CreateThread(NULL, 0, RecvThreadProc, NULL, NULL, NULL); CloseHandle(threadhandle); OutputUsage(); for(;;) { char Command[COMMANDMAXC]; gets(Command); ParseCommand(Command); } } catch(Exception &e) { printf(e.GetMessage()); return 1; } return 0; } /* 異常類 * * 文件名:Exception.h * * 日期:2004.5.5 * * 作者:shootingstars( [email protected] ) */ #ifndef __HZH_Exception__ #define __HZH_Exception__ #define EXCEPTION_MESSAGE_MAXLEN 256 #include "string.h" class Exception { private: char m_ExceptionMessage[EXCEPTION_MESSAGE_MAXLEN]; public: Exception(char *msg) { strncpy(m_ExceptionMessage, msg, EXCEPTION_MESSAGE_MAXLEN); } char *GetMessage() { return m_ExceptionMessage; } }; #endif /* P2P 程式傳輸協議 * * 日期:2004-5-21 * * 作者:shootingstars( [email protected] ) * */ #pragma once #include // 定義iMessageType的值 #define LOGIN 1 #define LOGOUT 2 #define P2PTRANS 3 #define GETALLUSER 4 // 服務器端口 #define SERVER_PORT 2280 // Client登錄時向服務器發送的消息 struct stLoginMessage { char userName[10]; char password[10]; }; // Client註銷時發送的消息 struct stLogoutMessage { char userName[10]; }; // Client向服務器請求另外一個Client(userName)向自己方向發送UDP打洞消息 struct stP2PTranslate { char userName[10]; }; // Client向服務器發送的消息格式 struct stMessage { int iMessageType; union _message { stLoginMessage loginmember; stLogoutMessage logoutmember; stP2PTranslate translatemessage; }message; }; // 客戶節點信息 struct stUserListNode { char userName[10]; unsigned int ip; unsigned short port; }; // Server向Client發送的消息 struct stServerToClient { int iMessageType; union _message { stUserListNode user; }message; }; //====================================== // 下面的協議用於客戶端之間的通信 //====================================== #define P2PMESSAGE 100 // 發送消息 #define P2PMESSAGEACK 101 // 收到消息的應答 #define P2PSOMEONEWANTTOCALLYOU 102 // 服務器向客戶端發送的消息 // 希望此客戶端發送一個UDP打洞包 #define P2PTRASH 103 // 客戶端發送的打洞包,接收端應該忽略此消息 // 客戶端之間發送消息格式 struct stP2PMessage { int iMessageType; int iStringLen; // or IP address unsigned short Port; }; using namespace std; typedef list UserList;
發佈了27 篇原創文章 · 獲贊 6 · 訪問量 13萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章