P2P 内网穿透通信与端口复用|Golang 代码示例

2022年1月11日 0 条评论 4.8k 次阅读 7 人点赞

B站教程视频(NAT1打洞技术)

https://www.bilibili.com/video/BV1VC4y1u7aR/?vd_source=2d0c706e18e52ecf183100ed5009fe51#reply750225836

一、前言

一般情况下,如果要实现聊天即时通讯,都要借助公网服务器作为中继节点对消息进行转发。

例如用户A和用户B进行即时通讯的具体步骤如下所示

首先用户A和B需要和公网服务器建立长连接

ClientA ====> (建立长连接) ===> 公网服务器

`ClientB ====> (建立长连接) ===> 公网服务器

紧接着用户A如果想发送消息给用户B,就会采用转发的形式

ClientA => 公网服务器(消息转发) => ClientB

但是我们从中可以看到,如果用户之间进行的是语音视频通话,所有流量将会从中继服务器中经过。这将会给中继服务器带来巨大挑战。

那么是否可以存在一种方式可以抛除中继服务器的存在,让用户A和用户B进行直连通信呢?

我们知道用户A和用户B都在各自的内网下,双方都不知道彼此的地址,那么如何进行通信成了问题。

二、P2P 通信与NAT类型

紧接上文,其实用户A在给中继服务器发送长连接请求后,中继服务器就能获取到运营商给用户A开放的公网IP和端口。

那么如果用户B知道了用户A所在的公网IP和端口,是否就能脱离中继服务器的限制,直接发送请求给用户A所在的IP和端口呢?

答案是,在一定情况下是可以的。这要求用户A所在的 NAT 是完全锥形。

NAT 的作用是会将内网主机的IP地址映射为一个公网IP,由于 IPV4 地址池不够用的情况下,运营商不会给每个接入互联网的用户分配公网 IP ,而是多个用户,或者一整个小区公用一个公网 IP 出口。

当用户发送网络请求时, NAT 会将用户的内网 IP 转换为公网 IP,并且分配一个公网端口。当用户的请求结束,一段时间后该这些公共资源将会被回收。

    Server S1                                     Server S2
18.181.0.31:1235                              138.76.29.7:1235
       |                                             |
       |                                             |
       +----------------------+----------------------+
                              |
  ^  Session 1 (A-S1)  ^      |      ^  Session 2 (A-S2)  ^
  |  18.181.0.31:1235  |      |      |  138.76.29.7:1235  |
  v 155.99.25.11:62000 v      |      v 155.99.25.11:62000 v
                              |
                           Cone NAT
                         155.99.25.11
                              |
  ^  Session 1 (A-S1)  ^      |      ^  Session 2 (A-S2)  ^
  |  18.181.0.31:1235  |      |      |  138.76.29.7:1235  |
  v   10.0.0.1:1234    v      |      v   10.0.0.1:1234    v
                              |
                           用户内网
                        10.0.0.1:1234

基于这种特性,NAT一般情况被分为 4 类

  1. 完全圆锥型NAT (Full Cone NAT)把一个来自内部IP地址和端口的所有请求,始终映射到相同的外网IP地址和端口;同时,任意外部主机向该映射的外网IP地址和端口发送报文,都可以实现和内网主机进行通信,就像一个向外开口的圆锥形一样,故得名。
  2. 地址限制式锥形NAT(Address Restricted Cone NAT)地址限制式圆锥形NAT同样把一个来自内部IP地址和端口的所有请求,始终映射到相同的外网IP地址和端口;与完全圆锥型NAT不同的是,当内网主机向某公网主机发送过报文后,只有该公网主机才能向内网主机发送报文,故得名。相比完全锥形,增加了地址限制,也就是IP受限,而端口不受限。
  3. 端口限制式锥形NAT(Port Restricted Cone NAT)端口限制式圆锥形NAT更加严格,在上述条件下,只有该公网主机该端口才能向内网主机发送报文,故得名。相比地址限制锥形又增加了端口限制,也就是说IP、端口都受限。
  4. 对称式NAT(Symmetric NAT)对称式NAT把内网IP和端口到相同目的地址和端口的所有请求,都映射到同一个公网地址和端口;同一个内网主机,用相同的内网IP和端口向另外一个目的地址发送报文,则会用不同的映射(比如映射到不同的端口)。和端口限制式NAT不同的是,端口限制式NAT是所有请求映射到相同的公网IP地址和端口,而对称式NAT是为不同的请求建立不同的映射。它具有端口受限锥型的受限特性,内部地址每一次请求一个特定的外部地址,都可能会绑定到一个新的端口号。也就是请求不同的外部地址映射的端口号是可能不同的。这种类型基本上就告别 P2P 了。

一般情况下,家用 NAT 是NAT3,也就是 端口限制式锥形NAT。我们基于这一特性可以尝试让两台主机进行内网端对端直连。

请注意,P2P通信不意味着全程不需要服务器的介入。服务器的介入只是为了让双方节点都获取到各自穿透的公网 IP和端口,实现的具体流程请方法下图。

请注意这里使用到了端口复用技术。因为我们的端口不仅要监听一个服务,并且这个端口还能进行复用发送网络请求。

具体代码示例如下:

代码我把它托管到了 Github 上,并且有完整说明,链接如下

https://github.com/xhyonline/p2p-demo

server.go

代码其实很简单,server.go 只做一件事,交换两个内网节点临时生成的公网 IP 和端口

package main

import (
	"encoding/json"
	"fmt"
	"github.com/go-basic/uuid"
	"github.com/libp2p/go-reuseport"
	"net"
	"time"
)

type Client struct {
	UID     string
	Conn    net.Conn
	Address string
}

type Handler struct {
	// 服务端句柄
	Listener net.Listener
	// 客户端句柄池
	ClientPool map[string]*Client
}

func (s *Handler) Handle() {
	for {
		conn, err := s.Listener.Accept()
		if err != nil {
			fmt.Println("获取连接句柄失败", err.Error())
			continue
		}
		id := uuid.New()
		s.ClientPool[id] = &Client{
			UID:     id,
			Conn:    conn,
			Address: conn.RemoteAddr().String(),
		}
		fmt.Println("一个客户端连接进去了,他的公网IP是", conn.RemoteAddr().String())
		// 暂时只接受两个客户端,多余的不处理
		if len(s.ClientPool) == 2 {
			// 交换双方的公网地址
			s.ExchangeAddress()
			break
		}
	}
}

// ExchangeAddress 交换地址
func (s *Handler) ExchangeAddress() {
	for uid, client := range s.ClientPool {
		for id, c := range s.ClientPool {
			// 自己不交换
			if uid == id {
				continue
			}
			var data = make(map[string]string)
			data["dst_uid"] = client.UID     // 对方的 UID
			data["address"] = client.Address // 对方的公网地址
			body, _ := json.Marshal(data)
			if _, err := c.Conn.Write(body); err != nil {
				fmt.Println("交换地址时出现了错误", err.Error())
			}
		}
	}
}

func main() {
	address := fmt.Sprintf("0.0.0.0:6999")
	listener, err := reuseport.Listen("tcp", address)
	if err != nil {
		panic("服务端监听失败" + err.Error())
	}
	h := &Handler{Listener: listener, ClientPool: make(map[string]*Client)}
	// 监听内网节点连接,交换彼此的公网 IP 和端口
	h.Handle()
	time.Sleep(time.Hour) // 防止主线程退出
}

client.go

客户端得到对方的临时生成的公网IP和端口后,尝试进行连接,并不停发送数据

package main

import (
	"crypto/rand"
	"encoding/json"
	"fmt"
	"github.com/libp2p/go-reuseport"
	"math"
	"math/big"
	"net"
	"time"
)

type Handler struct {
	// 中继服务器的连接句柄
	ServerConn net.Conn
	// p2p 连接
	P2PConn net.Conn
	// 端口复用
	LocalPort int
}

// WaitNotify 等待远程服务器发送通知告知我们另一个用户的公网IP
func (s *Handler) WaitNotify() {
	buffer := make([]byte, 1024)
	n, err := s.ServerConn.Read(buffer)
	if err != nil {
		panic("从服务器获取用户地址失败" + err.Error())
	}
	data := make(map[string]string)
	if err := json.Unmarshal(buffer[:n], &data); err != nil {
		panic("获取用户信息失败" + err.Error())
	}
	fmt.Println("客户端获取到了对方的地址:", data["address"])
	// 断开服务器连接
	defer s.ServerConn.Close()
	// 请求用户的临时公网 IP
	go s.DailP2PAndSayHello(data["address"], data["dst_uid"])
}

// DailP2PAndSayHello 连接对方临时的公网地址,并且不停的发送数据
func (s *Handler) DailP2PAndSayHello(address, uid string) {
	var errCount = 1
	var conn net.Conn
	var err error
	for {
		// 重试三次
		if errCount > 3 {
			break
		}
		time.Sleep(time.Second)
		conn, err = reuseport.Dial("tcp", fmt.Sprintf(":%d", s.LocalPort), address)
		if err != nil {
			fmt.Println("请求第", errCount, "次地址失败,用户地址:", address)
			errCount++
			continue
		}
		break
	}
	if errCount > 3 {
		panic("客户端连接失败")
	}
	s.P2PConn = conn
	go s.P2PRead()
	go s.P2PWrite()
}

// P2PRead 读取 P2P 节点的数据
func (s *Handler) P2PRead() {
	for {
		buffer := make([]byte, 1024)
		n, err := s.P2PConn.Read(buffer)
		if err != nil {
			fmt.Println("读取失败", err.Error())
			time.Sleep(time.Second)
			continue
		}
		body := string(buffer[:n])
		fmt.Println("读取到的内容是:", body)
		fmt.Println("来自地址", s.P2PConn.RemoteAddr())
		fmt.Println("=============")
	}
}

// P2PWrite 向远程 P2P 节点写入数据
func (s *Handler) P2PWrite() {
	for {
		if _, err := s.P2PConn.Write([]byte("你好呀~")); err != nil {
			fmt.Println("客户端写入错误")
		}
		time.Sleep(time.Second)
	}
}

func main() {
	// 指定本地端口
	localPort := RandPort(10000, 50000)
	// 向 P2P 转发服务器注册自己的临时生成的公网 IP (请注意,Dial 这里拨号指定了自己临时生成的本地端口)
	serverConn, err := reuseport.Dial("tcp", fmt.Sprintf(":%d", localPort), "你自己的公网服务器IP:6999")
	if err != nil {
		panic("请求远程服务器失败" + err.Error())
	}
	h := &Handler{ServerConn: serverConn, LocalPort: int(localPort)}
	h.WaitNotify()
	time.Sleep(time.Hour)
}

// RandPort 生成区间范围内的随机端口
func RandPort(min, max int64) int64 {
	if min > max {
		panic("the min is greater than max!")
	}
	if min < 0 {
		f64Min := math.Abs(float64(min))
		i64Min := int64(f64Min)
		result, _ := rand.Int(rand.Reader, big.NewInt(max+1+i64Min))
		return result.Int64() - i64Min
	}
	result, _ := rand.Int(rand.Reader, big.NewInt(max-min+1))
	return min + result.Int64()
}

兰陵美酒郁金香

大道至简 Simplicity is the ultimate form of sophistication.

文章评论(0)

你必须 登录 才能发表评论