本文将详细讲解内网穿透的基本原理,尤其是使用 Go 语言实现的基于信令服务器的 UDP 打洞(NAT 穿透)方法。通过本文你将了解每个步骤的原理,并获得完整可运行的示例代码,助你轻松构建点对点通信通道。
一、什么是内网穿透?
内网穿透(NAT Traversal)是指在没有公网 IP 的情况下,让处于不同局域网的设备能够直接通信的技术。
通常,家庭或企业网络的设备都在 NAT(网络地址转换)路由器之后,这样外部设备无法主动访问这些设备。
为了让这些设备之间能够 P2P 通信,需要借助一些策略进行 NAT 穿透。
二、常见内网穿透方式
- 中继服务器(TURN):借助公网中继服务器转发数据,稳定但耗带宽。
- 打洞(UDP Hole Punching):通过 NAT 的端口映射机制建立直接连接,是最常见的高效方案。
- 端口映射(UPnP):需要路由器支持,一般用于家庭设备。
本文重点介绍打洞方式。
三、UDP 打洞的原理
参与角色
角色 | 描述 |
---|---|
A/B | 两个客户端,处于不同的局域网 |
A-NAT/B-NAT | 各自的 NAT 路由器 |
Signaling Server | 有公网 IP 的信令服务器,用于协调通信 |
步骤详解
1. 注册自身地址
- A 和 B 向信令服务器发送注册请求,NAT 路由器自动为它们分配映射的公网地址和端口。
2. 获取对方地址
- 信令服务器返回对方的公网地址和端口。
3. 打洞
- A 和 B 开始向对方的地址发送 UDP 包。
- NAT 记录了“曾向该地址发出过请求”,从而允许外部数据包进入。
4. 成功建立 P2P 通信
- 经过多次尝试后,双方成功建立直连通道,之后的数据不再经过信令服务器。
小贴士
- 信令服务器必须有公网 IP。
- 若遇到对称 NAT,可能打洞失败,需使用 TURN 中继。
四、基于 Go 实现 UDP 打洞
我们使用 Go 实现一个简单的系统,包括:
- 信令服务器
signaling_server.go
- 客户端
client.go
1. 信令服务器
// signaling_server.go
package main
import (
"fmt"
"net"
"strings"
"sync"
)
var peers = make(map[string]*net.UDPAddr)
var mu sync.Mutex
func main() {
addr, _ := net.ResolveUDPAddr("udp", ":9999")
conn, _ := net.ListenUDP("udp", addr)
defer conn.Close()
fmt.Println("Signaling server listening on port 9999")
buf := make([]byte, 1024)
for {
n, clientAddr, _ := conn.ReadFromUDP(buf)
msg := string(buf[:n])
tokens := strings.Split(msg, " ")
if tokens[0] == "REGISTER" && len(tokens) == 2 {
name := tokens[1]
mu.Lock()
peers[name] = clientAddr
mu.Unlock()
fmt.Printf("Registered %s: %s\n", name, clientAddr.String())
} else if tokens[0] == "GET" && len(tokens) == 2 {
target := tokens[1]
mu.Lock()
targetAddr, ok := peers[target]
mu.Unlock()
if ok {
response := fmt.Sprintf("PEER %s %s", target, targetAddr.String())
conn.WriteToUDP([]byte(response), clientAddr)
}
}
}
}
2.信令服务器
// client.go
package main
import (
"fmt"
"net"
"os"
"strings"
"time"
)
func main() {
if len(os.Args) != 4 {
fmt.Println("Usage: go run client.go <your-name> <peer-name> <signaling-server-ip>")
return
}
selfName := os.Args[1]
peerName := os.Args[2]
serverIP := os.Args[3]
localAddr, _ := net.ResolveUDPAddr("udp", ":0")
conn, _ := net.ListenUDP("udp", localAddr)
defer conn.Close()
serverAddr, _ := net.ResolveUDPAddr("udp", serverIP+":9999")
regMsg := fmt.Sprintf("REGISTER %s", selfName)
conn.WriteToUDP([]byte(regMsg), serverAddr)
time.Sleep(1 * time.Second)
getMsg := fmt.Sprintf("GET %s", peerName)
conn.WriteToUDP([]byte(getMsg), serverAddr)
var peerAddr *net.UDPAddr
buf := make([]byte, 1024)
go func() {
for {
n, addr, _ := conn.ReadFromUDP(buf)
msg := string(buf[:n])
if strings.HasPrefix(msg, "PEER") {
parts := strings.Split(msg, " ")
if len(parts) == 3 {
peerAddr, _ = net.ResolveUDPAddr("udp", parts[2])
fmt.Printf("Peer address: %s\n", peerAddr.String())
}
} else {
fmt.Printf("Received from %s: %s\n", addr.String(), msg)
}
}
}()
for peerAddr == nil {
time.Sleep(500 * time.Millisecond)
}
go func() {
for {
msg := fmt.Sprintf("HELLO from %s", selfName)
conn.WriteToUDP([]byte(msg), peerAddr)
time.Sleep(1 * time.Second)
}
}()
select {}
}
3. 启动方式
# 启动服务器
$ go run signaling_server.go
# 启动 A 客户端
$ go run client.go A B 服务器IP
# 启动 B 客户端
$ go run client.go B A 服务器IP
五、总结
使用UDP 实现NAT 打洞,本质上就是通过同时发送请求来欺骗NAT 服务器,让他误认为外部的请求是局域网内设备发送请求的响应。同时UDP 打洞只能支持UDP协议发送数据, 如果想是实现TCP打洞会更具备挑战。因为 TCP 是连接导向的协议,不像 UDP 那样无状态。TCP 建立连接时需要 3 次握手(SYN → SYN-ACK → ACK)。如果你主动向对方发起连接,而对方没有监听,就会被目标 NAT 拒绝更麻烦的是,大多数 NAT 对 TCP 的行为更严格,且 NAT 不会帮你中继 TCP SYN 包,所以TCP 打洞比 UDP 更麻烦,需要双方同时发起 TCP 连接请求才能“打洞成功”。更常见的做法是使用UDP 打洞,然后在用户层实现类似于TCP协议的可靠性。(如 QUIC / 自定义协议)