首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> C語言 >> VC >> 關於VC++

TCP 穿透 NAT 實現 P2P 直連:原理、流程與完整原始碼解析

     編輯:關於VC++

P2P 應用(檔案傳輸、視訊會議、即時通訊)常面臨核心難題:雙方皆位於 NAT 後方,無法直接建立 TCP 連線。UDP 穿透 NAT 技術相對成熟(STUN、TURN、ICE),但 TCP 因連線導向、狀態機複雜,實作難度較高。本文詳細解析 TCP Hole Punching(TCP 打洞) 技術原理、NAT 分類影響、完整交握流程,並提供可編譯執行的 C++ 原始碼(含 Server/Client 端)。

NAT 分類與穿透可行性

依據 RFC 3489 / RFC 4787,NAT 分四類,前三屬 Cone 型,最後一類為 Symmetric:

類型映射行為外部主機可回傳條件TCP 打洞可行性
Full Cone內部 IP:Port → 固定外部 IP:Port任意外部主機✅ 容易
Restricted Cone同上僅內部曾主動連過的外部 IP✅ 可行
Port Restricted Cone同上僅內部曾主動連過的外部 IP:Port✅ 可行(需精確 Port)
Symmetric NAT每次新會話分配新 Port極嚴格,需預測 Port❌ 極難/不可靠

本文實作假設雙方 NAT 均為 Cone 型(含 Port Restricted),此為家用/企業路由器主流類型。

核心拓撲與角色

  • Server (S):擁有公網 IP,雙方均可連線的中介節點
  • Client A:位於 NAT-A 後,主動發起 P2P 連線請求
  • Client B:位於 NAT-B 後,被動接受打洞協助

完整交握流程(8 步驟)

  1. Server 啟動雙監聽:Main Port (4000) 維持長連線;Hole Port (8000) 處理打洞協商。
  2. A、B 連線 Main Port:註冊身分,Server 建立 ID 對應表。
  3. A 請求連線 B:A 連線 Hole Port,發送 ReqConnClientPkt(含雙方 ID),同時在本地相同 Port 監聽(需 SO_REUSEADDR)。
  4. Server 轉達給 B:透過 Main Port 通知 B,並附上 A 的公網 IP:Port(經 NAT-A 映射後)。
  5. B 回連 Server Hole Port:B 連線 Hole Port 並立即斷開,目的是讓 Server 看到 B 的公網 IP:Port(經 NAT-B 映射)。
  6. B 向 A 打洞:B 嘗試 connect(A公網IP, A公網Port)。多數 Cone NAT 會丟棄此 SYN,但NAT-A 會記錄此連入嘗試,建立映射期待後續回包 — 此即「打洞」動作。同時 B 在相同本地 Port 監聽。
  7. B 通知 Ready:打洞與監聽就緒後,B 經 Main Port 告知 Server「我準備好了」。
  8. A 直連 B:Server 轉達 B 的公網 IP:Port 給 A。A 發起 connect(B公網IP, B公網Port)。因步驟 6 NAT-A 已記錄 B 的 IP:Port,視為合法回應,允許通過。**TCP 直連建立成功。**

關鍵技術細節

SO_REUSEADDR — 同一 Port 同時 Listen 與 Connect

步驟 3 與 6 均需在「發起連線」與「監聽回連」使用相同本地 Port。預設 TCP 不允許同 Port 重複綁定,必須設定:

int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

非阻塞 Connect 輪詢

步驟 8 A 直連 B 時,若 B 尚未完成 accept,連線會暫時失敗(WSAEWOULDBLOCK)。採用非阻塞模式輪詢:


u_long mode = 1;
ioctlsocket(sock, FIONBIO, &mode); // 非阻塞
for (int i=0; i<100; ++i) {
    if (connect(sock, ...) == 0) break; // 成功
    if (WSAGetLastError() == WSAEWOULDBLOCK) Sleep(100);
    else break;
}

封包結構定義


#pragma pack(push, 1)
struct T_WelcomePkt       { DWORD dwID; char szClientIP[16]; UINT nClientPort; };
struct T_NewUserLoginPkt  { DWORD dwID; char szClientIP[16]; UINT nClientPort; };
struct T_ReqConnClientPkt { DWORD dwInviterID; DWORD dwInvitedID; };
struct T_SrvReqMakeHolePkt{ DWORD dwInviterID; DWORD dwInviterHoleID; DWORD dwInvitedID; char szClientHoleIP[16]; UINT nClientHolePort; };
struct T_ReqSrvDisconnectPkt { DWORD dwInviterID; DWORD dwInviterHoleID; DWORD dwInvitedID; };
struct T_SrvReqDirectConnectPkt { DWORD dwInvitedID; char szInvitedIP[16]; UINT nInvitedPort; };
struct T_HoleListenReadyPkt { DWORD dwInvitedID; DWORD dwInviterHoleID; };
#pragma pack(pop)

程式架構與執行緒模型

  • Main Thread:維持 Main Port 長連線,處理 Server 推播通知。
  • Hole Thread (Client A):連線 Hole Port → 發送請求 → 等待 Server 回應 B 的公網端點 → 發起直連。
  • Listen Thread (雙方):在 Hole Port 上 accept 對方直連。
  • MakeHole Thread (Client B):連線 Hole Port 取得公網端點 → 向 A 打洞 → 等待 A 直連。
  • 使用 WaitForMultipleObjects 同步多執行緒完成事件。

實測環境與結果

  • OS:Windows XP
  • 網路:天威寬頻 LAN(NAT-A)+ 中華電信 LAN(NAT-B)+ 電話撥號(Server 公網)
  • 路由器:TP-Link R402(支援 Port Restricted Cone)
  • 結果:雙向資料收發正常,中途關閉 Server 不影響已建立的 P2P 連線。

已知限制與後續改進方向

  • ❌ 未處理同一 LAN 內雙客戶端(需比對公網 IP/Subnet 判斷)
  • ❌ 未處理單方擁有公網 IP(可直接連線,無需打洞)
  • ❌ Symmetric NAT 無法穿透(需引入 TCP Relay / TURN)
  • ❌ 錯誤處理、重連機制、封包重組、加密傳輸均未實作

總結

TCP Hole Punching 利用 Cone NAT 的「記錄連入嘗試」特性,透過公網 Server 協雙方交換公網端點並同步打洞時機,成功建立雙向 TCP 直連。本文提供的完整原始碼(Server/Client A/Client B)可直接編譯測試,是理解 NAT 穿透機制的優良教材。實務部署時建議整合 ICE 框架,自動偵測 NAT 類型並選擇最佳策略(UDP 優先、TCP 兜底、Relay 最後手段)。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved