gopm:一个极简的内网穿透工具

一、为什么又要造轮子 #

说到内网穿透,frp 是绕不开的名字——功能全、生态好、star 数万。但对我而言,它有几个始终让我不太舒服的点:

第一,太重了。 frp 的二进制加上配置文件、权限体系、插件系统,做一个简单的端口映射显得杀鸡用牛刀。我很多时候只是想把本地的 8080 端口临时暴露到公网上,让同事看一眼我正在开发的东西,不需要 dashboard、不需要加密、不需要负载均衡。

第二,看不懂。 这不是贬义——frp 是一个成熟的生产级项目,代码量和复杂度自然高。但"看不懂"意味着出了问题只能去翻 issue、等别人修。对于工具类项目,我更希望自己能从头到尾读懂每一行代码。

第三,依赖多。 frp 引入了相当多的第三方库,升级、维护、安全审计都有成本。

所以 gopm 的设计目标从一开始就明确了:

纯 Go 标准库,零外部依赖,单二进制,一个文件搞定。

这不是要替代 frp,而是在另一个极端上做一个选择——用最少的代码做最核心的事。


二、gopm 是什么 #

gopm(Go Port Mapping)是一个通过反向 TCP 隧道实现内网穿透的工具。服务端和客户端共用同一个二进制文件,通过 -mode 参数区分角色。

一句话概括它的工作方式:

内网服务 ←→ 客户端 ←→ 公网服务器 ←→ 外部访客

你在公网服务器上跑服务端,在内网机器上跑客户端,告诉它把本地的哪个端口映射到公网的哪个端口。然后任何人访问公网的那个端口,流量就会被转发到你的内网服务上。

就这么简单。


三、核心设计 #

3.1 双连接模型 #

gopm 最重要的架构决策是控制连接与数据连接分离

  • 控制连接:客户端启动后与服务端建立一个长连接,维持整个生命周期。心跳包(ping/pong)走这条连接,新访客到达的通知也走这条连接。
  • 数据连接:每当有外部访客访问映射端口,服务端通知客户端,客户端新建一条 TCP 连接到服务端,建立数据隧道,双向透传流量。

为什么不让一条连接既传控制消息又传数据?因为 TCP 是字节流,控制消息是 JSON 文本,而数据是原始二进制——混在一起就需要做协议分帧,复杂度立马上来。分开之后,控制连接只走 JSON 行协议,数据连接 join 握手后直接变成 raw TCP 透传,干净利落。

访客 ──→ 服务器:映射端口 ──→ 服务器通知客户端 ──→ 客户端建数据连接 ──→ 本地服务

3.2 协议极简 #

控制连接上的消息是 \n 分隔的 JSON 行,只有几种类型:

消息类型方向用途
register客户端→服务端注册端口映射
register_ok服务端→客户端注册成功确认
ping / pong双向心跳保活
new_conn服务端→客户端通知有新访客
join客户端→服务端数据连接加入隧道
join_ok服务端→客户端隧道建立完成

握手完成后,数据连接上不再有任何 JSON——纯 TCP 双向透传,零开销。

消息大小限制为 4 KiB,防止恶意客户端发送超大消息耗尽内存。

3.3 心跳与超时回收 #

客户端每 15 秒发送一次 ping,服务端回复 pong。如果服务端 45 秒没有收到任何消息(包括心跳),就判定客户端已失联,自动回收映射资源、关闭监听端口。

这个机制解决了一个很实际的问题:客户端网络断开但没有正常关闭连接(比如笔记本合盖、WiFi 断了),服务端需要能自动发现并清理,否则映射端口会一直被占用。

3.4 断线重连 #

-retry 参数启用后,客户端在连接断开时自动重连,重连间隔按退避策略递增:

1秒 → 2秒 → 5秒 → 10秒 → 10秒 …

重连成功后自动重新发送注册请求,恢复映射。对于网络不稳定的环境(比如移动网络、校园网),这个功能几乎是必需的。


四、功能一览 #

服务端参数 #

1
./gopm -mode server -port <控制端口> [-token <令牌>] [-timeout <秒>] [-verbose]
参数说明
-port控制端口,接收客户端连接
-token可选鉴权令牌,设置后客户端必须提供相同值
-timeout自动关闭时长(秒),0 为永久运行
-verbose输出详细日志

客户端参数 #

1
2
./gopm -mode client -server <地址> -local <本地地址> -map <映射端口> \
  [-token <令牌>] [-name <名称>] [-retry] [-timeout <秒>] [-verbose]
参数说明
-server服务端控制地址,如 1.2.3.4:9000
-local本地服务地址,支持 8080127.0.0.1:8080
-map服务端暴露的映射端口
-token鉴权令牌,需与服务端一致
-name客户端标识名称,方便在日志中区分
-retry启用断线自动重连
-timeout自动关闭时长(秒)
-verbose详细日志

五、使用场景 #

场景一:临时展示本地 Web 服务 #

开发了一个前端项目,想给同事看看效果:

1
2
3
4
5
# 公网服务器
./gopm -mode server -port 9000

# 你的电脑
./gopm -mode client -server YOUR_SERVER:9000 -local 3000 -map 8080 -retry

同事访问 http://YOUR_SERVER:8080 即可看到你本地的 localhost:3000

场景二:带鉴权的正式使用 #

给服务端加个 Token,防止任何人随意注册映射:

1
2
3
4
5
# 服务端
./gopm -mode server -port 9000 -token my_secret

# 客户端
./gopm -mode client -server YOUR_SERVER:9000 -local 8080 -map 8080 -token my_secret -retry

Token 不匹配的客户端会收到 unauthorized 错误。

场景三:映射内网数据库 #

需要远程连接内网的 MySQL:

1
./gopm -mode client -server YOUR_SERVER:9000 -local 192.168.1.50:3306 -map 13306 -token db_tunnel

外部通过 YOUR_SERVER:13306 即可连接内网数据库。

场景四:多客户端并行 #

一个服务端可以同时服务多个客户端,每个映射不同端口:

1
2
3
4
5
# 客户端 A:Web 服务
./gopm -mode client -server YOUR_SERVER:9000 -local 3000 -map 8080

# 客户端 B:API 服务
./gopm -mode client -server YOUR_SERVER:9000 -local 9090 -map 9090

端口冲突会被自动检测并拒绝——同一映射端口只能被一个客户端占用。

场景五:定时自动关闭 #

临时调试,不想忘了关进程?用 -timeout 设个倒计时:

1
2
3
4
5
# 服务端,1 小时后自动关闭
./gopm -mode server -port 9000 -timeout 3600

# 客户端,30 分钟后自动关闭
./gopm -mode client -server YOUR_SERVER:9000 -local 8080 -map 8080 -timeout 1800

超时后自动触发优雅关闭,打印日志并退出。


六、安全设计 #

特性说明
Token 鉴权服务端设置 -token 后,注册和数据连接均需携带相同 Token
消息大小限制单条消息不超过 4 KiB,防止内存耗尽攻击
心跳超时回收45 秒无心跳自动移除映射,防止静默断连占用端口
并发写保护控制连接 Writer 通过互斥锁保护,防止数据损坏
优雅关闭SIGINT/SIGTERM 触发资源清理,不丢连接

这些不是事后补丁,而是写第一版代码时就考虑进去的。安全不是功能,是习惯。


七、造轮子的意义 #

有人可能会问:frp 能用,为什么要自己写?

我的回答是:造轮子的意义不在于轮子本身,而在于你理解了轮子的每一根辐条。

当你自己实现了心跳保活、断线重连、端口映射、并发安全这些机制之后,你对网络编程的理解就不再是"知道有这些概念",而是"知道为什么这样设计、不这样会出什么问题"。

gopm 全部代码不到 1000 行,纯标准库,从零读完一遍也就一个下午。如果它对你有用,直接拿去用;如果你正在学网络编程,它的代码也许比这篇文章更有价值。

GitHub: https://github.com/jacksalad/gopm

License: MIT