一、背景
最近在折腾家里的NAS,然后想在外网上访问家里NSA的资源。NAS其实都提供了内网穿透的功能,但是他们的解决方案一般都是所有流量都会走他们的第三方的服务器。一个是可能不安全,二是带宽有限。我要下载家里的资源速度会很慢,所以不太想用这种方案。
如果家里的光猫/路由器拨号上网有公网IP,可以直接通过家里宽带的公网IP访问,在光猫/路由器里面配置下端口转发就行了。但是我家里宽带升级到1000MB后就没有动态公网IP,所以这个方案也走不通。
网上查了下,也有很多人找宽带客服投诉以后,就给下发公网IP了的Case。 于是我也尝试找宽带客服咨询了下,问能不能给我申请一个动态公网IP,IPV6的也可以,客服明确告知说申请不了。这条路走不通,只能放弃。
然后就想到N年前做端上APP的时候,做了个UDP的内网穿透能力,当时做TCP没有搞通,所以这次又研究了一下。
二、基础知识
动态公网IP
家用宽带的上网IP一般都是拨号的时候动态下发的,你每次重新拨号都会给你一个新的IP,被回收的IP会放在池子里面一段时间后给其他人使用。可能早期路由器(电脑)不普及的年代,一家一般都只有一台电脑需要上网,都是用电脑直接拨号上网,电脑关了以后,网络就断开了,IP就被回收了,这种IP回收率高。但是现在家里都是路由器,路由器都是24小时不关机,这个时候回收已经没有意义了,基本等于一个家里24小时不间断持有一个公网IP了。
随着家用宽带越来越便宜,运营商估计也没多少IPv4可以给了。所以他们就想出了个新手段,新装(新升级)的宽带拨号都不给公网IP了,比如一栋楼的用户共用一个公网IP,这样问题就解决了。
不过这样其实是有损用户体验的。假设跟你共用一个公网IP的用户做了什么坏事,导致网站把你IP封了,这个时候你访问这个网站应该也是被封的状态。
怎么确认自己有没有公网IP,只要去你的拨号设备,路由器或者光猫(现在基本都是运营商的光猫负责拨号上网)上看下拨号的网络状态就知道了。 如下如,我这边光猫拨号以后,获取的是一个100.83.6.247内网IP。

DDNS
很多人一说内网穿透,就想到DDNS、花生壳之类的,其实DDNS就只是做一件事件:“自动获得你的公网IPv4或IPv6地址,并解析到对应的域名服务。”这个主要是为了方便,你能快速知道你家里的公网IP是啥。但是不能解决你有了IP以后怎么访问的问题。
花生壳早期只做域名解析,能够给用户提供免费的域名,而且很多路由器都内置了花生壳程序,在里面填上花生壳的账号密码以后,能够自动把花生壳上申请的域名解析到当前路由器所在网络的公网IP上。

看了下花生壳看现在也提供“内网穿透”服务了,不过应该也是走流量转发。看是按流量收费的。

还有路由器用的比较多的开源的 ddns-go 方案。
P2P
P2P技术,即点对点技术,是一种网络通信模式,其中网络中的各个节点(如计算机或设备)直接相互通信,而不依赖于中心服务器(不是完全不依赖服务端,节点IP和Port信息交换还是需要依赖服务端的)。实际上,每个节点既是客户端又是服务器。与传统的客户端 - 服务器模型相比,P2P 网络具有更高的容错性和可扩展性。
P2P(点对点,即 Peer-to-Peer)是一种网络架构模式,在这种模式下,每个节点(参与者)都可以充当客户端和服务器,直接进行通信和数据交换,而不依赖于中央服务器。P2P网络的优势在于去中心化,降低了单点故障的风险,还提高了资源的利用效率,降低服务器带宽成本。
常见应用:“BT 下载”、“加密IM软件(Skype的早期版本)”和“加密货币(比特币)”、PCDN、“Tor网络”、“PCDN”。
NAT
NAT,Network Address Translation,即网络地址转换,是一种在IP数据包通过路由器或防火墙时修改其源 IP地址或目标IP地址的技术。NAT可以让多个设备共享一个公共的IP地址连接到互联网,从而节约IPv4地址资源并提高网络的安全性。
NAT的实现方式有三种,即静态转换Static Nat、动态转换Dynamic Nat和端口多路复用OverLoad。
静态转换(Static Nat)是指内部本地地址一对一转换成内部全局地址,相当内部本地的每一台PC都绑定了一个全局地址。一般用于在内网中对外提供服务的服务器。

动态转换(Dynamic Nat)是指将内部网络的私有IP地址转换为公用IP地址时,IP地址是不确定的,是随机的,所有被授权访问上Internet的私有IP地址可随机转换为任何指定的合法IP地址。也就是说,只要指定哪些内部地址可以进行转换,以及用哪些合法地址作为外部地址时,就可以进行动态转换。动态转换可以使用多个合法外部地址集。当ISP提供的合法IP地址略少于网络内部的计算机数量时。可以采用动态转换的方式。

端口多路复用(Port address Translation,PAT)是指改变外出数据包的源端口并进行端口转换,即端口地址转换(PAT,Port Address Translation).采用端口多路复用方式。内部网络的所有主机均可共享一个合法外部IP地址实现对Internet的访问,从而可以最大限度地节约IP地址资源。同时,又可隐藏网络内部的所有主机,有效避免来自Internet的攻击。因此,网络中应用最多的就是端口多路复用方式。

前面基本不是本文讨论重点,宽带运营商基本上都是PAT类型的NAT。
从实现的技术角度,又可以将NAT分成如下几类:限制性锥NAT(Restricted Cone NAT) 、全锥NAT (Full Cone NAT)、端口限制性锥NAT(Port Restricted Cone NAT) 、对称NAT ( Symmetric NAT) 。
-
全锥NAT (Full Cone NAT),限制最小的NAT,两个内网设备,只要一个设备是全锥 NAT,那就可以穿透。- 内网主机使用一个固定的内网
IP和Port对外通信,这个内网IP和Port会固定对应一个外网IP和Port。 - 任何外部主机只要知道这个公网
IP和Port,就能向内网主机发送数据。

- 内网主机使用一个固定的内网
-
IP 限制性锥NAT(Restricted Cone NAT),相比全锥 NAT,IP限制性锥 NAT加上了IP的限制。- 内网主机使用一个固定的内网
IP和Port对外通信,这个内网IP和Port会固定对应一个外网IP和Port。 - 只有内网主机曾向某外部主机(
IP)发送过数据,该外部主机(IP)才能向内网主机发送数据。

- 内网主机使用一个固定的内网
-
端口限制性锥NAT(Port Restricted Cone NAT):类似IP 受限圆锥形 NAT,但还有端口限制。- 内网主机使用一个固定的内网
IP和Port对外通信,这个内网IP和Port会固定对应一个外网IP和Port。 - 只有内网主机曾向某外部主机的特定IP和端口发送过数据,该外部主机才能向内网主机发送数据。

- 内网主机使用一个固定的内网
-
对称 NAT(Symmetric NAT):对称形,内网主机与每个外部主机通信时,会分配不同的公网IP和端口,在安全性上最为严格,但也最容易导致连接问题。- 内网主机与每个外部主机通信时,会分配不同的公网
IP和端口。举个例子,本地使用192.168.1.6:8080分别向Server1和Server2建立连接。如果是锥性NAT (上面三种类型NAT),这个时候192.168.1.6:8080对应的外网IP和Port肯定是一样的。但是如果是对称性 NAT,两个连接的外网Port是不是不一样的 - 外部主机只能在收到内网主机的数据后才能回传数据,在安全性上最为严格,但也最容易导致连接问题。

- 内网主机与每个外部主机通信时,会分配不同的公网
不能穿透的两种场景:
- 所以两个设备都在
对称NAT下。 - 或者一个在
对称 NAT下一个在端口限制性锥 NAT。
可以思考下为什么。
SO_REUSEADDR
SO_REUSEADDR 是一个套接字选项,在网络编程中经常使用。它的主要作用是允许在bind()操作中复用本地地址。
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
SO_REUSEADDR主要给TCP做端口复用的,前面说了我要使用同一个端口号去做收发数据,意味着我们需要在同一个端口起一个TCP服务器去接收数据,还要使用这个端口作为TCP客户端去连接外部的服务器,如果不设置这个的话,会报Address already in use的错误。
NAT 类型检查
方法一(推荐):
pip install pystun3
➜ ~ pystun3
NAT Type: Restric Port NAT
External IP: None
External Port: None
方法二:
// 检查效果不如第一个准确
https://mao.fan/mynat
三、具体方案
服务器转发

这个方案整个链路比较简单,所有流量都走我的阿里云服务器,在通过frp程序转发给我内网的设备。
frp 是一个反向代理的程序,原理很简单,就是一个TCP程序,frpc和frps会保持长链,所以所有转发给frps的流量都能转发到内网frpc,frpc再把请求转发给内网的任意IP和任意端口。
举个frpc配置的例子,下面这个配置,表示把公网端口8999的所有请求都会转发到内网192.168.31.100:22端口,如果192.168.31.100开启了ssh,我只用在外面使用ssh -o Port=8999 xx.xx.xx.xx,就能ssh登录到内网的192.168.31.100这台机器。
[[proxies]]
name = "ssh"
type = "tcp"
localIP = "192.168.31.100"
localPort = 22
remotePort = 8999
UDP 穿透

UDP穿透的具体步骤如下:
ClientA和ClientB固定使用本地8080端口分别向Server发送UDP心跳包。因为需要保证NAT的端口不被回收,所以要一直发送。Server分别记录下ClientA和ClientB的公网地址和端口。ClientA和ClientB向Server查询对方的公网IP和Port。ClientA和ClientB使用本地8080端口,向对方的公网地址发UDP探测包。- 双方收到数据包以后就可以正常通信了。
服务端代码如下:
var clientIP2Port = sync.Map{}
const (
UDPServerPort = 8168
HTTPServerPort = "8169"
)
func main() {
go startHttpSever()
// 创建监听地址
addr := net.UDPAddr{
Port: UDPServerPort,
IP: net.ParseIP("0.0.0.0"),
}
fmt.Printf("Starting udp server at port %d...\n", UDPServerPort)
conn, err := net.ListenUDP("udp", &addr)
if err != nil {
fmt.Printf("Error: %s\n", err)
return
}
defer conn.Close()
for {
buffer := make([]byte, 1024)
n, clientAddr, err := conn.ReadFromUDP(buffer)
if err != nil {
fmt.Printf("Error: %s\n", err)
continue
}
const layout = "2006-01-02 15:04:05"
receivedMsg := string(buffer[:n])
now := time.Now().Format(layout)
fmt.Printf("[%s Received %s:%d]: %s\n", now, clientAddr.IP.String(), clientAddr.Port, receivedMsg)
clientIP2Port.Store(clientAddr.String(), clientAddr.Port)
message := []byte("Message from Server")
_, err = conn.WriteToUDP(message, clientAddr)
if err != nil {
fmt.Printf("SendTo(%v)Error: %s\n", *clientAddr, err)
continue
}
}
}
func startHttpSever() {
http.HandleFunc("/get", handlerGetOtherIP)
fmt.Printf("Starting http server at port %s...\n", HTTPServerPort)
if err := http.ListenAndServe(":"+HTTPServerPort, nil); err != nil {
fmt.Printf("Error starting server: %s\n", err)
}
}
func handlerGetOtherIP(w http.ResponseWriter, r *http.Request) {
//ip := getIPAddress(r)
result := make([]string, 0, 10)
// 遍历 map
clientIP2Port.Range(func(key, value interface{}) bool {
otherIP := key.(string)
fmt.Printf("ip = %v , port = %v \n", otherIP, value)
result = append(result, otherIP)
return true
})
resultStr := strings.Join(result, ",")
w.Write([]byte(resultStr))
return
}
客户端代码如下:
const serverIP = "xx.xx.xx.xx"
func main() {
// 创建监听地址
addr := net.UDPAddr{
Port: 8080,
IP: net.ParseIP("0.0.0.0"),
}
conn, err := net.ListenUDP("udp", &addr)
if err != nil {
fmt.Printf("Error: %s\n", err)
return
}
defer conn.Close()
// 启动一个goroutine来处理接收数据
go func() {
buffer := make([]byte, 1024)
for {
n, clientAddr, err := conn.ReadFromUDP(buffer)
if err != nil {
fmt.Printf("Error: %s\n", err)
continue
}
receivedMsg := string(buffer[:n])
str := ""
if clientAddr.IP.String() == serverIP {
str = "server"
} else {
str = "other pc"
}
fmt.Printf("[%s Received]: ‘%s’ from %s(%s:%d)\n", time.Now().Format("01-02 15:04:05"), receivedMsg, str, clientAddr.IP.String(), clientAddr.Port)
}
}()
for {
targetAddr := net.UDPAddr{
IP: net.ParseIP(serverIP),
Port: 8168, // 目标端口
}
message := []byte(fmt.Sprintf("Hello from client"))
_, err := conn.WriteToUDP(message, &targetAddr)
if err != nil {
fmt.Printf("Error: %s\n", err)
time.Sleep(5 * time.Second)
continue
}
ipPortStrArr := getOtherIPPorts()
if len(ipPortStrArr) == 0 {
fmt.Printf("don't find other pc\n")
continue
}
for _, ipPortStr := range ipPortStrArr {
if ipPortStr == "" {
fmt.Printf("ipPortStr is empty\n")
continue
}
fmt.Printf("send to : %s\n", ipPortStr)
ip, port, err := splitIPAndPort(ipPortStr)
if err != nil {
fmt.Printf("Error: %s\n", err)
continue
}
targetAddr1 := net.UDPAddr{
IP: net.ParseIP(ip),
Port: port, // 目标端口
}
_, err = conn.WriteToUDP(message, &targetAddr1)
if err != nil {
fmt.Printf("Error: %s\n", err)
time.Sleep(5 * time.Second)
continue
}
}
time.Sleep(5 * time.Second) // 每隔5秒发送一次
}
}
func getOtherIPPorts() []string {
// 服务器的地址
url := fmt.Sprintf("http://%s:8169/get", serverIP)
// 创建一个 HTTP 客户端
client := &http.Client{}
// 创建一个新的请求
req, err := http.NewRequest("GET", url, nil)
if err != nil {
fmt.Printf("Error creating request: %s\n", err)
return []string{}
}
// 发送请求
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Error sending request: %s\n", err)
return []string{}
}
defer resp.Body.Close()
// 读取响应
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading response: %s\n", err)
return []string{}
}
results := strings.Split(string(body), ",")
return results
}
func splitIPAndPort(address string) (string, int, error) {
ip, portStr, err := net.SplitHostPort(address)
if err != nil {
return "", 0, err
}
port, err := strconv.Atoi(portStr)
if err != nil {
return "", 0, err
}
return ip, port, nil
}
TCP 穿透
TCP穿透和UDP穿透有个很大的不同就是,TCP需要一方作为Server端,我在端口限制性锥NAT(Port Restricted Cone NAT)的网络环境下做了各种测试,把Server放在端口限制性锥 NAT穿透是没有成功过(UDP穿透没有问题)。
我的家庭网络环境大致如下:

这个时候其实我是有点怀疑TCP服务端能否在端口限制性锥NAT(Port Restricted Cone NAT)下做穿透。然后就试了下市面上的P2P软件,比较有代表性的是zerotier,zerotier的具体实现是新建一个NetworkID,然后设备Join这NetworkID,设备之间会尝试互相打洞,如果可以打洞就是走P2P逻辑,如果不能打洞就是走服务器转发逻辑。zerotier需要装Client,Client能够走全局代理(VPN)方式来发送流量,猜测是类似用的VXLAN的技术,点对点通讯还是走的UDP。


也加深了我对端口限制性锥NAT(Port Restricted Cone NAT)不能做TCP打洞的想法,不过端口限制性锥NAT(Port Restricted Cone NAT)不行,那我有没有办法改变我的NAT设置。我抱着试试的心态折腾了下我的光猫,先破解了我家里光猫的超级管理员账号,登录下光猫测试了下各种配置,最终修改了下面两个配置以后成功了:
-
在上网拨号的界面里面,把
NAT类型改成了完全锥型NAT

-
在
NAT 设置里面做了一个端口映射

最后我们在内部主机的防火墙里面白名单里面加上,上面客户端口的代理程序以后(在 MacOS 上这一步必须得,不要就走不通)。
经过上面的配置,这个时候,我们主机的网络环境其实已经变成全锥NAT (Full Cone NAT)网络了,可以执行pystun3查看,如下:
➜ ~ pystun3
NAT Type: Full Cone
External IP: xx.xx.xx.xx
External Port: 25305
全锥NAT (Full Cone NAT)其实穿透就很简单了。
TCP整体代码流程跟UDP差不多。具体代码如下:
服务端完整代码: tcps.go
客户端完整代码: tcpc.go
点对点直连以后,可以方便的在线观看家里磁盘上的4k电影了。

FAQ
端口会变吗?多长时间变一次。
会,每次路由重新拨号,或者打洞的TCP连接断开以后,穿透端口就会改变。
如果是网站的话,可以先通过一个固定的域名访问Server,然后Server再根据客户端的穿透信息,拼好地址和端口,帮忙302 redirect真正的服务器地址。具体流程如下:

如果是非浏览器访问的话,我是自己写了个App方便实时查看端口穿透信息。

会有安全问题吗?
理论上,随意开放内部服务(比如NAS、Samba、FTP)的端口到公网给外部人使用,肯定是有风险的。所以我在Proxy层加了一个白名单。只允许固定的几个公网IP,才能访问家里内网的的服务。
然后并且提供了Admin的管理接口,能让我在手机上实时添加可以远程访问的白名单。管理接口走的Https加验签,由于App不对外,理论上是没有被破解的风险的。
剩下所有的外部web页面都是走Https到内网的Proxy,内网的Proxy在卸载Https然后Http转发给内网真正的服务。所以也不存在账号密码被泄露的风险。

怎么保证 Proxy 能一直在线
因为我的Proxy是运行在Mac上的,Mac上也是提供了Daemon
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.fanlv.tcpc</string>
<key>ProgramArguments</key>
<array>
<string>~/Downloads/TestTool</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/fanlv/startup/tcpt.log</string>
<key>StandardErrorPath</key>
<string>/Users/fanlv/startup/tcpt.log</string>
</dict>
</plist>