Golang用300行代碼實現(xiàn)內網穿透

原因分析

我們經常會遇到一個問題,如何將本機的服務暴露到公網上,讓別人也可以訪問。我們知道,在家上網的時候我們有一個 IP 地址,但是這個 IP 地址并不是一個公網的 IP 地址,別人無法通過一個 IP 地址訪問到你的服務,所以在例如:微信接口調試、三方對接的時候,你必須將你的服務部署到一個公網的系統(tǒng)中去,這樣太累了。

這個時候,內網穿透就出現(xiàn)了,它的作用就是即使你在家的服務,也能被其人訪問到。

今天讓我們來用一個最簡單的案例學習一下如何用 go 來做一個最簡單的內網穿透工具。

整體結構

首先我們用幾張圖來說明一下我們是如何實現(xiàn)的,說清楚之后再來用代碼實現(xiàn)一下。

當前網絡情況

當前網絡情況

我們可以看到,畫實線的是我們當前可以訪問的,畫虛線的是我們當前無法進行直接訪問的。

我們現(xiàn)在有的路是:

  1. 用戶主動訪問公網服務器是可以的
  2. 內網主動訪問公網服務也是可以的

當前我們要做的是想辦法能讓用戶訪問到內網服務,所以如果能做到公網服務訪問到內網服務,那么用戶就能間接訪問到內網服務了。

想是這么想的,但是實際怎么做呢?用戶訪問不到內網服務,那我公網服務器同樣訪問不到吧。所以我們就需要利用現(xiàn)有的鏈路來完成這件事。

基本架構

image-20200408232311358
  • 內網,客戶端(我們要搞一個)
  • 外網,服務端(我們也要搞一個)
  • 訪問者,用戶
  1. 首先我們需要一個控制通道來傳遞消息,因為只有內網可以訪問公網,公網不知道內網在哪里,所以第一次肯定需要客戶端主動告訴服務端我在哪
  2. 服務端通過 8007 端口監(jiān)聽用戶來的請求
  3. 當用戶發(fā)來請求時,服務端需要通過控制信道告訴客戶端,有用戶來了
  4. 客戶端收到消息之后建立隧道通道,主動訪問服務端的 8008 來建立 TCP 連接
  5. 此時客戶端需要同時與本地需要暴露的服務 127.0.0.1:8080 建立連接
  6. 連接完成后,服務端需要將 8007 的請求轉發(fā)到隧道端口 8008 中
  7. 客戶端從隧道中獲得用戶請求,轉發(fā)給內網服務,同時將內網服務的返回信息放入隧道

最終請求流向是,如圖中的紫色箭頭走向,請求返回是如圖中紅色箭頭走向。

需要理解的是,TCP 一旦建立了連接,雙方就都可以向對方發(fā)送信息了,所以其實原理很簡單,就是利用已有的單向路建立 TCP 連接,從而知道對方的位置信息,然后將請求進行轉發(fā)即可。

代碼實現(xiàn)

工具方法

首先我們先定義三個需要使用的工具方法,還需要定義兩個消息編碼常量,后面會用到

  1. 監(jiān)聽一個地址對應的 TCP 請求 CreateTCPListener
  2. 連接一個 TCP 地址 CreateTCPConn
  3. 將一個 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 件:

  1. 連接服務端的控制通道
  2. 等待服務端從控制通道中發(fā)來建立連接的消息
  3. 收到建立連接的消息時,將本地服務和遠端隧道建立連接(這里就要用到我們的工具方法了)
package main

import (
   "bufio"
   "io"
   "log"
   "net"

   "nat-proxy/cmd/network"
)

var (
   // 本地需要暴露的服務端口
   localServerAddr = "127.0.0.1:32768"

   remoteIP = "111.111.111.111"
   // 遠端的服務控制通道,用來傳遞控制信息,如出現(xiàn)新連接和心跳
   remoteControlAddr = remoteIP + ":8009"
   // 遠端服務端口,用來建立隧道
   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("[連接本地服務失敗]" + err.Error())
   }
   return conn
}

func connectRemote() *net.TCPConn {
   conn, err := network.CreateTCPConn(remoteServerAddr)
   if err != nil {
      log.Println("[連接遠端服務失敗]" + err.Error())
   }
   return conn
}

服務端

服務端的實現(xiàn)就相對復雜一些了:

  1. 監(jiān)聽控制通道,接收客戶端的連接請求
  2. 監(jiān)聽訪問端口,接收來自用戶的 http 請求
  3. 第二步接收到請求之后需要存放一下這個連接并同時發(fā)消息給客戶端,告訴客戶端有用戶訪問了,趕緊建立隧道進行通信
  4. 監(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())
      // 如果當前已經有一個客戶端存在,則丟棄這個鏈接
      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 的消息,用于保持客戶端與服務端的一直正常連接
  • 我們還需要定期清理一下服務端 map 中沒有建立成功的連接

實驗一下

首先在本機用 dokcer 部署一個 nginx 服務(你可以啟動一個 tomcat 都可以的),并修改客戶監(jiān)聽端口localServerAddr為127.0.0.1:32768,并修改remoteIP 為服務端 IP 地址。然后訪問以下,看到是可以正常訪問的。

image-20200408223531645

然后編譯打包服務端扔到服務器上啟動、客戶端本地啟動,如果控制臺輸出連接成功,就完成準備了

現(xiàn)在通過訪問服務端的 8007 端口就可以訪問我們內網的服務了。

image-20200408223550812

遺留問題

上述的實現(xiàn)是一個最小的實現(xiàn),也只是為了完成基本功能,還有一些遺留的問題等待你的處理:

  • 現(xiàn)在一個客戶端連接上了就不能連接第二個了,那怎么做多個客戶端的連接呢?
  • 當前這個 map 的使用其實是有風險的,如何做好連接池的管理?
  • TCP 連接的開銷是很大的,如何做好連接的復用?
  • 當前是 TCP 的連接,那么如果是 UDP 如何實現(xiàn)呢?
  • 當前連接都是不加密的,如何進行加密呢?
  • 當前的 keepalive 實現(xiàn)很簡單,有沒有更優(yōu)雅的實現(xiàn)方式呢?

這些就交給聰明的你來完成了

總結

其實最后回頭看看實現(xiàn)起來并不復雜,用 go 來實現(xiàn)已經是非常簡單了,所以 github 上面有很多利用 go 來實現(xiàn)代理或者穿透的工具,我也是參考它們抽離了其中的核心,最重要的就是工具方法中的第三個 copy 了,不過其實還有很多細節(jié)點需要考慮的。你可以參考下面的源碼繼續(xù)深入探索一下。

https://github.com/fatedier/frp

https://github.com/snail007/goproxy

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內容