使用Golang语言带你轻松搞定内网穿透

使用Golang语言带你轻松搞定内网穿透

在日常开发过程中,相信不少开发者都遭遇过这样的困扰:如何将本地机器上运行的服务暴露到公网,让外部能够顺利访问?就拿我们在家上网的场景来说,所分配到的IP地址通常并非公网IP ,这就导致其他人无法直接通过IP访问我们本地运行的服务。这在微信接口调试、第三方平台对接等场景下,就会造成极大的不便。按照常规做法,就得把服务部署到公网服务器上,不仅步骤繁琐,还需要投入一定的成本,实在是费时费力。

这个时候,内网穿透技术便应运而生。它打破了网络限制,即便服务运行在家中本地环境,其他人也能轻松访问。

今天,我们就通过一个简单易懂的案例,深入学习如何运用Go语言打造一个基础的内网穿透工具,手把手带你揭开内网穿透的神秘面纱,轻松解决公网访问难题。

整体结构

首先我们用几张图来说明一下我们是如何实现的,说清楚之后再来用代码实现一下。

当前网络情况

从图示中能够清晰地看到,以实线标识的部分,是我们当前能够顺利访问的路径;而以虚线标识的部分,则是我们目前无法直接访问的路径。

目前,我们现有的访问路径如下:

  1. 用户主动发起对公网服务器的访问,这一过程是可行的。
  2. 内网主动访问公网服务,同样不存在问题。

当下,我们亟待解决的问题是,如何使用户能够访问到内网服务。不难理解,如果公网服务能够成功访问内网服务,那么用户就可以通过公网服务这个“桥梁”,间接实现对内网服务的访问。

想法虽然清晰明了,但在实际操作中,该如何落地呢?毕竟用户无法直接访问内网服务,同理,公网服务器也无法直接访问内网服务。所以,我们必须巧妙借助现有的网络链路,来达成这一目标 。

基本架构

在内网穿透的实现架构中,主要涉及三个关键角色:

  • 内网客户端:这是我们需要搭建的,用于在内网环境中进行相关操作。
  • 外网服务端:同样是我们要构建的,处于外网环境,负责与外界交互。
  • 访问者(用户):作为服务的请求发起方。

整个流程如下:

  1. 控制通道建立与信息传递:由于内网能够访问公网,而公网不清楚内网的具体位置,所以首先需要搭建一个控制通道用于消息传递。首次通信时,客户端必须主动告知服务端自己的位置信息。
  2. 服务端监听:服务端通过8007端口监听来自用户的请求,时刻准备接收用户访问指令。
  3. 通知客户端:当服务端接收到用户发来的请求后,会通过之前建立的控制信道,告知客户端有用户发起访问。
  4. 隧道通道建立:客户端收到通知消息后,着手建立隧道通道。它会主动访问服务端的8008端口,以此建立TCP连接,为后续的数据传输搭建通道。
  5. 客户端与内网服务连接:在建立与服务端连接的同时,客户端还需要与本地需要暴露的服务(127.0.0.1:8080)建立连接,从而打通内网服务与外部交互的链路。
  6. 请求转发:连接全部建立完成后,服务端将接收到的8007端口的请求,转发到隧道端口8008中,让请求进入隧道传输体系。
  7. 内网交互与信息返回:客户端从隧道中获取用户请求,并将其转发给内网服务。同时,客户端会把内网服务的返回信息重新放入隧道,以便沿着原路径返回给用户。

最终的请求流向,就如同图中紫色箭头所指示的方向;而请求返回的路径,则如红色箭头所示。需要明确的是,一旦TCP连接建立,通信双方都能向对方发送信息。所以,整个内网穿透的原理其实并不复杂,核心就在于利用现有的单向网络通路建立TCP连接,获取对方位置信息,进而实现请求的转发。

代码实现

工具方法

在构建内网穿透工具的过程中,我们首先需要定义三个极为关键的工具方法,同时还需设定两个消息编码常量,这些常量将在后续的流程中发挥重要作用。

  1. CreateTCPListener:此方法用于监听特定地址对应的TCP请求。通过该方法,程序能够实时捕捉到达指定地址的TCP连接请求,为后续的通信交互做好准备。
  2. CreateTCPConn:该方法负责连接到指定的TCP地址。借助它,我们可以轻松建立与目标TCP地址的连接,实现数据的传输与交互。
  3. Join2Conn:这是整个工具方法中的核心所在,虽然代码量仅短短十几行,却意义重大。此方法的功能是将一个TCP连接(TCP-A)的数据写入另一个TCP连接(TCP-B),同时,把TCP-B连接返回的数据再写入TCP-A连接。通过这种双向的数据流转,实现了两个TCP连接之间的数据交互与共享,确保了数据在不同连接之间的顺畅传递。
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
   }
}

客户端

在构建内网穿透系统时,我们先从相对简易的客户端入手。客户端主要承担三项关键任务:

  1. 建立控制通道连接:客户端首先要与服务端的控制通道进行连接。这一连接至关重要,它是后续通信的基础,就像是搭建起了一座沟通的桥梁,使得客户端和服务端能够进行消息的交互与传递。
  2. 监听控制通道消息:成功建立连接后,客户端需持续监听控制通道,等待服务端发来建立连接的消息。这一步骤如同在桥边时刻等待信号,只有收到服务端的“指令”,客户端才能继续下一步的操作。
  3. 建立本地与远端连接:当客户端接收到服务端发来的建立连接消息时,便要利用之前定义的工具方法,将本地服务与远端隧道建立连接。这就好比在本地和远方的目的地之间搭建起了一条高速公路,让数据能够在两者之间自由通行,实现内网服务与公网的连通。
package main

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

   "nat-proxy/cmd/network"
)

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

   remoteIP = "111.111.111.111"
   // 远端的服务控制通道,用来传递控制信息,如出现新连接和心跳
   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
      }

      // 当有新连接信号出现时,新建一个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
}

服务端

服务端的实现过程相对更为复杂,具体包含以下几个关键步骤:

  1. 控制通道监听:持续监听控制通道,实时捕捉客户端发送的连接请求,一旦接收到请求,便迅速响应并完成连接建立。
  2. 访问端口监听:同步监听访问端口,及时接收来自用户的HTTP请求,确保用户的访问需求能够被快速感知。
  3. 连接处理与信令发送:在成功接收用户HTTP请求后,将该连接信息妥善存储,以便后续追踪和管理。同时,立即向客户端发送信令,告知客户端有用户访问,督促客户端尽快建立隧道,为通信做好准备。
  4. 隧道通道监听与连接桥接:对隧道通道进行监听,捕获客户端的连接请求,运用工具方法将客户端连接与用户连接巧妙关联起来,构建起稳定的数据传输链路。
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()
}

// 创建一个控制通道,用于传递控制消息,如:心跳,创建新连接
func createControlChannel() {
   tcpListener, err := network.CreateTCPListener(controlAddr)
   if err != nil {
      panic(err)
   }

   log.Println("[已监听]" + 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)
      }
   }()
}

// 监听来自用户的请求
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,}
}

// 发送给客户端新消息
func sendMessage(message string) {
   if clientConn == nil {
      log.Println("[无已连接的客户端]")
      return
   }
   _, err := clientConn.Write([]byte(message))
   if err != nil {
      log.Println("[发送消息异常]: 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 中未成功建立的连接。

实验操作

首先,在本地通过 Docker 部署一个 Nginx 服务(当然,你也可以启动一个 Tomcat 服务)。接着,将客户端监听端口 localServerAddr 修改为 127.0.0.1:32768,并把 remoteIP 设置为服务端的 IP 地址。完成上述操作后进行访问测试,你会发现服务能够正常被访问。

然后编译打包服务端扔到服务器上启动、客户端本地启动,如果控制台输出连接成功,就完成准备了

现在通过访问服务端的 8007 端口就可以访问我们内网的服务了。

待解决问题

上述实现仅是一个极简版本,仅实现了基本功能,仍存在一些待解决的问题,期待你的探索与完善:

  • 目前系统仅允许单个客户端连接,若要支持多个客户端同时连接,该如何设计与实现?
  • 当前对map的使用存在潜在风险,怎样有效管理连接池,确保系统的稳定性与高效性?
  • TCP连接开销较大,如何优化连接复用机制,以降低资源消耗,提升系统性能?
  • 现有实现基于TCP连接,若要适配UDP协议,具体的实现思路和方法是什么?
  • 当前连接未进行加密处理,如何引入加密技术,保障数据传输的安全性和隐私性?
  • 当前的keepalive实现较为基础,是否存在更优化、更优雅的实现方式,以增强连接的稳定性?

总结

回顾整个实现过程,其实并没有想象中那么复杂。使用Go语言进行开发,极大地简化了难度。正因如此,GitHub上涌现出大量基于Go语言实现的代理和穿透工具。我在实践中参考了这些工具,抽离出其中的核心逻辑,尤其是工具方法中的第三个——数据复制(copy),它堪称关键所在。不过,尽管基本功能得以实现,仍有诸多细节不容忽视。建议你参考下方的源码,继续深入研究探索,相信会有更多收获。

推荐项目

以下是几个用 Go 语言实现内网穿透的库及其 GitHub 链接:

  1. frp:一款高性能的反向代理应用,支持 TCP、UDP 等多种协议,可用于实现内网穿透。
  2. nps:一款轻量级、高性能、功能强大的内网穿透代理服务器,支持多平台。
  3. goproxy:GoProxy是一款轻量级、功能强大、高性能的http代理、https代理、socks5代理、内网穿透代理服务器、ss代理、游戏盾、游戏代理,支持API代理认证。
  4. gost 功能强大的代理隧道工具,不仅支持内网穿透,还具备代理、转发等多种功能,支持 TCP、UDP、HTTP、SOCKS 等多种协议。

文章来源: https://study.disign.me/article/202508/21.golang-internal-network-penetration.md

发布时间: 2025-02-23

作者: 技术书栈编辑