在日常开发过程中,相信不少开发者都遭遇过这样的困扰:如何将本地机器上运行的服务暴露到公网,让外部能够顺利访问?就拿我们在家上网的场景来说,所分配到的IP地址通常并非公网IP ,这就导致其他人无法直接通过IP访问我们本地运行的服务。这在微信接口调试、第三方平台对接等场景下,就会造成极大的不便。按照常规做法,就得把服务部署到公网服务器上,不仅步骤繁琐,还需要投入一定的成本,实在是费时费力。
这个时候,内网穿透技术便应运而生。它打破了网络限制,即便服务运行在家中本地环境,其他人也能轻松访问。
今天,我们就通过一个简单易懂的案例,深入学习如何运用Go语言打造一个基础的内网穿透工具,手把手带你揭开内网穿透的神秘面纱,轻松解决公网访问难题。
整体结构
首先我们用几张图来说明一下我们是如何实现的,说清楚之后再来用代码实现一下。
当前网络情况
从图示中能够清晰地看到,以实线标识的部分,是我们当前能够顺利访问的路径;而以虚线标识的部分,则是我们目前无法直接访问的路径。
目前,我们现有的访问路径如下:
- 用户主动发起对公网服务器的访问,这一过程是可行的。
- 内网主动访问公网服务,同样不存在问题。
当下,我们亟待解决的问题是,如何使用户能够访问到内网服务。不难理解,如果公网服务能够成功访问内网服务,那么用户就可以通过公网服务这个“桥梁”,间接实现对内网服务的访问。
想法虽然清晰明了,但在实际操作中,该如何落地呢?毕竟用户无法直接访问内网服务,同理,公网服务器也无法直接访问内网服务。所以,我们必须巧妙借助现有的网络链路,来达成这一目标 。
基本架构
在内网穿透的实现架构中,主要涉及三个关键角色:
- 内网客户端:这是我们需要搭建的,用于在内网环境中进行相关操作。
- 外网服务端:同样是我们要构建的,处于外网环境,负责与外界交互。
- 访问者(用户):作为服务的请求发起方。
整个流程如下:
- 控制通道建立与信息传递:由于内网能够访问公网,而公网不清楚内网的具体位置,所以首先需要搭建一个控制通道用于消息传递。首次通信时,客户端必须主动告知服务端自己的位置信息。
- 服务端监听:服务端通过8007端口监听来自用户的请求,时刻准备接收用户访问指令。
- 通知客户端:当服务端接收到用户发来的请求后,会通过之前建立的控制信道,告知客户端有用户发起访问。
- 隧道通道建立:客户端收到通知消息后,着手建立隧道通道。它会主动访问服务端的8008端口,以此建立TCP连接,为后续的数据传输搭建通道。
- 客户端与内网服务连接:在建立与服务端连接的同时,客户端还需要与本地需要暴露的服务(127.0.0.1:8080)建立连接,从而打通内网服务与外部交互的链路。
- 请求转发:连接全部建立完成后,服务端将接收到的8007端口的请求,转发到隧道端口8008中,让请求进入隧道传输体系。
- 内网交互与信息返回:客户端从隧道中获取用户请求,并将其转发给内网服务。同时,客户端会把内网服务的返回信息重新放入隧道,以便沿着原路径返回给用户。
最终的请求流向,就如同图中紫色箭头所指示的方向;而请求返回的路径,则如红色箭头所示。需要明确的是,一旦TCP连接建立,通信双方都能向对方发送信息。所以,整个内网穿透的原理其实并不复杂,核心就在于利用现有的单向网络通路建立TCP连接,获取对方位置信息,进而实现请求的转发。
代码实现
工具方法
在构建内网穿透工具的过程中,我们首先需要定义三个极为关键的工具方法,同时还需设定两个消息编码常量,这些常量将在后续的流程中发挥重要作用。
CreateTCPListener
:此方法用于监听特定地址对应的TCP请求。通过该方法,程序能够实时捕捉到达指定地址的TCP连接请求,为后续的通信交互做好准备。CreateTCPConn
:该方法负责连接到指定的TCP地址。借助它,我们可以轻松建立与目标TCP地址的连接,实现数据的传输与交互。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
}
}
客户端
在构建内网穿透系统时,我们先从相对简易的客户端入手。客户端主要承担三项关键任务:
- 建立控制通道连接:客户端首先要与服务端的控制通道进行连接。这一连接至关重要,它是后续通信的基础,就像是搭建起了一座沟通的桥梁,使得客户端和服务端能够进行消息的交互与传递。
- 监听控制通道消息:成功建立连接后,客户端需持续监听控制通道,等待服务端发来建立连接的消息。这一步骤如同在桥边时刻等待信号,只有收到服务端的“指令”,客户端才能继续下一步的操作。
- 建立本地与远端连接:当客户端接收到服务端发来的建立连接消息时,便要利用之前定义的工具方法,将本地服务与远端隧道建立连接。这就好比在本地和远方的目的地之间搭建起了一条高速公路,让数据能够在两者之间自由通行,实现内网服务与公网的连通。
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
}
服务端
服务端的实现过程相对更为复杂,具体包含以下几个关键步骤:
- 控制通道监听:持续监听控制通道,实时捕捉客户端发送的连接请求,一旦接收到请求,便迅速响应并完成连接建立。
- 访问端口监听:同步监听访问端口,及时接收来自用户的HTTP请求,确保用户的访问需求能够被快速感知。
- 连接处理与信令发送:在成功接收用户HTTP请求后,将该连接信息妥善存储,以便后续追踪和管理。同时,立即向客户端发送信令,告知客户端有用户访问,督促客户端尽快建立隧道,为通信做好准备。
- 隧道通道监听与连接桥接:对隧道通道进行监听,捕获客户端的连接请求,运用工具方法将客户端连接与用户连接巧妙关联起来,构建起稳定的数据传输链路。
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 链接:
- frp:一款高性能的反向代理应用,支持 TCP、UDP 等多种协议,可用于实现内网穿透。
- GitHub 链接:https://github.com/fatedier/frp
- nps:一款轻量级、高性能、功能强大的内网穿透代理服务器,支持多平台。
- GitHub 链接:https://github.com/ehang-io/nps
- goproxy:GoProxy是一款轻量级、功能强大、高性能的http代理、https代理、socks5代理、内网穿透代理服务器、ss代理、游戏盾、游戏代理,支持API代理认证。
- GitHub 链接:https://github.com/snail007/goproxy
- gost 功能强大的代理隧道工具,不仅支持内网穿透,还具备代理、转发等多种功能,支持 TCP、UDP、HTTP、SOCKS 等多种协议。
- GitHub 链接:https://github.com/ginuerzh/gost
文章来源: https://study.disign.me/article/202508/21.golang-internal-network-penetration.md
发布时间: 2025-02-23
作者: 技术书栈编辑