跳转到主要内容
Chang Wei's Blog昌维的博客

Article

用 Go 写一个 TCP 反向隧道与端口转发工具

2026年6月15日星期一 00:00Chang Wei (昌维) <changwei1006@gmail.com>zh-Hans-CN
用 Go 写一个 TCP 反向隧道与端口转发工具 封面图
用 Go 写一个 TCP 反向隧道与端口转发工具 封面图
永久链接:

本文面向只学过基础 C 语言、还没有系统接触过网络通信的读者。我们会从 IP、端口、TCP、NAT、防火墙入站与出站这些最基本的概念讲起,再通过我写的 NATBypass 项目源码,完整拆解一个 TCP 端口转发与反向隧道工具是如何工作的。

NATBypass 网络隧道原理图:内网主机主动连接公网中转机,外部客户端通过中转机访问内网服务
NATBypass 网络隧道原理图:内网主机主动连接公网中转机,外部客户端通过中转机访问内网服务


一、先说清楚:NATBypass 到底解决什么问题?

假设你家里有一台电脑,内网地址是 192.168.1.2,上面开了一个远程桌面服务,监听 3389 端口。你在学校、公司或外面的咖啡店,想从公网访问它。直觉上你可能会觉得:“我知道它的 IP 和端口,直接连 192.168.1.2:3389 不就行了吗?”

答案是不行。因为 192.168.1.2 不是公网地址,而是私有地址。它只在你家里的局域网里有意义。离开这个局域网以后,互联网上的路由器并不知道 192.168.1.2 在哪里。事实上,全世界有无数台机器都可能叫 192.168.1.2,这个地址不是唯一的。

如果你家的路由器有公网 IP,例如 123.123.123.123,并且你在路由器上配置了端口映射,把公网的 3389 转发到内网的 192.168.1.2:3389,那外部客户端就可以访问。但现实里经常遇到几个限制:

  1. 你没有路由器管理权限。
  2. 运营商没有给你真正的公网 IP,而是又套了一层运营商级 NAT。
  3. 服务器所在网络限制了入站连接,但允许主动访问外网。
  4. 你只是临时调试,不想改路由器或防火墙配置。

NATBypass 的核心思路是:既然外面不能主动连进内网,那就让内网主机主动连出去;只要这条出站 TCP 连接建立成功,数据就可以沿着这条连接双向流动。

这听起来有点反直觉。初学者很容易把 TCP 连接理解成“谁连接谁,数据就只能从谁发到谁”。实际上不是这样。TCP 是一个全双工协议,连接一旦建立,双方都可以同时读写数据。就像两个人打电话,虽然电话是其中一个人拨出去的,但接通以后双方都可以讲话。

NATBypass 就是利用这个事实,把两条 TCP 连接“拼接”起来:

flowchart LR A[内网服务<br/>192.168.1.2:3389] B[内网主机运行 nb -slave] C[公网中转机运行 nb -listen] D[外部客户端<br/>远程桌面/浏览器/其他程序] B -- 主动连接公网 1997 --> C D -- 连接公网 2017 --> C C -- 把两条连接的数据互相复制 --> B B -- 转发到本机服务 --> A

从外部客户端的视角看,它连的是公网机器的 2017 端口;从内网服务的视角看,它收到的是本机或内网中的连接;而 NATBypass 负责在中间把字节流搬来搬去。


二、网络通信的最小知识地图

写过基础 C 程序的人,一般已经理解变量、函数、循环、条件判断和文件读写。网络编程本质上也可以先用类似的方式理解:网络连接像一个“跨机器的文件”,程序可以从里面读字节,也可以往里面写字节。不同的是,普通文件保存在磁盘上,而网络连接的另一端是另一个进程。

2.1 IP 地址:机器在网络中的位置

IP 地址可以粗略理解成网络世界里的“门牌号”。常见的 IPv4 地址由四段数字组成,例如:

192.168.1.2
8.8.8.8
123.123.123.123

但不是所有 IP 都能在公网直接访问。IPv4 地址数量很少,所以有一些地址段被专门留给局域网使用,最常见的是:

10.0.0.0/8
172.16.0.0/12
192.168.0.0/16

你家路由器下面的电脑、手机、平板,经常会拿到 192.168.1.x 这样的地址。这些地址只在家里这张小网络里有效。互联网骨干路由器不会帮你转发目的地为 192.168.1.2 的包,因为它无法知道你指的是哪一个家庭、哪一个公司、哪一台电脑。

2.2 端口:同一台机器上的不同服务

一台机器可能同时运行很多网络程序。浏览器访问网页、SSH 登录、远程桌面、数据库服务,都可能在同一个 IP 上同时存在。为了区分这些服务,TCP 和 UDP 引入了端口号。端口号是 165535 之间的整数。

可以把 IP 和端口合起来理解:

IP 地址 = 找到哪一台机器
端口号 = 找到这台机器上的哪一个程序

所以 192.168.1.2:3389 表示:找到 192.168.1.2 这台机器上监听 3389 端口的程序。远程桌面默认使用 3389,HTTP 默认使用 80,HTTPS 默认使用 443,SSH 默认使用 22

NATBypass 的命令行参数里到处都是端口和 ip:port

nb -listen 1997 2017
nb -tran 1997 192.168.1.2:3389
nb -slave 127.0.0.1:3389 123.123.123.123:1997

如果理解了“IP 定位机器、端口定位进程”,这些参数就不再神秘。

2.3 TCP:可靠的字节流

TCP(Transmission Control Protocol,传输控制协议)是互联网上最重要的协议之一。它给程序员提供的抽象非常友好:你看到的是一条连续的字节流,而不是一个个容易丢失、乱序、重复的网络包。

从程序员角度看,TCP 有几个关键特性:

  1. 面向连接:通信前要先建立连接。
  2. 可靠传输:丢包会重传,乱序会重排。
  3. 全双工:连接两端都可以同时发送和接收。
  4. 字节流:没有天然的消息边界,读到多少字节取决于缓冲区和网络状态。

“字节流”这个概念特别重要。NATBypass 并不理解远程桌面协议、HTTP 协议、SSH 协议的内容。它只负责复制字节。只要上层协议跑在 TCP 上,NATBypass 就可以把一端收到的字节写到另一端。

这就像水管工不需要知道水里溶解了什么矿物质,只需要把两根管子接起来,让水能流过去。

2.4 监听、连接和接受连接

在 TCP 编程里,服务端和客户端的角色通常这样分工:

sequenceDiagram participant S as 服务端程序 participant O as 操作系统内核 participant C as 客户端程序 S->>O: listen(0.0.0.0:1997) C->>O: connect(服务器IP:1997) O-->>S: accept() 返回一个连接 S->>C: 可以读写数据 C->>S: 也可以读写数据

服务端先监听端口,客户端主动连接这个端口。连接建立以后,服务端的 accept 会返回一个代表本次连接的对象。后续读写都发生在这个连接对象上。

在 Go 里,这几个动作分别对应:

listener, err := net.Listen("tcp", "0.0.0.0:1997")
conn, err := listener.Accept()
target, err := net.Dial("tcp", "192.168.1.2:3389")

如果你学过 C 语言里的文件操作,可以类比:

FILE *fp = fopen("a.txt", "rb");
fread(buf, 1, n, fp);
fwrite(buf, 1, n, fp);

TCP 连接也可以读写,只不过读写对象不是磁盘文件,而是网络对端。

2.5 NAT:为什么内网主机能访问外网,外网却不能直接访问内网?

NAT 网络地址转换原理图:路由器将内网私有地址转换为公网地址,使得内网主机可以访问互联网
NAT 网络地址转换原理图:路由器将内网私有地址转换为公网地址,使得内网主机可以访问互联网

NAT(Network Address Translation,网络地址转换)是理解 NATBypass 的关键。家用路由器一般会做 NAT。内网电脑访问外网时,数据包大概长这样:

源地址:192.168.1.2:50000
目的地址:8.8.8.8:53

这个包到达路由器后,路由器会把源地址改成自己的公网地址和一个临时端口:

源地址:123.123.123.123:62001
目的地址:8.8.8.8:53

同时,路由器在 NAT 表里记一条映射:

123.123.123.123:62001 <-> 192.168.1.2:50000

当外网服务器回复 123.123.123.123:62001 时,路由器查表,知道应该把回复转回 192.168.1.2:50000。这就是为什么内网主机可以主动访问外网。

NAPT 网络地址端口转换示意图:NAT 路由器同时记录地址和端口的映射关系,实现多个内网主机共享同一公网 IP
NAPT 网络地址端口转换示意图:NAT 路由器同时记录地址和端口的映射关系,实现多个内网主机共享同一公网 IP

如果外部客户端直接访问 123.123.123.123:3389,路由器不知道这应该交给内网哪一台机器。除非你提前做端口映射,否则这个入站连接会被丢弃。

flowchart TB subgraph LAN[家庭/公司内网] H1[电脑 A<br/>192.168.1.2] H2[手机 B<br/>192.168.1.3] end R[路由器 NAT<br/>公网 123.123.123.123] Internet[互联网服务器] Client[外部客户端] H1 -->|主动出站:允许| R --> Internet Internet -->|回复已有连接:查 NAT 表| R --> H1 Client -.->|新入站连接:没有映射| R R -. 丢弃 .-> Client

NATBypass 的“绕过”不是破解 NAT,也不是让路由器违反规则,而是换了一个方向:内网主机主动连到公网中转机,这样 NAT 表里就有一条合法的出站连接。之后公网中转机可以沿着这条已经建立的 TCP 连接把数据送回来。

2.6 防火墙入站与出站

防火墙常常区分入站连接和出站连接。入站连接是外部主动连进来,出站连接是本机主动连出去。很多网络环境会禁止入站,但允许访问外网,例如允许浏览网页、更新软件、访问 GitHub 等。

NATBypass 利用的正是这个常见策略:

外部 -> 内网:禁止
内网 -> 外部:允许

因此本文中的“绕过防火墙入站限制”指的是:在你有授权的网络环境中,通过内网机器主动建立出站连接,把需要访问的服务暴露给自己使用。不要把它用于未授权访问、隐藏控制通道或规避组织安全策略。网络工具本身是中性的,使用边界必须由合法授权来决定。


三、NATBypass 的三种工作模式

NATBypass 的 README 里定义了三种模式:-listen-tran-slave。这三个模式看似不同,本质上都在做同一件事:拿到两个 TCP 连接,然后把它们互相转发。

3.1 -tran:最容易理解的本地端口转发

先看 -tran

nb -tran 1997 192.168.1.2:3389

含义是:NATBypass 在本机监听 1997 端口。只要有人连接本机的 1997,它就主动连接 192.168.1.2:3389,然后把这两条连接接起来。

flowchart LR C[客户端] -->|连接本机 :1997| NB[NATBypass] NB -->|主动连接| T[目标服务<br/>192.168.1.2:3389] NB <-->|双向复制字节| C NB <-->|双向复制字节| T

如果你熟悉 SSH 的本地端口转发,可以把它理解成最朴素版本的 TCP 代理。它不会加密,不会认证,也不会理解协议内容,只是转发。

在源码中,-tran 对应 port2host

func port2host(allowPort string, targetAddress string) {
    server := start_server("0.0.0.0:" + allowPort)
    for {
        conn := accept(server)
        if conn == nil {
            continue
        }
        go func(targetAddress string) {
            log.Println("[+]", "start connect host:["+targetAddress+"]")
            target, err := net.Dial("tcp", targetAddress)
            if err != nil {
                log.Println("[x]", "connect target address ["+targetAddress+"] faild.")
                conn.Close()
                time.Sleep(timeout * time.Second)
                return
            }
            log.Println("[→]", "connect target address ["+targetAddress+"] success.")
            forward(target, conn)
        }(targetAddress)
    }
}

这段代码有四个动作:

  1. start_server("0.0.0.0:" + allowPort):监听本机所有网卡上的指定端口。
  2. accept(server):等待客户端连接进来。
  3. net.Dial("tcp", targetAddress):连接真正的目标服务。
  4. forward(target, conn):把目标服务和客户端连接起来。

注意这里用了 go func(...) { ... },也就是启动一个 goroutine。你可以把 goroutine 粗略理解成 Go 语言里非常轻量的线程。每来一个客户端连接,就开一个 goroutine 处理,这样一个客户端慢,不会阻塞下一个客户端。

3.2 -listen:公网中转机等待两端接入

-listen 是反向隧道场景里运行在公网中转机上的模式:

nb -listen 1997 2017

它会同时监听 19972017 两个端口。一个端口给内网主机主动连上来,另一个端口给外部客户端连上来。只要两个端口各收到一个连接,NATBypass 就把它们配对,然后开始转发。

sequenceDiagram participant I as 内网主机 nb -slave participant P as 公网中转机 nb -listen participant C as 外部客户端 P->>P: 监听 :1997 和 :2017 I->>P: 主动连接 :1997 C->>P: 连接 :2017 P->>P: accept 得到两条 TCP 连接 P->>I: 复制客户端发来的字节 I->>P: 复制内网服务返回的字节 P->>C: 返回给外部客户端

源码如下:

func port2port(port1 string, port2 string) {
    listen1 := start_server("0.0.0.0:" + port1)
    listen2 := start_server("0.0.0.0:" + port2)
    log.Println("[√]", "listen port:", port1, "and", port2, "success. waiting for client...")
    for {
        conn1 := accept(listen1)
        conn2 := accept(listen2)
        if conn1 == nil || conn2 == nil {
            log.Println("[x]", "accept client faild. retry in ", timeout, " seconds. ")
            time.Sleep(timeout * time.Second)
            continue
        }
        forward(conn1, conn2)
    }
}

初学者读这段代码时,最容易忽略的是 accept 的顺序。它先等 port1 的连接,再等 port2 的连接。也就是说,在这个简单实现里,一对连接是顺序配对的:先连上 1997 的连接,会和后连上 2017 的连接组成一组。

这不是复杂的连接池,也没有身份认证和多路复用。它非常直接,适合学习 TCP 隧道的本质:连接对象本身就是通道,把两个连接对象接起来,就得到一个逻辑上的隧道。

3.3 -slave:内网主机主动打洞到公网

-slave 运行在内网主机上:

nb -slave 127.0.0.1:3389 123.123.123.123:1997

含义是:内网主机先连接自己的 127.0.0.1:3389,再连接公网中转机的 123.123.123.123:1997,然后把这两条连接接起来。

这里的 127.0.0.1 是环回地址,也就是“本机自己”。如果远程桌面服务就在同一台机器上,就可以写 127.0.0.1:3389;如果服务在同一个局域网的另一台机器上,也可以写 192.168.1.2:3389

flowchart LR S[内网服务<br/>127.0.0.1:3389] N[内网主机<br/>nb -slave] P[公网中转机<br/>123.123.123.123:1997] N -->|net.Dial tcp| S N -->|net.Dial tcp<br/>主动出站| P N <-->|forward 双向复制| S N <-->|forward 双向复制| P

源码:

func host2host(address1, address2 string) {
    for {
        log.Println("[+]", "try to connect host:["+address1+"] and ["+address2+"]")
        var host1, host2 net.Conn
        var err error
        for {
            host1, err = net.Dial("tcp", address1)
            if err == nil {
                log.Println("[→]", "connect ["+address1+"] success.")
                break
            } else {
                log.Println("[x]", "connect target address ["+address1+"] faild. retry in ", timeout, " seconds. ")
                time.Sleep(timeout * time.Second)
            }
        }
        for {
            host2, err = net.Dial("tcp", address2)
            if err == nil {
                log.Println("[→]", "connect ["+address2+"] success.")
                break
            } else {
                log.Println("[x]", "connect ["+address2+"] faild. retry in ", timeout, " seconds. ")
                time.Sleep(timeout * time.Second)
            }
        }
        forward(host1, host2)
    }
}

这段代码体现了反向隧道工具常见的“保持连接”思路:如果连接失败,就等待几秒再试;如果成功,就进入 forward;当 forward 返回后,外层 for 会再次开始,尝试建立下一轮连接。

它没有花哨的状态机,但逻辑很清楚:

一直循环:
    直到连上内网服务
    直到连上公网中转机
    开始双向转发
    转发结束后重新来过

对于只会 C 的读者来说,可以把它想成:

while (1) {
    while (connect_to_local_service() failed) {
        sleep(5);
    }
    while (connect_to_public_server() failed) {
        sleep(5);
    }
    forward_two_sockets();
}

四、真正的核心:forward 如何把两条 TCP 连接接起来?

现在我们已经知道三种模式都在做同一件事:拿到两个 net.Conn。那么 NATBypass 的灵魂就在 forwardconnCopy 里。

源码:

func forward(conn1 net.Conn, conn2 net.Conn) {
    log.Printf("[+] start transmit. [%s],[%s] <-> [%s],[%s] \n",
        conn1.LocalAddr().String(),
        conn1.RemoteAddr().String(),
        conn2.LocalAddr().String(),
        conn2.RemoteAddr().String())
    var wg sync.WaitGroup
    wg.Add(2)
    go connCopy(conn1, conn2, &wg)
    go connCopy(conn2, conn1, &wg)
    wg.Wait()
}

它做了三件事:

  1. 创建一个 sync.WaitGroup,用来等待两个 goroutine 结束。
  2. 启动 connCopy(conn1, conn2),负责一个方向的数据复制。
  3. 启动 connCopy(conn2, conn1),负责另一个方向的数据复制。

为什么需要两个 goroutine?因为 TCP 是全双工的。客户端可能正在发送数据,服务端也可能同时返回数据。如果只用一个循环先读 A 再写 B,然后再读 B 再写 A,就很容易卡住。正确做法是两个方向并发复制:

flowchart LR A[连接 A] -->|goroutine 1<br/>io.Copy(B, A)| B[连接 B] B -->|goroutine 2<br/>io.Copy(A, B)| A

再看 connCopy

func connCopy(conn1 net.Conn, conn2 net.Conn, wg *sync.WaitGroup) {
    logFile := openLog(conn1.LocalAddr().String(), conn1.RemoteAddr().String(), conn2.LocalAddr().String(), conn2.RemoteAddr().String())
    if logFile != nil {
        w := io.MultiWriter(conn1, logFile)
        io.Copy(w, conn2)
    } else {
        io.Copy(conn1, conn2)
    }
    conn1.Close()
    log.Println("[←]", "close the connect at local:["+conn1.LocalAddr().String()+"] and remote:["+conn1.RemoteAddr().String()+"]")
    wg.Done()
}

这里最重要的一行是:

io.Copy(conn1, conn2)

Go 标准库的 io.Copy(dst, src) 会不断从 src 读取数据,然后写入 dst,直到读到 EOF 或发生错误。对网络连接来说,它就等价于:

buf := make([]byte, 32*1024)
for {
    n, readErr := conn2.Read(buf)
    if n > 0 {
        _, writeErr := conn1.Write(buf[:n])
        if writeErr != nil {
            break
        }
    }
    if readErr != nil {
        break
    }
}

如果改写成 C 风格伪代码,大概是:

char buf[32768];
while (1) {
    int n = read(socket_b, buf, sizeof(buf));
    if (n <= 0) {
        break;
    }
    write(socket_a, buf, n);
}
close(socket_a);

这就是所有 TCP 转发工具的本质。你甚至可以说,NATBypass 的核心思想只有八个字:一边读取,一边写入。

4.1 为什么关闭一边连接会让另一边也结束?

io.Copy(conn1, conn2) 结束时,代码会关闭 conn1。与此同时,另一个 goroutine 可能正在执行 io.Copy(conn2, conn1)conn1 被关闭后,另一个方向的读写也会收到错误或 EOF,于是它也会结束,再关闭 conn2

这个行为使得任意一边断开连接后,整条转发链路都会被清理掉。比如外部客户端关闭远程桌面窗口,公网中转机的一个方向复制结束,连接关闭,另一方向也随之退出。

简化成流程图:

flowchart TD A[客户端关闭连接] --> B[一个 io.Copy 返回] B --> C[关闭目标连接] C --> D[另一个 io.Copy 读写失败] D --> E[另一个 goroutine 返回] E --> F[WaitGroup 等到两个方向结束] F --> G[forward 返回]

4.2 日志记录为什么用 io.MultiWriter

NATBypass 支持 -log 参数。打开日志后,它不仅把数据写到对端连接,还会同时写入日志文件:

w := io.MultiWriter(conn1, logFile)
io.Copy(w, conn2)

io.MultiWriter 可以把同一份数据写入多个 Writer。这里的意思是:从 conn2 读到的每一段字节,既写给 conn1,也写到 logFile

这对理解网络协议很有帮助,因为你可以把转发过程中的原始字节保存下来,再用十六进制编辑器或协议分析工具观察。不过也要注意,很多协议传输的是账号、Cookie、令牌或其他敏感数据;日志功能只能在授权调试场景使用,不能拿来记录他人的通信内容。


五、从 main 函数看命令行如何分派

前面我们直接看了三个模式的函数。现在回到入口函数 main

func main() {
    log.SetFlags(log.Ldate | log.Lmicroseconds)
 
    printWelcome()
 
    args := os.Args
    argc := len(os.Args)
    if argc <= 2 {
        printHelp()
        os.Exit(0)
    }
 
    switch args[1] {
    case "-listen":
        port1 := checkPort(args[2])
        port2 := checkPort(args[3])
        port2port(port1, port2)
    case "-tran":
        port := checkPort(args[2])
        var remoteAddress string
        if checkIp(args[3]) {
            remoteAddress = args[3]
        }
        port2host(port, remoteAddress)
    case "-slave":
        var address1, address2 string
        if checkIp(args[2]) {
            address1 = args[2]
        }
        if checkIp(args[3]) {
            address2 = args[3]
        }
        host2host(address1, address2)
    default:
        printHelp()
    }
}

os.Args 是命令行参数数组。如果你运行:

nb -slave 127.0.0.1:3389 123.123.123.123:1997

那么它大概长这样:

args[0] = "nb"
args[1] = "-slave"
args[2] = "127.0.0.1:3389"
args[3] = "123.123.123.123:1997"

所以 switch args[1] 就是在判断用户选择了哪种模式。后面的 checkPortcheckIp 用来做基本参数校验:

func checkPort(port string) string {
    PortNum, err := strconv.Atoi(port)
    if err != nil {
        log.Fatalln("[x]", "port should be a number")
    }
    if PortNum < 1 || PortNum > 65535 {
        log.Fatalln("[x]", "port should be a number and the range is [1,65536)")
    }
    return port
}

端口是字符串输入,但程序需要确认它是数字,并且在合法范围内。strconv.Atoi 的作用类似 C 里的 atoi,但 Go 会返回错误,能区分“转换成功”和“转换失败”。

IP 校验使用正则表达式:

pattern := `^(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])$`
ok, err := regexp.MatchString(pattern, ip)

这个正则确保每一段 IP 都在 0255 之间。它不支持域名,也不支持 IPv6,这是早期小工具很常见的取舍:先把最核心的 TCP 转发跑通,再考虑扩展能力。


六、完整反向隧道示例:远程访问内网 RDP

现在把所有概念串起来。假设:

公网中转机:123.123.123.123
公网中转机开放端口:1997、2017
内网主机:192.168.1.2
内网服务:RDP 远程桌面 127.0.0.1:3389
外部客户端:你的笔记本

第一步,在公网中转机运行:

nb -listen 1997 2017

它会监听两个端口:

1997:等待内网主机主动连上来
2017:等待外部客户端连上来

第二步,在内网主机运行:

nb -slave 127.0.0.1:3389 123.123.123.123:1997

这会建立两条连接:

内网主机 -> 本机远程桌面服务 127.0.0.1:3389
内网主机 -> 公网中转机 123.123.123.123:1997

第三步,在外部客户端连接:

123.123.123.123:2017

连接路径如下:

flowchart LR C[外部客户端] -->|TCP connect :2017| P2[公网中转机<br/>连接 B] P1[公网中转机<br/>连接 A] <-->|由 nb -listen 配对| P2 P1 <-->|TCP 已建立| N2[内网主机<br/>连接到 :1997] N1[内网主机<br/>连接本机 :3389] <-->|由 nb -slave 转发| N2 N1 -->|访问| RDP[RDP 服务] RDP --> N1 --> N2 --> P1 --> P2 --> C

可以把它理解成两级拼接:

外部客户端 <-> 公网中转机 <-> 内网主机 <-> 内网服务

公网中转机不知道 RDP 协议是什么,内网主机的 NAT 也不知道远端客户端是谁。它们只是在搬运 TCP 字节。

6.1 为什么这能穿过 NAT?

关键在第二步。内网主机主动连接了 123.123.123.123:1997。对家用路由器来说,这就是一条正常的出站连接,和浏览器访问网站没有本质区别。路由器会为它创建 NAT 映射。只要连接不断开,公网中转机就可以沿着这条连接把数据发回内网主机。

注意,这不是 UDP 打洞,也不是 STUN/TURN 那种点对点穿透。NATBypass 的模式更简单:它需要一台有公网地址的中转机。外部客户端和内网主机都连接这台中转机,由中转机负责配对和转发。

它牺牲的是一台中转机和额外的转发路径,换来的是实现简单、可解释、可调试。


七、如果从零实现,你应该先写哪几行?

为了帮助只会基础 C 的读者建立直觉,我们可以把 NATBypass 拆成四个逐步升级的小程序。

7.1 第一版:只做 TCP 客户端

最小 TCP 客户端只需要连接目标:

conn, err := net.Dial("tcp", "example.com:80")
if err != nil {
    log.Fatal(err)
}
defer conn.Close()

这相当于 C 里的 socket() + connect()。连接成功后,你就可以 ReadWrite

7.2 第二版:只做 TCP 服务端

服务端先监听,再接受连接:

listener, err := net.Listen("tcp", "0.0.0.0:1997")
if err != nil {
    log.Fatal(err)
}
for {
    conn, err := listener.Accept()
    if err != nil {
        continue
    }
    go handle(conn)
}

这相当于 C 里的 socket() + bind() + listen() + accept()。不同语言的 API 名字不同,但概念非常稳定。

7.3 第三版:把一个连接的数据打印出来

func handle(conn net.Conn) {
    defer conn.Close()
    buf := make([]byte, 4096)
    for {
        n, err := conn.Read(buf)
        if n > 0 {
            fmt.Printf("%x\n", buf[:n])
        }
        if err != nil {
            return
        }
    }
}

这一步可以帮助你理解:网络连接里流动的就是字节。文本协议看起来像字符串,二进制协议看起来像十六进制,但本质都是字节数组。

7.4 第四版:把两个连接互相复制

最后才是 NATBypass 的核心:

func forward(a, b net.Conn) {
    go io.Copy(a, b)
    go io.Copy(b, a)
}

真实项目里还需要处理关闭、等待、日志、错误和重试,所以 NATBypass 写成了 forward + connCopy + WaitGroup。但最小模型就是这两行。


八、用数据包视角再看一遍

从应用程序视角看,NATBypass 复制的是 net.Conn 的字节流。从网络层视角看,实际发生的是很多 TCP 段在不同连接上来回传输。

假设外部客户端发送一个 RDP 数据片段 X

外部客户端 -> 公网中转机:2017:发送 X
公网中转机 nb -listen:从连接 B 读到 X
公网中转机 nb -listen:把 X 写入连接 A
内网主机 nb -slave:从连接公网的那条连接读到 X
内网主机 nb -slave:把 X 写入 127.0.0.1:3389
RDP 服务:收到 X

RDP 服务返回响应 Y 时,路径反过来:

RDP 服务 -> 内网主机 nb -slave:发送 Y
内网主机 nb -slave:把 Y 写入公网连接
公网中转机 nb -listen:读到 Y
公网中转机 nb -listen:把 Y 写入外部客户端连接
外部客户端:收到 Y

这就是“隧道”的含义:上层协议以为自己在和对端直接通信,但中间其实被包装在另一组连接里传输了。

sequenceDiagram participant C as 外部客户端 participant P as 公网中转机 participant N as 内网主机 participant S as 内网服务 C->>P: 数据 X P->>N: 复制 X N->>S: 复制 X S->>N: 响应 Y N->>P: 复制 Y P->>C: 复制 Y

九、NATBypass 的设计取舍

NATBypass 是一个很适合教学的项目,因为它把最关键的东西保留下来,把很多工程化复杂度拿掉了。

它保留了:

  1. TCP 监听与主动连接。
  2. 两条连接的双向复制。
  3. 内网主动连接公网中转机的反向隧道思想。
  4. 连接失败后的简单重试。
  5. 原始数据日志记录。

它没有实现:

  1. TLS 加密。
  2. 用户认证。
  3. 多客户端会话管理。
  4. 连接心跳。
  5. UDP 转发。
  6. 域名和 IPv6 支持。
  7. 流量压缩或多路复用。

这些不是缺陷,而是学习材料的价值所在。很多成熟工具功能非常强,但一上来就包含配置文件、加密握手、连接池、多路复用、权限控制和插件机制,新手很难看清最核心的“两个连接互相复制”。NATBypass 的代码短,主线清楚,非常适合作为网络编程入门项目。

如果将来继续扩展,可以沿着这些方向:

mindmap root((NATBypass 扩展方向)) 安全 TLS 加密 访问口令 白名单 可靠性 心跳检测 自动重连 半关闭处理 协议 UDP 转发 IPv6 域名解析 运维 配置文件 systemd 服务 结构化日志

但在学习阶段,不要急着加功能。先把 TCP、NAT、监听、连接、转发、关闭这条主线吃透,后面的功能才有落点。


十、常见误解

10.1 “反向连接”是不是只能从内网往外发数据?

不是。连接建立的方向和数据流动方向是两回事。内网主机主动拨号只是为了穿过 NAT 和防火墙出站规则。TCP 建立以后,双方都可以发送数据。公网中转机完全可以把外部客户端的数据沿着这条连接写回内网主机。

10.2 NATBypass 会不会修改 RDP、HTTP、SSH 的协议内容?

不会。它只复制字节,不解析上层协议。正因为不解析,所以它对大多数 TCP 协议都通用;也正因为不解析,所以它无法做应用层鉴权、协议感知路由或内容过滤。

10.3 为什么不直接用端口映射?

如果你能控制路由器,并且有公网 IP,端口映射通常更直接。但很多场景没有这个条件。反向隧道适用于“内网能主动访问外网,但外网不能主动访问内网”的场景。

10.4 为什么需要公网中转机?

因为内网地址不能被互联网直接路由。公网中转机是双方都能访问到的会合点。内网主机连它,外部客户端也连它,它再把连接配对。

10.5 这和 VPN 有什么区别?

VPN 通常在网络层或更完整的隧道层工作,可以让一整个网段互通,并且包含加密、认证、路由下发等能力。NATBypass 更像一个轻量 TCP 端口转发器:它只处理指定端口上的 TCP 字节流。


十一、学习路线:从 C 语言到网络工具

如果你只会基础 C,建议按下面路线学习,而不是一上来就背七层模型:

  1. 先理解“进程”和“端口”的关系:一个端口通常对应一个正在监听的进程。
  2. 再理解 TCP 的连接模型:服务端 listen/accept,客户端 connect
  3. 然后写一个 echo server:客户端发什么,服务端回什么。
  4. 接着写一个 TCP proxy:客户端连代理,代理连目标,把两边数据互相复制。
  5. 最后理解 NAT:为什么内网出站容易,公网入站困难。

NATBypass 正好位于第 4 步和第 5 步之间。它不是纯理论,也不是复杂到看不懂的大工程。读完它,你应该能回答几个关键问题:

为什么需要两个端口?
为什么内网主机要主动连接公网?
为什么 io.Copy 要启动两个 goroutine?
为什么一个 TCP 代理不需要理解上层协议?
为什么连接断开后要关闭另一侧连接?

只要这些问题能答出来,你就已经迈过了网络编程最重要的一道门槛。


十二、总结

NATBypass 的核心并不神秘。它建立在几个非常朴素的事实之上:

  1. TCP 连接是可靠的全双工字节流。
  2. 内网主机通常不能被公网直接访问,但往往可以主动访问公网。
  3. NAT 会允许已有出站连接的返回流量。
  4. 两条 TCP 连接可以通过双向复制字节拼成一条逻辑通道。
  5. io.Copynet.Listennet.DialAccept 这些 API 已经把底层复杂性封装好了。

如果用一句话概括 NATBypass:

它让内网主机主动连接公网中转机,再把公网客户端连接和内网服务连接配对,通过双向复制 TCP 字节流,实现简单直接的反向隧道和端口转发。

学习网络编程时,最难的往往不是 API,而是脑海里没有数据如何流动的图。只要把“谁监听、谁连接、谁读、谁写、谁关闭”画清楚,很多看似高深的网络工具都会变得可以理解。NATBypass 的价值也正在这里:用很短的代码,把 TCP 转发、NAT 出站、反向隧道这些概念放在同一个可运行的例子里。

最后再次提醒:端口转发和反向隧道只能用于你拥有或获得授权的系统。理解原理是为了更好地调试、运维和保护自己的网络,而不是绕过他人的安全边界。