當(dāng)今互聯(lián)網(wǎng)到處存在著一些中間件(MIddleBoxes)渔呵,如NAT和防火墻,導(dǎo)致兩個(gè)(不在同一內(nèi)網(wǎng))中的客戶端無法直接通信砍鸠。 這些問題即便是到了IPV6時(shí)代也會存在扩氢,因?yàn)榧词共恍枰狽AT,但還有其他中間件如防火墻阻擋了鏈接的建立爷辱。 目前部署的中間件多都是在C/S架構(gòu)上設(shè)計(jì)的录豺,其中相對隱匿的客戶機(jī)主動向周知的服務(wù)端(擁有靜態(tài)IP地址和DNS名稱)發(fā)起鏈接請求。 大多數(shù)中間件實(shí)現(xiàn)了一種非對稱的通訊模型饭弓,即內(nèi)網(wǎng)中的主機(jī)可以初始化對外的鏈接双饥,而外網(wǎng)的主機(jī)卻不能初始化對內(nèi)網(wǎng)的鏈接, 除非經(jīng)過中間件管理員特殊配置弟断。
在中間件為常見的NAPT的情況下(也是本文主要討論的)咏花,內(nèi)網(wǎng)中的客戶端沒有單獨(dú)的公網(wǎng)IP地址, 而是通過NAPT轉(zhuǎn)換阀趴,和其他同一內(nèi)網(wǎng)用戶共享一個(gè)公網(wǎng)IP昏翰。這種內(nèi)網(wǎng)主機(jī)隱藏在中間件后的不可訪問性對于一些客戶端軟件如瀏覽器來說 并不是一個(gè)問題苍匆,因?yàn)槠渲恍枰跏蓟瘜ν獾逆溄樱瑥哪撤矫鎭砜捶炊€對隱私保護(hù)有好處棚菊。然而在P2P應(yīng)用中锉桑, 內(nèi)網(wǎng)主機(jī)(客戶端)需要對另外的終端(Peer)直接建立鏈接,但是發(fā)起者和響應(yīng)者可能在不同的中間件后面窍株, 兩者都沒有公網(wǎng)IP地址民轴。而外部對NAT公網(wǎng)IP和端口主動的鏈接或數(shù)據(jù)都會因內(nèi)網(wǎng)未請求被丟棄掉。本文討論的就是如何跨越NAT實(shí)現(xiàn)內(nèi)網(wǎng)主機(jī)直接通訊的問題球订。
網(wǎng)絡(luò)模型
假設(shè)客戶端A和客戶端B的地址都是內(nèi)網(wǎng)地址后裸,且在不同的NAT后面。A冒滩、B上運(yùn)行的P2P應(yīng)用程序和服務(wù)器S都使用了UDP端口9982微驶,A和B分別初始化了 與Server的UDP通信,地址映射如圖所示:
Server S
207.148.70.129:9981
|
|
+----------------------|----------------------+
| |
NAT A NAT B
120.27.209.161:6000 120.26.10.118:3000
| |
| |
Client A Client B
10.0.0.1:9982 192.168.0.1:9982
現(xiàn)在假設(shè)客戶端A打算與客戶端B直接建立一個(gè)UDP通信會話开睡。如果A直接給B的公網(wǎng)地址120.26.10.118:3000發(fā)送UDP數(shù)據(jù)因苹,NAT B將很可能會無視進(jìn)入的 數(shù)據(jù)(除非是Full Cone NAT),因?yàn)樵吹刂泛投丝谂cS不匹配篇恒,而最初只與S建立過會話扶檐。B往A直接發(fā)信息也類似。
假設(shè)A開始給B的公網(wǎng)地址發(fā)送UDP數(shù)據(jù)的同時(shí)胁艰,給服務(wù)器S發(fā)送一個(gè)中繼請求款筑,要求B開始給A的公網(wǎng)地址發(fā)送UDP信息。A往B的輸出信息會導(dǎo)致NAT A打開 一個(gè)A的內(nèi)網(wǎng)地址與與B的外網(wǎng)地址之間的新通訊會話腾么,B往A亦然奈梳。一旦新的UDP會話在兩個(gè)方向都打開之后,客戶端A和客戶端B就能直接通訊解虱, 而無須再通過引導(dǎo)服務(wù)器S了攘须。
UDP打洞技術(shù)有許多有用的性質(zhì)。一旦一個(gè)的P2P鏈接建立殴泰,鏈接的雙方都能反過來作為“引導(dǎo)服務(wù)器”來幫助其他中間件后的客戶端進(jìn)行打洞于宙, 極大減少了服務(wù)器的負(fù)載。應(yīng)用程序不需要知道中間件具體是什么(如果有的話)艰匙,因?yàn)橐陨系倪^程在沒有中間件或者有多個(gè)中間件的情況下 也一樣能建立通信鏈路限煞。
打洞流程
假設(shè)A現(xiàn)在希望建立一條到B的udp會話,那么這個(gè)建立基本流程是:
1. A,B分別建立到Server S的udp會話,那么Server S此時(shí)是知道A,B各自的外網(wǎng)ip+端口
2. Server S在和B的udp會話里告訴A的地址(外網(wǎng)ip+端口: 120.27.209.161:6000),同理把B的地址(120.26.10.118:3000)告訴A
3. B向A地址(120.27.209.161:6000)發(fā)送一個(gè)"握手"udp包,打通A->B的udp鏈路
4. 此時(shí)A可以向B(120.26.10.118:3000)發(fā)送udp包,A->B的會話建立成功
先決條件
能夠完成打洞有幾個(gè)先決條件:
1. A,B所在的nat網(wǎng)絡(luò)類型(Full cone, Restricted cone, Port-restricted cone, Symmetric NAT)
2. 在一次udp會話期間,nat設(shè)備(路由器)會保持內(nèi)網(wǎng)進(jìn)程 inner_ip:inner_port <-> share_public_ip:share_port的映射關(guān)系,一般根據(jù)具體路由器實(shí)現(xiàn),這個(gè)映射關(guān)系可以維持幾分鐘到幾個(gè)小時(shí)不等
3. 流程中第3步,nat A收到這個(gè)握手包后并不會轉(zhuǎn)發(fā)給A,因?yàn)樗l(fā)現(xiàn)自己的沒有保存過B的地址,認(rèn)為這是一個(gè)來歷不明的包而直接丟棄,然而這個(gè)包的作用在于在nat B留下了A的記錄,使得nat B認(rèn)為A是可達(dá)或者說可通過了,這樣當(dāng)A->B再發(fā)送udp包時(shí)就可以真正到達(dá)B了员凝。所以這個(gè)"握手"包的作用是可以打通A->B的通路,是必要的
源碼示例
使用三臺設(shè)備模擬,外網(wǎng)設(shè)備207.148.70.129模擬Server S,執(zhí)行server.go代碼:
package main
import (
"fmt"
"log"
"net"
"time"
)
func main() {
listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 9981})
if err != nil {
fmt.Println(err)
return
}
log.Printf("本地地址: <%s> \n", listener.LocalAddr().String())
peers := make([]net.UDPAddr, 0, 2)
data := make([]byte, 1024)
for {
n, remoteAddr, err := listener.ReadFromUDP(data)
if err != nil {
fmt.Printf("error during read: %s", err)
}
log.Printf("<%s> %s\n", remoteAddr.String(), data[:n])
peers = append(peers, *remoteAddr)
if len(peers) == 2 {
log.Printf("進(jìn)行UDP打洞,建立 %s <--> %s 的連接\n", peers[0].String(), peers[1].String())
listener.WriteToUDP([]byte(peers[1].String()), &peers[0])
listener.WriteToUDP([]byte(peers[0].String()), &peers[1])
time.Sleep(time.Second * 8)
log.Println("中轉(zhuǎn)服務(wù)器退出,仍不影響peers間通信")
return
}
}
}
另外兩臺分別位于不同內(nèi)網(wǎng)后的設(shè)備,均運(yùn)行相同代碼peer.go:
package main
import (
"fmt"
"log"
"net"
"os"
"strconv"
"strings"
"time"
)
var tag string
const HAND_SHAKE_MSG = "我是打洞消息"
func main() {
// 當(dāng)前進(jìn)程標(biāo)記字符串,便于顯示
tag = os.Args[1]
srcAddr := &net.UDPAddr{IP: net.IPv4zero, Port: 9982} // 注意端口必須固定
dstAddr := &net.UDPAddr{IP: net.ParseIP("207.148.70.129"), Port: 9981}
conn, err := net.DialUDP("udp", srcAddr, dstAddr)
if err != nil {
fmt.Println(err)
}
if _, err = conn.Write([]byte("hello, I'm new peer:" + tag)); err != nil {
log.Panic(err)
}
data := make([]byte, 1024)
n, remoteAddr, err := conn.ReadFromUDP(data)
if err != nil {
fmt.Printf("error during read: %s", err)
}
conn.Close()
anotherPeer := parseAddr(string(data[:n]))
fmt.Printf("local:%s server:%s another:%s\n", srcAddr, remoteAddr, anotherPeer.String())
// 開始打洞
bidirectionHole(srcAddr, &anotherPeer)
}
func parseAddr(addr string) net.UDPAddr {
t := strings.Split(addr, ":")
port, _ := strconv.Atoi(t[1])
return net.UDPAddr{
IP: net.ParseIP(t[0]),
Port: port,
}
}
func bidirectionHole(srcAddr *net.UDPAddr, anotherAddr *net.UDPAddr) {
conn, err := net.DialUDP("udp", srcAddr, anotherAddr)
if err != nil {
fmt.Println(err)
}
defer conn.Close()
// 向另一個(gè)peer發(fā)送一條udp消息(對方peer的nat設(shè)備會丟棄該消息,非法來源),用意是在自身的nat設(shè)備打開一條可進(jìn)入的通道,這樣對方peer就可以發(fā)過來udp消息
if _, err = conn.Write([]byte(HAND_SHAKE_MSG)); err != nil {
log.Println("send handshake:", err)
}
go func() {
for {
time.Sleep(10 * time.Second)
if _, err = conn.Write([]byte("from [" + tag + "]")); err != nil {
log.Println("send msg fail", err)
}
}
}()
for {
data := make([]byte, 1024)
n, _, err := conn.ReadFromUDP(data)
if err != nil {
log.Printf("error during read: %s\n", err)
} else {
log.Printf("收到數(shù)據(jù):%s\n", data[:n])
}
}
}
注意代碼僅模擬打洞基礎(chǔ)流程,如果讀者測試網(wǎng)絡(luò)情況較差發(fā)生udp丟包,可能看不到預(yù)期結(jié)果,此時(shí)簡單重啟server,peer即可.
udp打洞轉(zhuǎn)tcp通信
通常,由于udp打洞實(shí)現(xiàn)簡單,p2p的實(shí)現(xiàn)采用udp打洞較多,然而當(dāng)通路建立起來后使用tcp進(jìn)行節(jié)點(diǎn)間通信可以獲取更好的通信效果。因?yàn)閡dp打洞完成后形成的nat映射是和tcp/udp無關(guān)的,所以此時(shí)可以轉(zhuǎn)為使用tcp建立連接,達(dá)到最終的p2p的tcp通信.由于代碼較簡單,這里就不給出示例了奋献。