Golang實現(xiàn)內(nèi)網(wǎng)穿透詳解
我們經(jīng)常會遇到一個問題,如何將本機的服務(wù)暴露到公網(wǎng)上,讓別人也可以訪問。我們知道,在家上網(wǎng)的時候我們有一個 IP 地址,但是這個 IP 地址并不是一個公網(wǎng)的 IP 地址,別人無法通過一個 IP 地址訪問到你的服務(wù),所以在例如:微信接口調(diào)試、三方對接的時候,你必須將你的服務(wù)部署到一個公網(wǎng)的系統(tǒng)中去,這樣太累了。 這個時候,內(nèi)網(wǎng)穿透就出現(xiàn)了,它的作用就是即使你在家的服務(wù),也能被其人訪問到。 今天讓我們來用一個最簡單的案例學習一下如何用 go 來做一個最簡單的內(nèi)網(wǎng)穿透工具。
整體結(jié)構(gòu)
首先我們用幾張圖來說明一下我們是如何實現(xiàn)的,說清楚之后再來用代碼實現(xiàn)一下。
當前網(wǎng)絡(luò)情況

我們可以看到,畫實線的是我們當前可以訪問的,畫虛線的是我們當前無法進行直接訪問的。
我們現(xiàn)在有的路是:
- 用戶主動訪問公網(wǎng)服務(wù)器是可以的
- 內(nèi)網(wǎng)主動訪問公網(wǎng)服務(wù)也是可以的
當前我們要做的是想辦法能讓用戶訪問到內(nèi)網(wǎng)服務(wù),所以如果能做到公網(wǎng)服務(wù)訪問到內(nèi)網(wǎng)服務(wù),那么用戶就能間接訪問到內(nèi)網(wǎng)服務(wù)了。
想是這么想的,但是實際怎么做呢?用戶訪問不到內(nèi)網(wǎng)服務(wù),那我公網(wǎng)服務(wù)器同樣訪問不到吧。所以我們就需要利用現(xiàn)有的鏈路來完成這件事。
基本架構(gòu)

- 內(nèi)網(wǎng),客戶端(我們要搞一個)
- 外網(wǎng),服務(wù)端(我們也要搞一個)
- 訪問者,用戶
- 首先我們需要一個控制通道來傳遞消息,因為只有內(nèi)網(wǎng)可以訪問公網(wǎng),公網(wǎng)不知道內(nèi)網(wǎng)在哪里,所以第一次肯定需要客戶端主動告訴服務(wù)端我在哪
- 服務(wù)端通過 8007 端口監(jiān)聽用戶來的請求
- 當用戶發(fā)來請求時,服務(wù)端需要通過控制信道告訴客戶端,有用戶來了
- 客戶端收到消息之后建立隧道通道,主動訪問服務(wù)端的 8008 來建立 TCP 連接
- 此時客戶端需要同時與本地需要暴露的服務(wù) 127.0.0.1:8080 建立連接
- 連接完成后,服務(wù)端需要將 8007 的請求轉(zhuǎn)發(fā)到隧道端口 8008 中
- 客戶端從隧道中獲得用戶請求,轉(zhuǎn)發(fā)給內(nèi)網(wǎng)服務(wù),同時將內(nèi)網(wǎng)服務(wù)的返回信息放入隧道
最終請求流向是,如圖中的紫色箭頭走向,請求返回是如圖中紅色箭頭走向。
需要理解的是,TCP 一旦建立了連接,雙方就都可以向?qū)Ψ桨l(fā)送信息了,所以其實原理很簡單,就是利用已有的單向路建立 TCP 連接,從而知道對方的位置信息,然后將請求進行轉(zhuǎn)發(fā)即可。
代碼實現(xiàn)
工具方法
首先我們先定義三個需要使用的工具方法,還需要定義兩個消息編碼常量,后面會用到
- 監(jiān)聽一個地址對應(yīng)的 TCP 請求
CreateTCPListener - 連接一個 TCP 地址
CreateTCPConn - 將一個 TCP-A 連接的數(shù)據(jù)寫入另一個 TCP-B 連接,將 TCP-B 連接返回的數(shù)據(jù)寫入 TCP-A 的連接中
Join2Conn(別看這短短 10 幾行代碼,這就是核心了)
package network
import (
"io"
"log"
"net"
)
const (
KeepAlive = "KEEP_ALIVE"
NewConnection = "NEW_CONNECTION"
)
func CreateTCPListener(addr string) (*net.TCPListener, error) {
tcpAddr, err := net.ResolveTCPAddr("tcp", addr)
if err != nil {
return nil, err
}
tcpListener, err := net.ListenTCP("tcp", tcpAddr)
if err != nil {
return nil, err
}
return tcpListener, nil
}
func CreateTCPConn(addr string) (*net.TCPConn, error) {
tcpAddr, err := net.ResolveTCPAddr("tcp", addr)
if err != nil {
return nil, err
}
tcpListener, err := net.DialTCP("tcp",nil, tcpAddr)
if err != nil {
return nil, err
}
return tcpListener, nil
}
func Join2Conn(local *net.TCPConn, remote *net.TCPConn) {
go joinConn(local, remote)
go joinConn(remote, local)
}
func joinConn(local *net.TCPConn, remote *net.TCPConn) {
defer local.Close()
defer remote.Close()
_, err := io.Copy(local, remote)
if err != nil {
log.Println("copy failed ", err.Error())
return
}
}
客戶端
我們先來實現(xiàn)相對簡單的客戶端,客戶端主要做的事情是 3 件:
- 連接服務(wù)端的控制通道
- 等待服務(wù)端從控制通道中發(fā)來建立連接的消息
- 收到建立連接的消息時,將本地服務(wù)和遠端隧道建立連接(這里就要用到我們的工具方法了)
package main
import (
"bufio"
"io"
"log"
"net"
"nat-proxy/cmd/network"
)
var (
// 本地需要暴露的服務(wù)端口
localServerAddr = "127.0.0.1:32768"
remoteIP = "111.111.111.111"
// 遠端的服務(wù)控制通道,用來傳遞控制信息,如出現(xiàn)新連接和心跳
remoteControlAddr = remoteIP + ":8009"
// 遠端服務(wù)端口,用來建立隧道
remoteServerAddr = remoteIP + ":8008"
)
func main() {
tcpConn, err := network.CreateTCPConn(remoteControlAddr)
if err != nil {
log.Println("[連接失敗]" + remoteControlAddr + err.Error())
return
}
log.Println("[已連接]" + remoteControlAddr)
reader := bufio.NewReader(tcpConn)
for {
s, err := reader.ReadString('\n')
if err != nil || err == io.EOF {
break
}
// 當有新連接信號出現(xiàn)時,新建一個tcp連接
if s == network.NewConnection+"\n" {
go connectLocalAndRemote()
}
}
log.Println("[已斷開]" + remoteControlAddr)
}
func connectLocalAndRemote() {
local := connectLocal()
remote := connectRemote()
if local != nil && remote != nil {
network.Join2Conn(local, remote)
} else {
if local != nil {
_ = local.Close()
}
if remote != nil {
_ = remote.Close()
}
}
}
func connectLocal() *net.TCPConn {
conn, err := network.CreateTCPConn(localServerAddr)
if err != nil {
log.Println("[連接本地服務(wù)失敗]" + err.Error())
}
return conn
}
func connectRemote() *net.TCPConn {
conn, err := network.CreateTCPConn(remoteServerAddr)
if err != nil {
log.Println("[連接遠端服務(wù)失敗]" + err.Error())
}
return conn
}
服務(wù)端
服務(wù)端的實現(xiàn)就相對復(fù)雜一些了:
- 監(jiān)聽控制通道,接收客戶端的連接請求
- 監(jiān)聽訪問端口,接收來自用戶的 http 請求
- 第二步接收到請求之后需要存放一下這個連接并同時發(fā)消息給客戶端,告訴客戶端有用戶訪問了,趕緊建立隧道進行通信
- 監(jiān)聽隧道通道,接收來自客戶端的連接請求,將客戶端的連接與用戶的連接建立起來(也是用工具方法)
package main
import (
"log"
"net"
"strconv"
"sync"
"time"
"nat-proxy/cmd/network"
)
const (
controlAddr = "0.0.0.0:8009"
tunnelAddr = "0.0.0.0:8008"
visitAddr = "0.0.0.0:8007"
)
var (
clientConn *net.TCPConn
connectionPool map[string]*ConnMatch
connectionPoolLock sync.Mutex
)
type ConnMatch struct {
addTime time.Time
accept *net.TCPConn
}
func main() {
connectionPool = make(map[string]*ConnMatch, 32)
go createControlChannel()
go acceptUserRequest()
go acceptClientRequest()
cleanConnectionPool()
}
// 創(chuàng)建一個控制通道,用于傳遞控制消息,如:心跳,創(chuàng)建新連接
func createControlChannel() {
tcpListener, err := network.CreateTCPListener(controlAddr)
if err != nil {
panic(err)
}
log.Println("[已監(jiān)聽]" + controlAddr)
for {
tcpConn, err := tcpListener.AcceptTCP()
if err != nil {
log.Println(err)
continue
}
log.Println("[新連接]" + tcpConn.RemoteAddr().String())
// 如果當前已經(jīng)有一個客戶端存在,則丟棄這個鏈接
if clientConn != nil {
_ = tcpConn.Close()
} else {
clientConn = tcpConn
go keepAlive()
}
}
}
// 和客戶端保持一個心跳鏈接
func keepAlive() {
go func() {
for {
if clientConn == nil {
return
}
_, err := clientConn.Write(([]byte)(network.KeepAlive + "\n"))
if err != nil {
log.Println("[已斷開客戶端連接]", clientConn.RemoteAddr())
clientConn = nil
return
}
time.Sleep(time.Second * 3)
}
}()
}
// 監(jiān)聽來自用戶的請求
func acceptUserRequest() {
tcpListener, err := network.CreateTCPListener(visitAddr)
if err != nil {
panic(err)
}
defer tcpListener.Close()
for {
tcpConn, err := tcpListener.AcceptTCP()
if err != nil {
continue
}
addConn2Pool(tcpConn)
sendMessage(network.NewConnection + "\n")
}
}
// 將用戶來的連接放入連接池中
func addConn2Pool(accept *net.TCPConn) {
connectionPoolLock.Lock()
defer connectionPoolLock.Unlock()
now := time.Now()
connectionPool[strconv.FormatInt(now.UnixNano(), 10)] = &ConnMatch{now, accept,}
}
// 發(fā)送給客戶端新消息
func sendMessage(message string) {
if clientConn == nil {
log.Println("[無已連接的客戶端]")
return
}
_, err := clientConn.Write([]byte(message))
if err != nil {
log.Println("[發(fā)送消息異常]: message: ", message)
}
}
// 接收客戶端來的請求并建立隧道
func acceptClientRequest() {
tcpListener, err := network.CreateTCPListener(tunnelAddr)
if err != nil {
panic(err)
}
defer tcpListener.Close()
for {
tcpConn, err := tcpListener.AcceptTCP()
if err != nil {
continue
}
go establishTunnel(tcpConn)
}
}
func establishTunnel(tunnel *net.TCPConn) {
connectionPoolLock.Lock()
defer connectionPoolLock.Unlock()
for key, connMatch := range connectionPool {
if connMatch.accept != nil {
go network.Join2Conn(connMatch.accept, tunnel)
delete(connectionPool, key)
return
}
}
_ = tunnel.Close()
}
func cleanConnectionPool() {
for {
connectionPoolLock.Lock()
for key, connMatch := range connectionPool {
if time.Now().Sub(connMatch.addTime) > time.Second*10 {
_ = connMatch.accept.Close()
delete(connectionPool, key)
}
}
connectionPoolLock.Unlock()
time.Sleep(5 * time.Second)
}
}
其他
- 其中我加入了 keepalive 的消息,用于保持客戶端與服務(wù)端的一直正常連接
- 我們還需要定期清理一下服務(wù)端 map 中沒有建立成功的連接
實驗一下
首先在本機用 dokcer 部署一個 nginx 服務(wù)(你可以啟動一個 tomcat 都可以的),并修改客戶監(jiān)聽端口localServerAddr為127.0.0.1:32768,并修改remoteIP 為服務(wù)端 IP 地址。然后訪問以下,看到是可以正常訪問的。

然后編譯打包服務(wù)端扔到服務(wù)器上啟動、客戶端本地啟動,如果控制臺輸出連接成功,就完成準備了
現(xiàn)在通過訪問服務(wù)端的 8007 端口就可以訪問我們內(nèi)網(wǎng)的服務(wù)了。

遺留問題
上述的實現(xiàn)是一個最小的實現(xiàn),也只是為了完成基本功能,還有一些遺留的問題等待你的處理:
- 現(xiàn)在一個客戶端連接上了就不能連接第二個了,那怎么做多個客戶端的連接呢?
- 當前這個 map 的使用其實是有風險的,如何做好連接池的管理?
- TCP 連接的開銷是很大的,如何做好連接的復(fù)用?
- 當前是 TCP 的連接,那么如果是 UDP 如何實現(xiàn)呢?
- 當前連接都是不加密的,如何進行加密呢?
- 當前的 keepalive 實現(xiàn)很簡單,有沒有更優(yōu)雅的實現(xiàn)方式呢?
這些就交給聰明的你來完成了
總結(jié)
其實最后回頭看看實現(xiàn)起來并不復(fù)雜,用 go 來實現(xiàn)已經(jīng)是非常簡單了,所以 github 上面有很多利用 go 來實現(xiàn)代理或者穿透的工具,我也是參考它們抽離了其中的核心,最重要的就是工具方法中的第三個 copy 了,不過其實還有很多細節(jié)點需要考慮的。
以上就是Golang實現(xiàn)內(nèi)網(wǎng)穿透詳解的詳細內(nèi)容,更多關(guān)于Go內(nèi)網(wǎng)穿透的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Golang新提案:panic?能不能加個?PanicError?
這篇文章主要為大家介紹了Golang的新提案關(guān)于panic能不能加個PanicError的問題分析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-12-12
Linux環(huán)境下編譯并運行g(shù)o項目的全過程
Go語言是Google的開源編程語言,廣泛應(yīng)用于云計算、分布式系統(tǒng)開發(fā)等領(lǐng)域,在Linux上也有大量的應(yīng)用場景,這篇文章主要給大家介紹了關(guān)于Linux環(huán)境下編譯并運行g(shù)o項目的相關(guān)資料,需要的朋友可以參考下2023-11-11
Go語言獲取系統(tǒng)性能數(shù)據(jù)gopsutil庫的操作
這篇文章主要介紹了Go語言獲取系統(tǒng)性能數(shù)據(jù)gopsutil庫的操作,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-12-12
Golang使用Gin框架實現(xiàn)HTTP響應(yīng)格式統(tǒng)一處理
在gin框架中,我們可以定義一個中間件來處理統(tǒng)一的HTTP響應(yīng)格式,本文主要為大家介紹了具體是怎么定義實現(xiàn)這樣的中間件的,感興趣的小伙伴可以了解一下2023-07-07

