冲突解决 internal/node/node.go: Node 结构体同时保留两侧新增字段 - 远端: HandshakeTimeout (agent 拨号应答 5s 上限, BUG 修复) - 本地: TunnelPortRange (隧道端口范围, 配置文件功能) - cmd/node 配置加载器经 node.New() 构造, HandshakeTimeout 默认值不受影响 - 验证: go build/vet 通过, go test ./... 全绿(e2e 3.97s)
zonat — 内网穿透轻量库
从完整版内网穿透项目(client + server + cloud 三端约 1.4 万行)裁剪出的轻量实现,
只服务远程桌面场景(VNC / RDP / webshell / 文件)。背景与裁剪依据见
capricorn 仓库 docs/remote-desktop-nat-analysis.md 第八节。
在 zomaintain 中的定位:backend/zonat 是 backend/ 下的共享库 module
(module 名 zonat,非业务端)——被控场所侧宿主(网吧服务器 BS,即 backend/server)
内嵌 agent/ 包建立穿透隧道;穿透节点用 cmd/node 独立部署在公网/机房。
业务端之间仍是「零 import」,zonat 是唯一例外:桥接协议必须单一来源,防止两端漂移。
打包约定:
- 单 Go module(node + agent + wire 全在一起,单一来源,方便维护):端到端测试可 进程内直接跑通;后续如需按端拆分再拆。
- 宿主用 import 引用 agent(推荐):
agent/是公开包且自包含(5 个文件,仅依赖标准库 + smux),宿主 go.modrequire zonat+replace zonat => ../zonat即可;构建加-trimpath后,agent 日志路径稳定为zonat@版本/agent/agent.go模块形式(已实测,见下)。 - 整目录拷贝为备用方案:不依赖模块引用的场景(如临时放进某个项目调试)直接拷
agent/目录,日志走宿主项目内路径,同样零修改。 - 日志注入:agent 包不依赖任何具体日志实现,宿主注入 4 方法
Logger接口 (logrus 原生满足,slog 写几行适配器),agent 日志即归入宿主的格式/级别/输出。 唯一例外DefaultLogger是仅标准库的兜底(stderr,Info 级)。
架构
控制面:cloud ──WS──► 场所服务器(内嵌 agent 库)──桥接帧命令注册隧道──► 节点(纯内存,无DB/无管理API)
数据面:浏览器 ──► 节点:隧道端口 ──smux(TCP桥接)──► agent ──LAN TCP──► VNC/RDP/3000
多节点模型:每条 Tunnel 自带 NodeAddr + Token,由独立连接完成
"登录 → smux → 控制流注册自身 → 数据流服务";Agent 只是隧道注册表,不持有网络连接。
因此一个 agent 的多条隧道可以分布在多个不同节点,各隧道的连接/重连/心跳互不影响。
相比完整版裁掉:KCP 桥接、UDP 隧道、http(s) 反向代理模式、协议嗅探与兜底站、 管理 REST、数据库、P2P、限流统计、zonat cloud 整端。 保留:帧协议(wire format 与完整版一致)、smux 多路复用(恒开,不再有开关)、 TCP 桥接、心跳重连、TTL 空闲自动删除(对应完整版 AutoDelete)。
协议
- 帧格式:
ver(1) + cmd(1) + length(2,LE) + sid(4,LE) + data(JSON),与完整版桥接帧一致。 - 命令字与完整版对齐:
CmdLogin=0、CmdPing=3、CmdTarget=4(同 CmdTunnelLogin/Ping/Target), 新增CmdRegisterTunnel=10、CmdUnregisterTunnel=11、CmdTunnelClosed=12(推送)。 - 一条隧道一条桥接连接(TCP + smux):连接建立时先用该隧道的 Token 登录,再经控制流 注册自身。数据流由节点主动 OpenStream,首帧 CmdTarget 告知目标,agent 拨号本地 TCP 目标后回执,之后纯字节管道(无协议嗅探)。
- 隧道生命周期由 agent 注册:
port=0节点随机分配;ttl>0空闲超时由节点清扫并推送 CmdTunnelClosed;永久隧道(ttl=0)断线后各自自动重连重注册。 - 同一 agentId 在一个节点上可有多个并发会话(每隧道一个);同 ID 隧道重复注册按 "隧道 ID + agentId" 粒度顶替(同 agent 更新配置,跨 agent 活隧道拒绝)。
- 鉴权:登录帧带 JWT(HS256 系,节点用
-jwt-secret密钥校验)。token 必须带exp; 若带agentId/subclaim 则必须与登录 AgentId 一致。正式部署由 cloud 签发下发, 每条隧道各自的 token 可不同(含不同有效期/不同节点密钥)。
目录
zonat/
├── agent/ 可拷贝分发:agent.go + tunnel.go + forward.go + frame.go + protocol.go(自包含,仅依赖标准库+smux)
├── cmd/node/ 节点入口(独立部署二进制;含 -print-token 手工签发)
├── cmd/agent/ 被控端独立二进制(测试/独立跑用;slog 适配器示例)
├── cmd/tcp-echo/ 测试目标:TCP 回显(模拟 VNC/RDP/3000)
├── cmd/http-demo/ 测试目标:HTTP 服务(webshell/文件场景)
├── internal/node/ 节点:JWT 登录 / 桥接会话 / 隧道注册表 / 纯 TCP 隧道监听 / TTL 清扫
└── e2e/ 端到端测试(进程内,不依赖外部环境)
node 与 agent 共用同一份 wire 实现:节点侧 import "git.zeroonesoft.cn/golib/zonat/agent" 复用
Frame/Tunnel/命令字(agent 包是协议的唯一来源,节点不另留副本,防止两端漂移)。
宿主接入 agent(如 backend/server)
方式一:import 引用(推荐)
宿主 go.mod(宿主在 backend/<端> 下,与本库平级,用相对路径 replace):
require zonat v0.0.0
replace zonat => ../zonat // 例:backend/server、backend/user
宿主代码:
import "git.zeroonesoft.cn/golib/zonat/agent"
ag := agent.New(agentId) // 节点地址与令牌随各隧道下发
ag.Logger = logrus.StandardLogger() // logrus 四个方法签名天然满足接口;不注入也有兜底
ag.OnTunnelPort = func(id string, port int) { /* 保存/上报分配到的端口 */ }
go ag.Run()
// 每条隧道自带节点与 JWT,可指向不同节点
port, _ := ag.RegisterTunnel(agent.Tunnel{
Id: "vnc-1", NodeAddr: "node1.example.com:5212", Token: jwtFromCloud,
TargetIp: "127.0.0.1", TargetPort: 5900, TTLSec: 300,
})
port2, _ := ag.RegisterTunnel(agent.Tunnel{
Id: "web-1", NodeAddr: "node2.example.com:5212", Token: anotherJwt,
TargetIp: "127.0.0.1", TargetPort: 3000,
})
RegisterTunnel 同步等待首次"连接+登录+注册"完成并返回节点分配的端口;之后由后台
worker 维持:永久隧道(TTLSec=0)断线自动重连重注册,临时隧道(TTLSec>0)一次性。
宿主构建必须加 -trimpath,否则 agent 日志记的是构建机全路径
(D:/golib/zonat/agent/agent.go)。实测对比(模拟宿主 Logger 记录调用点):
不加 -trimpath: WARN D:/golib/zonat/agent/agent.go:119
加 -trimpath: WARN zonat@v0.0.0/agent/agent.go:119 ← 模块名+包相对路径,跨机器稳定
@v0.0.0 来自宿主 require 的版本号(tag 后即 @v1.0.0),Logger 里可顺手替换掉。
注意:加了 -trimpath 后宿主自身日志路径也会变成 server/internal/... 模块形式,
且这两类路径都不含宿主 rootDir——宿主 Logger 若做过"削 rootDir、兜底取文件名"式处理,
需同步升级为保留模块形式路径(else 分支一行改动),否则退化成 agent.go:119。
方式二:整目录拷贝(备用)
cp -r /d/zomaintain/backend/zonat/agent /d/zomaintain/backend/server/pkg/agent
cd /d/zomaintain/backend/server && go mod tidy # 只为拉一次 xtaci/smux
import 改为 server/pkg/agent。源码在宿主项目内,runtime.Caller 记录宿主内路径,
宿主现有 rootDir 削前缀原样生效(得到 pkg/agent/agent.go:88),无需 -trimpath。
已用临时 server 模块验证零修改编译。升级纪律:从 backend/zonat 重新拷贝整目录,
不在副本里改协议。
节点部署(多台)
# Windows 交叉编译 Linux 节点
GOOS=linux GOARCH=amd64 go build -o bin/node-linux-amd64 ./cmd/node
# 本机
go build -o bin/node.exe ./cmd/node
# 各节点:./node-linux-amd64 -addr :5212 -jwt-secret <各节点不同密钥> -tunnel-bind 0.0.0.0
# 手工签发一张登录 token(正式部署由 cloud 签发):
./node-linux-amd64 -jwt-secret <密钥> -print-token -agent <agentId> -ttl 24h
验证
cd /d/zomaintain/backend/zonat
go test ./e2e/ -v # 13 个用例:TCP 回显(并发/大包/二进制安全)、HTTP GET/POST/keep-alive、
# 单agent双隧道连两节点、注销、TTL 过期、ID 冲突(同agent顶替/跨agent拒绝)、
# JWT 拒绝(错误签名/过期/agentId不一致)、踢会话重连重注册、日志注入
go build ./...
手动验证:
go build -o bin/ ./cmd/...
./bin/tcp-echo -addr 127.0.0.1:9000 &
./bin/node -addr :5212 -jwt-secret s3cret -tunnel-bind 127.0.0.1 &
TOK=$(./bin/node -jwt-secret s3cret -print-token -agent a1 -ttl 1h)
./bin/agent -node 127.0.0.1:5212 -token "$TOK" -id a1 -tunnel "id=demo,target=127.0.0.1:9000"
# agent 日志输出 "隧道端口 id=demo listen=<port>" 后:
nc 127.0.0.1 <port> # 输入任意内容,回显即穿透成功
已知简化(P0 范围)
- 无 TLS:节点 ↔ agent 明文(内网/机房部署假设);JWT 解决的是"静态共享秘密 + 无过期", 不解决信道窃听——明文信道上 token 在 exp 前仍可被截获重放。如需加密后续加 smux 前置 TLS。
- 每隧道一条连接:同一节点上的多条隧道不共享桥接连接(简单优先;如需收敛连接数, 后续可在 agent 内按 NodeAddr 池化)。
- 无限流/流量统计;连接数仅内存计数。
- 临时隧道 TTL 过期由节点清扫推送,agent 不重试(与完整版临时通道语义一致:由上层重新申请)。
- HTTP 隧道场景(webshell)验证走纯 tcp 模式;完整版的 http 反代模式不再实现。
- 拷贝式分发下各宿主副本可能随时间漂移:纪律是升级一律从
backend/zonat重新拷贝整目录, 不在副本里改协议逻辑(与仓库"拷贝下沉"惯例一致,取舍已明确)。