本文中的算法需要「雙重桶」機制以確保可靠性。否則,雙方可能在桶邊界分裂,導致無法收斂。如果第一個桶失敗,則計算額外的桶步驟以達成收斂。我的算法以較慢的啟動速度換取較少的基礎設施需求。

TCP 穿透是一種連接兩台位於 NAT 路由器後的電腦的方法。此技術有許多必要條件才能運作。

雙方必須知道彼此的 WAN IP

必須知道正確的外部端口

必須在完全相同的時間連接

實務上,這通常需要使用 STUN 查詢 WAN IP、進行 NAT 類型枚舉與端口預測、透過 NTP 同步時間,並且雙方透過某種「通道」交換所有必要的元資料(WAN IP、端口預測、未來的 NTP 穿透時間)。

這涉及一長串基礎設施與程式碼,既複雜又容易出錯。如果你只想測試穿透算法是否有效,而不在意其他軟體部分,這裡提供一種方法。

繞過穿透算法中固定基礎設施需求的簡單方法,是使用確定性算法從單一參數推導所有元資料。運作方式如下。

首先,根據 unix 時間戳選擇起始參數。我們需要一個雙方都能收斂的數字,且不需任何通訊。然而,分散式系統理論告訴我們,網路中沒有「現在」這個概念。因此,我們用數學方法來創造「現在」。

我們定義接受的解決方案標準。協議必須在一定時間內完成,且時間戳必須落在特定範圍內才視為有效。這些資訊被結合並量化,使雙方即使有時鐘偏差也能收斂到相同數字,我們稱之為「桶」。

既然雙方共享相同的桶,我們就能用它推導出共享端口清單。端口清單的想法是本地端口等於外部端口。許多家用路由器嘗試在外部映射中保留來源端口,這稱為「等差映射」特性。雖然並非所有路由器都支援,但為了簡化,我們在算法中犧牲部分覆蓋率。

為了在雙方無通訊的情況下產生共享端口清單,桶被用作偽隨機數生成器的種子。這讓桶數字的轉換更平滑,避免直接變化過小。

FF…數字限制邊界範圍,避免種子溢位。乘數使用質數,因為若乘數與模數(0xff..)有公因數,可能縮小數字空間。質數確保數字空間包含唯一項目。

端口範圍透過 randrange 函數計算。在我的程式碼中,我生成 16 個端口,並丟棄無法綁定的端口。由於隨機選擇端口,預期會與作業系統中現有程式端口衝突。

在繼續之前,回顧 TCP 穿透的需求很有幫助。必須設定非常特定的 socket 選項才能成功。

TCP 穿透涉及積極重用 socket 地址。一般 TCP 連線失敗會關閉 socket,但穿透中若做任何清理(如呼叫 close),會破壞協議。close 可能會發送 RST 封包告訴遠端路由器「連線錯誤,忽略」。

關閉 socket 後,作業系統會啟動清理程序,socket 會進入 TIME_WAIT 等狀態,即使設定正確選項,也難以可靠重用相同地址。我也補充,TCP 穿透唯一正確的模型是使用非阻塞 socket。阻塞 socket 無法快速發送 SYN 封包且不等待回應。

非同步網路也不適用,因為它無法精確控制時序,而 TCP 穿透對時序極為敏感。封包交換若延遲幾毫秒,整個協議可能失敗。我認為最好的實作方式是使用非阻塞 socket 搭配 select 輪詢,能妥善處理每個連線狀態且不影響時序。

穿透時,我們簡單地對 (dest_ip, port) 呼叫 connect_ex,並以 0.01 秒間隔睡眠直到逾時(src_port == dest_port)。實際穿透並不優雅,因為只是瘋狂發送 SYN 封包。但這部分必須足夠積極以建立遠端映射,且不能過度耗費 CPU。我使用 0.01 秒睡眠。

此算法使用多個端口,因此可返回多個成功連線。問題是如何選擇相同連線?我的方法是先讓雙方選出領導者與追隨者。領導者擁有較大的 WAN IP。領導者在連線上傳送單一字元,並乾淨地關閉其他連線。

追隨者使用 select 輪詢事件,若發現事件則呼叫 recv(1) 選擇該連線(勝利者)。使用單一字元是因為 TCP 是串流式,若成功分隔符是字串,追隨者必須實作緩衝讀取器來確保完整接收,增加複雜度。單字元是原子操作。

我向你介紹:一個簡單的 TCP 穿透算法,只需目標 IP 即可使用。由於協議是確定性的,測試不需基礎設施,也不需主機間交換元資料。主機仍可選擇使用 NTP 同步時間,雖非必須,但建議使用(舊版虛擬機 OS 時間維持較差)。

假設有其他程序協調此工具運行。你也可在多個終端自行執行,只要命令在 10 秒最小運行窗口內。此處穿透適用於使用等差分配的常見路由器。

你可以執行 tcp_punch.py 127.0.0.1 來體驗程式碼。