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秒 …
重连成功后自动重新发送注册请求,恢复映射。对于网络不稳定的环境(比如移动网络、校园网),这个功能几乎是必需的。
四、功能一览 #
服务端参数 #
| |
| 参数 | 说明 |
|---|---|
-port | 控制端口,接收客户端连接 |
-token | 可选鉴权令牌,设置后客户端必须提供相同值 |
-timeout | 自动关闭时长(秒),0 为永久运行 |
-verbose | 输出详细日志 |
客户端参数 #
| |
| 参数 | 说明 |
|---|---|
-server | 服务端控制地址,如 1.2.3.4:9000 |
-local | 本地服务地址,支持 8080 或 127.0.0.1:8080 |
-map | 服务端暴露的映射端口 |
-token | 鉴权令牌,需与服务端一致 |
-name | 客户端标识名称,方便在日志中区分 |
-retry | 启用断线自动重连 |
-timeout | 自动关闭时长(秒) |
-verbose | 详细日志 |
五、使用场景 #
场景一:临时展示本地 Web 服务 #
开发了一个前端项目,想给同事看看效果:
| |
同事访问 http://YOUR_SERVER:8080 即可看到你本地的 localhost:3000。
场景二:带鉴权的正式使用 #
给服务端加个 Token,防止任何人随意注册映射:
| |
Token 不匹配的客户端会收到 unauthorized 错误。
场景三:映射内网数据库 #
需要远程连接内网的 MySQL:
| |
外部通过 YOUR_SERVER:13306 即可连接内网数据库。
场景四:多客户端并行 #
一个服务端可以同时服务多个客户端,每个映射不同端口:
| |
端口冲突会被自动检测并拒绝——同一映射端口只能被一个客户端占用。
场景五:定时自动关闭 #
临时调试,不想忘了关进程?用 -timeout 设个倒计时:
| |
超时后自动触发优雅关闭,打印日志并退出。
六、安全设计 #
| 特性 | 说明 |
|---|---|
| Token 鉴权 | 服务端设置 -token 后,注册和数据连接均需携带相同 Token |
| 消息大小限制 | 单条消息不超过 4 KiB,防止内存耗尽攻击 |
| 心跳超时回收 | 45 秒无心跳自动移除映射,防止静默断连占用端口 |
| 并发写保护 | 控制连接 Writer 通过互斥锁保护,防止数据损坏 |
| 优雅关闭 | SIGINT/SIGTERM 触发资源清理,不丢连接 |
这些不是事后补丁,而是写第一版代码时就考虑进去的。安全不是功能,是习惯。
七、造轮子的意义 #
有人可能会问:frp 能用,为什么要自己写?
我的回答是:造轮子的意义不在于轮子本身,而在于你理解了轮子的每一根辐条。
当你自己实现了心跳保活、断线重连、端口映射、并发安全这些机制之后,你对网络编程的理解就不再是"知道有这些概念",而是"知道为什么这样设计、不这样会出什么问题"。
gopm 全部代码不到 1000 行,纯标准库,从零读完一遍也就一个下午。如果它对你有用,直接拿去用;如果你正在学网络编程,它的代码也许比这篇文章更有价值。
GitHub: https://github.com/jacksalad/gopm
License: MIT