177 lines
9.7 KiB
Markdown
177 lines
9.7 KiB
Markdown
# 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 <agentId> -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=<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` 重新拷贝整目录,
|
||
不在副本里改协议逻辑(与仓库"拷贝下沉"惯例一致,取舍已明确)。
|