# 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.mod `require 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`/`sub` claim 则必须与登录 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 ``` 宿主代码: ```go 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`。 ### 方式二:整目录拷贝(备用) ```bash 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` 重新拷贝整目录, 不在副本里改协议。 ## 节点部署(多台) ```bash # 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 -ttl 24h ``` ## 验证 ```bash 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 ./... ``` 手动验证: ```bash 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=" 后: nc 127.0.0.1 # 输入任意内容,回显即穿透成功 ``` ## 已知简化(P0 范围) - 无 TLS:节点 ↔ agent 明文(内网/机房部署假设);JWT 解决的是"静态共享秘密 + 无过期", 不解决信道窃听——明文信道上 token 在 exp 前仍可被截获重放。如需加密后续加 smux 前置 TLS。 - 每隧道一条连接:同一节点上的多条隧道不共享桥接连接(简单优先;如需收敛连接数, 后续可在 agent 内按 NodeAddr 池化)。 - 无限流/流量统计;连接数仅内存计数。 - 临时隧道 TTL 过期由节点清扫推送,agent 不重试(与完整版临时通道语义一致:由上层重新申请)。 - HTTP 隧道场景(webshell)验证走纯 tcp 模式;完整版的 http 反代模式不再实现。 - 拷贝式分发下各宿主副本可能随时间漂移:纪律是升级一律从 `backend/zonat` 重新拷贝整目录, 不在副本里改协议逻辑(与仓库"拷贝下沉"惯例一致,取舍已明确)。