init: 自 zomaintain/backend/zonat 平移独立成库; module path 改为 git.zeroonesoft.cn/golib/zonat; 内部 import 同步重写

This commit is contained in:
w11
2026-09-19 18:55:43 +08:00
commit 984041f1a0
20 changed files with 2427 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
bin/
+176
View File
@@ -0,0 +1,176 @@
# 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` 重新拷贝整目录,
不在副本里改协议逻辑(与仓库"拷贝下沉"惯例一致,取舍已明确)。
+222
View File
@@ -0,0 +1,222 @@
// Package agent 轻量穿透被控端(zonat client 的裁剪版),设计为**可嵌入公开包**:
// 每条隧道自带节点地址与登录令牌,由独立连接完成
// 登录节点 → smux 会话 → 控制流注册自身 → 数据流拨号本地 TCP 目标,
// 因此一个 agent 的多条隧道可以分布在**多个不同节点**。
// Agent 本身只是隧道的注册表与生命周期管理器,不持有网络连接、不做登录。
// 相比 zonat client 裁掉:KCP 拨号、UDP 代理、普通(非复用)连接模式、
// 用户级通道分发、服务安装等本机管理逻辑。
// 永久隧道(TTLSec=0)断线各自自动重连重注册;临时隧道(TTLSec>0)
// 一次性,连接断开或节点 TTL 过期推送后即消失,与 zonat 临时通道语义一致。
//
// 日志:包内不依赖任何具体日志实现,宿主项目通过注入 Logger 接口
// 把 agent 日志纳入自己的日志体系(格式/级别/输出/文件路径归一);
// 不注入时使用仅标准库的兜底实现(stderr,Info 级)。
// 注意:日志中的 file:line 归属调用点。宿主以 import 方式引用本包并加
// -trimpath 构建时,路径为模块形式 zonat@版本/agent/agent.go(跨机器稳定);
// 以整目录拷贝方式放进宿主项目时,路径在宿主项目内,宿主现有 rootDir
// 削前缀规则原样生效。两种方式都不需要在包内做任何路径处理。
package agent
import (
"errors"
"fmt"
"log"
"sync"
"time"
)
const (
defaultBackoff = 5 * time.Second
defaultPing = 30 * time.Second
)
// Logger agent 日志接口。logrus.Logger 的 Debugf/Infof/Warnf/Errorf 签名
// 与此完全一致,可直接注入;slog 等其它实现写一个几行的适配器即可。
type Logger interface {
Debugf(format string, args ...any)
Infof(format string, args ...any)
Warnf(format string, args ...any)
Errorf(format string, args ...any)
}
// DefaultLogger 未注入时的兜底日志:标准错误,Info 级,仅标准库实现。
type DefaultLogger struct{}
func (DefaultLogger) Debugf(string, ...any) {}
func (DefaultLogger) Infof(format string, args ...any) {
log.Printf("INFO %s", fmt.Sprintf(format, args...))
}
func (DefaultLogger) Warnf(format string, args ...any) {
log.Printf("WARN %s", fmt.Sprintf(format, args...))
}
func (DefaultLogger) Errorf(format string, args ...any) {
log.Printf("ERROR %s", fmt.Sprintf(format, args...))
}
// Agent 被控端:隧道注册表。每条 Tunnel 携带自己的 NodeAddr+Token,
// 由独立 worker(tunnelConn)连接登录各自节点;Agent 不再有集中连接与登录。
type Agent struct {
AgentId string
Backoff time.Duration // 永久隧道重连退避(默认 5s)
PingEvery time.Duration // 各隧道控制流心跳间隔(默认 30s)
// Logger 日志注入点。需要纳入宿主统一日志体系时设置
// (logrus 直接注入 logrus.StandardLogger() 即可);未设置用 DefaultLogger。
Logger Logger
// OnTunnelPort 隧道端口确定/变更回调:port>0 已注册,port<0 已关闭。
// 节点随机分配端口时,上层靠它拿到实际端口。多隧道场景下可能从
// 不同 worker 并发回调,宿主需自行保证线程安全。
OnTunnelPort func(id string, port int)
mu sync.Mutex
closed bool
tunnels map[string]*tunnelConn
done chan struct{} // Run 的阻塞点,Close 时关闭
}
// New 创建被控端。节点地址与令牌随各隧道下发(Tunnel.NodeAddr/Token)。
func New(agentId string) *Agent {
return &Agent{
AgentId: agentId,
Backoff: defaultBackoff,
PingEvery: defaultPing,
Logger: DefaultLogger{},
tunnels: make(map[string]*tunnelConn),
done: make(chan struct{}),
}
}
// logger 取当前日志实现(零值 Agent 未注入时兜底)
func (a *Agent) logger() Logger {
a.mu.Lock()
l := a.Logger
a.mu.Unlock()
if l == nil {
return DefaultLogger{}
}
return l
}
// Run 阻塞直到 Close。隧道的连接在 RegisterTunnel 时即自行驱动,
// 这里仅保留"go a.Run()"式宿主用法的阻塞点。
func (a *Agent) Run() error {
<-a.done
return nil
}
// Close 停止 agent:断开并停止全部隧道 worker
func (a *Agent) Close() {
a.mu.Lock()
if a.closed {
a.mu.Unlock()
return
}
a.closed = true
workers := make([]*tunnelConn, 0, len(a.tunnels))
for _, w := range a.tunnels {
workers = append(workers, w)
}
close(a.done)
a.mu.Unlock()
for _, w := range workers {
w.stop()
}
}
func (a *Agent) isClosed() bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.closed
}
// Connected 指定隧道是否已连接其节点
func (a *Agent) Connected(id string) bool {
a.mu.Lock()
w := a.tunnels[id]
a.mu.Unlock()
if w == nil {
return false
}
w.mu.Lock()
s := w.session
w.mu.Unlock()
return s != nil && !s.IsClosed()
}
// RegisterTunnel 注册隧道:同步等待首次"连接+登录+注册"完成并返回节点分配端口,
// 之后由后台 worker 维持——永久隧道(TTLSec=0)断线自动重连重注册,
// 临时隧道(TTLSec>0)一次性。同 ID 重复注册视为更新配置(停旧 worker 起新)。
func (a *Agent) RegisterTunnel(t Tunnel) (int, error) {
if t.Id == "" {
return 0, errors.New("隧道ID不能为空")
}
if t.NodeAddr == "" || t.Token == "" {
return 0, fmt.Errorf("隧道缺少节点地址或令牌 id=%s", t.Id)
}
a.mu.Lock()
if a.closed {
a.mu.Unlock()
return 0, errors.New("agent 已关闭")
}
old := a.tunnels[t.Id]
w := newTunnelConn(a, t)
a.tunnels[t.Id] = w
a.mu.Unlock()
if old != nil {
old.stop()
}
go w.run()
select {
case <-w.first:
return w.firstPort, w.firstErr
case <-time.After(registerWait):
return 0, fmt.Errorf("注册超时(节点 %s),永久隧道将在后台继续重试", t.NodeAddr)
}
}
// UnregisterTunnel 注销隧道:尽力经该隧道的控制流通知节点删除,随后停止本地 worker
func (a *Agent) UnregisterTunnel(id string) error {
a.mu.Lock()
w := a.tunnels[id]
a.mu.Unlock()
if w == nil {
return errors.New("隧道不存在:" + id)
}
err := w.unregister()
w.stop()
a.removeTunnelState(id)
return err
}
// notifyPort 隧道注册成功(含重连后重注册)时回调
func (a *Agent) notifyPort(id string, port int) {
a.mu.Lock()
cb := a.OnTunnelPort
a.mu.Unlock()
if cb != nil {
cb(id, port)
}
}
func (a *Agent) removeTunnelState(id string) {
a.mu.Lock()
delete(a.tunnels, id)
cb := a.OnTunnelPort
a.mu.Unlock()
if cb != nil {
cb(id, -1)
}
}
func errText(err error) string {
if err == nil {
return ""
}
return err.Error()
}
+52
View File
@@ -0,0 +1,52 @@
package agent
import (
"io"
"net"
)
// handleData 处理节点主动打开的数据流:
// 首帧 CmdTarget 携带目标信息 → 拨号本地 TCP 目标 → 回执 → 双向管道。
func (a *Agent) handleData(stream net.Conn) {
defer stream.Close()
f, err := ReadFrame(stream)
if err != nil {
return
}
if f.Cmd() != CmdTarget {
a.logger().Warnf("agent 数据流首帧命令错误 cmd=%d", f.Cmd())
return
}
t := Tunnel{}
if err := f.Unmarshal(&t); err != nil {
return
}
conn, err := net.Dial("tcp", t.TargetAddr())
if err != nil {
a.logger().Warnf("agent 拨号本地目标失败 target=%s err=%s", t.TargetAddr(), err.Error())
rf := NewFrame(FrameVersion, CmdTarget, f.StreamID())
_ = rf.Marshal(Ret{Code: 1, Msg: err.Error()})
_ = WriteFrame(stream, rf)
return
}
defer conn.Close()
rf := NewFrame(FrameVersion, CmdTarget, f.StreamID())
_ = rf.Marshal(Ret{Code: 0, Msg: "ok"})
if err := WriteFrame(stream, rf); err != nil {
return
}
pipe(conn, stream)
}
// pipe 双向拷贝,任一方向结束即关闭两端
func pipe(a, b net.Conn) {
go func() {
io.Copy(b, a)
b.Close()
}()
io.Copy(a, b)
a.Close()
}
+149
View File
@@ -0,0 +1,149 @@
// frame.go 帧协议,wire format 与 zonat 桥接帧完全一致
// (ver(1) + cmd(1) + length(2, LE) + sid(4, LE) + data)。
// 本文件取自 zonat client/internal/frame 的本地修复副本,仅裁掉未用方法。
package agent
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net"
)
const FrameVersion byte = 1
// 帧头部
const (
sizeOfVer = 1
sizeOfCmd = 1
sizeOfLength = 2
sizeOfSid = 4
headerSize = sizeOfVer + sizeOfCmd + sizeOfSid + sizeOfLength
)
const (
frameBufSize = 0x10000
)
type Frame struct {
ver byte
cmd byte
sid uint32
data []byte
}
func NewFrame(version byte, cmd byte, sid uint32) Frame {
return Frame{ver: version, cmd: cmd, sid: sid}
}
// ReadFrame 读取一帧。头部与数据均用 ReadFull,防止单次 Read 短读导致解析错乱,
// 也不会把后续帧的数据读进缓冲丢弃。
func ReadFrame(conn net.Conn) (Frame, error) {
f := NewFrame(0, 0, 0)
hdr := RawHeader{}
if _, err := io.ReadFull(conn, hdr[:]); err != nil {
return f, err
}
f.ver = hdr.Version()
f.cmd = hdr.Cmd()
f.sid = hdr.StreamID()
length := int(hdr.Length())
if length == 0 {
return f, nil
}
data := make([]byte, length)
if _, err := io.ReadFull(conn, data); err != nil {
return f, err
}
f.data = data
return f, nil
}
func WriteFrame(conn net.Conn, frame Frame) error {
hdr := RawHeader{}
hdr[0] = frame.ver
hdr[1] = frame.cmd
binary.LittleEndian.PutUint16(hdr[2:], uint16(len(frame.data)))
binary.LittleEndian.PutUint32(hdr[4:], frame.sid)
buf := hdr.Bytes()
buf = append(buf, frame.data...)
_, err := conn.Write(buf)
return err
}
func (f *Frame) Version() byte {
return f.ver
}
func (f *Frame) Cmd() byte {
return f.cmd
}
func (f *Frame) Length() uint16 {
return uint16(len(f.data))
}
func (f *Frame) Data() []byte {
return f.data
}
func (f *Frame) StreamID() uint32 {
return f.sid
}
func (f *Frame) SetData(data []byte) error {
if len(data) > frameBufSize-1 {
return fmt.Errorf("超出最大长度:%d", frameBufSize-1)
}
f.data = data
return nil
}
func (f *Frame) Marshal(v any) error {
buf, err := json.Marshal(v)
if err != nil {
return err
}
return f.SetData(buf)
}
func (f *Frame) Unmarshal(v any) error {
if len(f.data) == 0 {
return errors.New("没有数据")
}
return json.Unmarshal(f.data, v)
}
func (f *Frame) String() string {
return fmt.Sprintf("Version:%d Cmd:%d StreamID:%d Length:%d",
f.Version(), f.Cmd(), f.StreamID(), f.Length())
}
type RawHeader [headerSize]byte
func (h RawHeader) Version() byte {
return h[0]
}
func (h RawHeader) Cmd() byte {
return h[1]
}
func (h RawHeader) Length() uint16 {
return binary.LittleEndian.Uint16(h[2:])
}
func (h RawHeader) StreamID() uint32 {
return binary.LittleEndian.Uint32(h[4:])
}
func (h RawHeader) Bytes() []byte {
buf := make([]byte, headerSize)
copy(buf, h[:])
return buf
}
+75
View File
@@ -0,0 +1,75 @@
// protocol.go 定义 zonat 桥接协议的命令字与消息结构。
// 命令字取值与 zonat 桥接命令字对齐(CmdLogin 同 CmdTunnelLogin、CmdTarget 同 CmdTunnelTarget),
// 帧格式不变,便于将来与 zonat 互通或平滑迁移。
package agent
import (
"fmt"
"time"
"github.com/xtaci/smux"
)
// 帧命令字
const (
// CmdLogin 登录(请求与应答共用同一命令字,同 zonat)
CmdLogin byte = 0
// CmdPing 心跳(请求与应答共用同一命令字,同 zonat CmdTunnelPing)
CmdPing byte = 3
// CmdTarget 数据流首帧:节点告知 agent 本条公网连接的目标(同 zonat CmdTunnelTarget)
CmdTarget byte = 4
// CmdRegisterTunnel agent→节点 注册隧道(应答共用同一命令字,Ret.Port 为分配端口)
CmdRegisterTunnel byte = 10
// CmdUnregisterTunnel agent→节点 注销隧道(应答共用同一命令字)
CmdUnregisterTunnel byte = 11
// CmdTunnelClosed 节点→agent 推送:隧道已被节点删除(TTL 过期、会话顶替等)
CmdTunnelClosed byte = 12
)
// Version 协议/程序版本
const Version = "0.1.0"
// Login 登录请求
type Login struct {
AgentId string `json:"agentId"`
Token string `json:"token"` // JWT,由签发方(如 cloud)生成,节点用密钥校验
Version string `json:"version"`
}
// Ret 通用应答(zonat JsonRet 的裁剪版)
type Ret struct {
Code int `json:"code"`
Msg string `json:"msg"`
Port int `json:"port,omitempty"` // 注册隧道应答:节点分配的监听端口
}
// Tunnel 一条隧道的定义(zonat models.Tunnel 的裁剪版)。
// NodeAddr/Token 是 zonat 的多节点扩展:每条隧道自带目标节点与登录令牌,
// 由独立连接登录注册,因此一个 agent 的多条隧道可分布在多个不同节点。
// 这两个字段只用于 agent→节点的登录,节点侧不使用也不回传(CmdTarget 不携带)。
type Tunnel struct {
Id string `json:"id"`
NodeAddr string `json:"nodeAddr,omitempty"` // 该隧道登录的节点桥接地址
Token string `json:"token,omitempty"` // 登录令牌(JWT),仅登录帧使用
ListenPort int `json:"listenPort"` // 0=节点随机分配
TargetIp string `json:"targetIp"`
TargetPort int `json:"targetPort"`
TTLSec int `json:"ttlSec"` // 空闲自动删除秒数,0=永久
}
// TargetAddr 隧道在内网的目标地址
func (t Tunnel) TargetAddr() string {
return fmt.Sprintf("%s:%d", t.TargetIp, t.TargetPort)
}
// SmuxConfig smux 会话参数,与 zonat 保持一致
func SmuxConfig() *smux.Config {
return &smux.Config{
Version: 2,
KeepAliveInterval: 10 * time.Second,
KeepAliveTimeout: 30 * time.Second,
MaxFrameSize: 32768,
MaxReceiveBuffer: 4194304,
MaxStreamBuffer: 65536,
}
}
+304
View File
@@ -0,0 +1,304 @@
// tunnel.go 一条隧道的节点连接 worker:每条隧道独立完成
// 连接→登录(携带自身 Token)→smux→控制流注册自身→数据流服务,
// 因此天然支持不同隧道连接不同节点。
// 永久隧道断线按 Backoff 重连重注册;临时隧道一次性退出。
package agent
import (
"errors"
"fmt"
"net"
"sync"
"time"
"github.com/xtaci/smux"
)
const (
loginTimeout = 10 * time.Second // 登录握手超时(含拨号)
registerWait = 30 * time.Second // RegisterTunnel 等待首次注册完成的上限
)
// errTunnelClosed 节点推送 CmdTunnelClosed:隧道已被节点删除(TTL 过期等)
var errTunnelClosed = errors.New("隧道已被节点关闭")
// tunnelConn 一条隧道的全部运行态:期望配置 + 到该隧道节点的桥接连接
type tunnelConn struct {
ag *Agent
t Tunnel
stopCh chan struct{}
stopOnce sync.Once
firstOnce sync.Once
first chan struct{} // 首次"连接+登录+注册"尝试结束后关闭
firstPort int
firstErr error
everRegistered bool // 本次注册成功过至少一次(run 协程独占读写)
mu sync.Mutex
conn net.Conn
session *smux.Session
control net.Conn
ctrlMu sync.Mutex // 控制流"写请求+读应答"串行化
}
func newTunnelConn(ag *Agent, t Tunnel) *tunnelConn {
return &tunnelConn{
ag: ag,
t: t,
stopCh: make(chan struct{}),
first: make(chan struct{}),
}
}
// markFirst 记录首次注册尝试的结果;channel close 保证读取方看到字段写入
func (w *tunnelConn) markFirst(port int, err error) {
w.firstOnce.Do(func() {
w.firstPort, w.firstErr = port, err
close(w.first)
})
}
// run 重连主循环,阻塞直到停止/一次性结束
func (w *tunnelConn) run() {
for {
port, err := w.connect()
w.markFirst(port, err)
if err == nil {
err = w.serve()
}
w.teardown()
if w.isStopped() || w.ag.isClosed() {
return
}
if errors.Is(err, errTunnelClosed) {
w.ag.removeTunnelState(w.t.Id)
return
}
if w.t.TTLSec > 0 {
// 临时隧道一次性:连接断开即结束(节点侧随会话移除)
if w.everRegistered {
w.ag.removeTunnelState(w.t.Id)
}
return
}
w.ag.logger().Warnf("agent 隧道连接断开 id=%s node=%s err=%s backoff=%s",
w.t.Id, w.t.NodeAddr, errText(err), w.ag.Backoff)
select {
case <-time.After(w.ag.Backoff):
case <-w.stopCh:
return
}
}
}
// connect 连接并登录该隧道的节点,经控制流注册自身,返回节点分配端口
func (w *tunnelConn) connect() (int, error) {
conn, err := net.DialTimeout("tcp", w.t.NodeAddr, loginTimeout)
if err != nil {
return 0, err
}
// 登录(同 zonat:CmdLogin 帧 + Ret 应答)
lf := NewFrame(FrameVersion, CmdLogin, 0)
if err := lf.Marshal(Login{AgentId: w.ag.AgentId, Token: w.t.Token, Version: Version}); err != nil {
conn.Close()
return 0, err
}
if err := WriteFrame(conn, lf); err != nil {
conn.Close()
return 0, err
}
conn.SetReadDeadline(time.Now().Add(loginTimeout))
rf, err := ReadFrame(conn)
if err != nil {
conn.Close()
return 0, err
}
ret := Ret{}
if err := rf.Unmarshal(&ret); err != nil {
conn.Close()
return 0, err
}
if ret.Code != 0 {
conn.Close()
return 0, fmt.Errorf("登录失败:%s", ret.Msg)
}
conn.SetReadDeadline(time.Time{})
sess, err := smux.Client(conn, SmuxConfig())
if err != nil {
conn.Close()
return 0, err
}
control, err := sess.OpenStream()
if err != nil {
sess.Close()
return 0, err
}
w.mu.Lock()
w.conn, w.session, w.control = conn, sess, control
w.mu.Unlock()
// 注册自身;凭据只用于登录,不随注册/数据帧回发
wire := w.t
wire.NodeAddr, wire.Token = "", ""
f, err := w.callControl(func(control net.Conn) error {
rf := NewFrame(FrameVersion, CmdRegisterTunnel, 0)
if err := rf.Marshal(wire); err != nil {
return err
}
return WriteFrame(control, rf)
}, CmdRegisterTunnel)
if err != nil {
return 0, err
}
rret := Ret{}
if err := f.Unmarshal(&rret); err != nil {
return 0, err
}
if rret.Code != 0 {
return 0, errors.New(rret.Msg)
}
w.everRegistered = true
w.ag.notifyPort(w.t.Id, rret.Port)
w.ag.logger().Infof("agent 隧道已连接节点 id=%s node=%s listen=%d",
w.t.Id, w.t.NodeAddr, rret.Port)
if w.ag.PingEvery > 0 {
go w.pingLoop()
}
return rret.Port, nil
}
// serve 数据流循环(阻塞;出错即断线,由 run 重连)
func (w *tunnelConn) serve() error {
w.mu.Lock()
sess := w.session
w.mu.Unlock()
if sess == nil {
return errors.New("未连接节点")
}
for {
stream, err := sess.AcceptStream()
if err != nil {
return err
}
go w.ag.handleData(stream)
}
}
func (w *tunnelConn) pingLoop() {
ticker := time.NewTicker(w.ag.PingEvery)
defer ticker.Stop()
for {
select {
case <-w.stopCh:
return
case <-ticker.C:
}
_, err := w.callControl(func(control net.Conn) error {
return WriteFrame(control, NewFrame(FrameVersion, CmdPing, 0))
}, CmdPing)
if err != nil {
if errors.Is(err, errTunnelClosed) {
// 节点已删除本隧道:杀掉会话让 serve 退出,run 收尾
w.teardown()
}
return
}
}
}
// callControl 串行化控制流交互。等待应答期间收到本隧道的关闭推送即返回 errTunnelClosed。
func (w *tunnelConn) callControl(write func(control net.Conn) error, wantCmd byte) (Frame, error) {
w.ctrlMu.Lock()
defer w.ctrlMu.Unlock()
w.mu.Lock()
control := w.control
w.mu.Unlock()
if control == nil {
return Frame{}, errors.New("未连接节点")
}
if err := write(control); err != nil {
return Frame{}, err
}
for {
f, err := ReadFrame(control)
if err != nil {
return Frame{}, err
}
if f.Cmd() == CmdTunnelClosed {
t := Tunnel{}
_ = f.Unmarshal(&t)
if t.Id == w.t.Id {
return Frame{}, errTunnelClosed
}
continue
}
if f.Cmd() == wantCmd {
return f, nil
}
// 其它命令忽略
}
}
// unregister 经控制流注销本隧道。传输失败视为成功:
// 连接已断时节点会随会话移除隧道,无需再通知。
func (w *tunnelConn) unregister() error {
w.mu.Lock()
connected := w.control != nil
w.mu.Unlock()
if !connected {
return nil
}
f, err := w.callControl(func(control net.Conn) error {
rf := NewFrame(FrameVersion, CmdUnregisterTunnel, 0)
if err := rf.Marshal(Tunnel{Id: w.t.Id}); err != nil {
return err
}
return WriteFrame(control, rf)
}, CmdUnregisterTunnel)
if err != nil {
return nil
}
ret := Ret{}
if err := f.Unmarshal(&ret); err != nil {
return nil
}
if ret.Code != 0 {
return errors.New(ret.Msg)
}
return nil
}
func (w *tunnelConn) stop() {
w.stopOnce.Do(func() { close(w.stopCh) })
w.teardown()
}
func (w *tunnelConn) isStopped() bool {
select {
case <-w.stopCh:
return true
default:
return false
}
}
func (w *tunnelConn) teardown() {
w.mu.Lock()
sess, conn := w.session, w.conn
w.session, w.conn, w.control = nil, nil, nil
w.mu.Unlock()
if sess != nil {
sess.Close()
}
if conn != nil {
conn.Close()
}
}
+139
View File
@@ -0,0 +1,139 @@
// zonat 被控端入口:跑在内网机器上,按隧道定义登录各自节点并注册。
// 每条隧道自带节点地址与登录令牌(JWT),可分布在多个不同节点。
// 隧道通过 -tunnel 参数下发(后续由 capricorn cloud 经 WS 下发,见分析文档 P1):
//
// -tunnel id=web1,target=127.0.0.1:3000,node=10.0.0.2:5212,token=<jwt>,port=0,ttl=0
// -tunnel id=vnc1,target=127.0.0.1:5900,ttl=300 // node/token 取 -node/-token 缺省值
package main
import (
"flag"
"fmt"
"log/slog"
"net"
"os"
"strconv"
"strings"
"time"
"git.zeroonesoft.cn/golib/zonat/agent"
)
// slogLogger 把 slog 适配为 agent.Logger 接口(独立二进制用;
// 宿主项目若是 logrus 可直接注入 logrus.StandardLogger())
type slogLogger struct{ l *slog.Logger }
func (s slogLogger) Debugf(format string, args ...any) { s.l.Debug(fmt.Sprintf(format, args...)) }
func (s slogLogger) Infof(format string, args ...any) { s.l.Info(fmt.Sprintf(format, args...)) }
func (s slogLogger) Warnf(format string, args ...any) { s.l.Warn(fmt.Sprintf(format, args...)) }
func (s slogLogger) Errorf(format string, args ...any) { s.l.Error(fmt.Sprintf(format, args...)) }
func main() {
nodeAddr := flag.String("node", "127.0.0.1:5212", "缺省节点桥接地址(隧道未带 node= 时使用)")
token := flag.String("token", "", "缺省登录令牌/JWT(隧道未带 token= 时使用)")
id := flag.String("id", "", "agent 唯一标识(默认取主机名)")
backoff := flag.Duration("backoff", 5*time.Second, "永久隧道重连退避")
ping := flag.Duration("ping", 30*time.Second, "控制流心跳间隔")
var tunnels sliceFlag
flag.Var(&tunnels, "tunnel", "隧道定义 id=..,target=ip:port[,node=addr][,token=jwt][,port=0][,ttl=0],可重复")
flag.Parse()
if *id == "" {
host, err := os.Hostname()
if err != nil {
slog.Error("获取主机名失败", "err", err.Error())
os.Exit(1)
}
*id = host
}
a := agent.New(*id)
a.Logger = slogLogger{slog.Default()}
a.Backoff = *backoff
a.PingEvery = *ping
a.OnTunnelPort = func(tunnelId string, port int) {
if port > 0 {
slog.Info("隧道端口", "id", tunnelId, "listen", port)
}
}
for _, spec := range tunnels {
t, err := parseTunnel(spec, *nodeAddr, *token)
if err != nil {
slog.Error("隧道参数错误", "spec", spec, "err", err.Error())
os.Exit(1)
}
if _, err := a.RegisterTunnel(t); err != nil {
slog.Warn("注册隧道失败(永久隧道会在后台持续重试)", "id", t.Id, "err", err.Error())
}
}
if err := a.Run(); err != nil {
slog.Error("agent 退出", "err", err.Error())
os.Exit(1)
}
}
// parseTunnel 解析一条隧道定义,node/token 未带时回落到全局缺省值
func parseTunnel(spec, defNode, defToken string) (agent.Tunnel, error) {
t := agent.Tunnel{NodeAddr: defNode, Token: defToken}
for _, kv := range strings.Split(spec, ",") {
parts := strings.SplitN(kv, "=", 2)
if len(parts) != 2 {
return t, errBad("键值对缺少=:" + kv)
}
switch parts[0] {
case "id":
t.Id = parts[1]
case "node":
t.NodeAddr = parts[1]
case "token":
t.Token = parts[1]
case "target":
host, portStr, err := net.SplitHostPort(parts[1])
if err != nil {
return t, err
}
port, err := strconv.Atoi(portStr)
if err != nil {
return t, err
}
t.TargetIp, t.TargetPort = host, port
case "port":
v, err := strconv.Atoi(parts[1])
if err != nil {
return t, err
}
t.ListenPort = v
case "ttl":
v, err := strconv.Atoi(parts[1])
if err != nil {
return t, err
}
t.TTLSec = v
default:
return t, errBad("未知键:" + parts[0])
}
}
if t.Id == "" || t.TargetIp == "" || t.TargetPort == 0 {
return t, errBad("缺少 id 或 target")
}
if t.NodeAddr == "" || t.Token == "" {
return t, errBad("缺少 node 或 token(登录令牌/JWT)")
}
return t, nil
}
func errBad(msg string) error { return &badSpec{msg} }
type badSpec struct{ msg string }
func (e *badSpec) Error() string { return e.msg }
type sliceFlag []string
func (f *sliceFlag) String() string { return strings.Join(*f, " ") }
func (f *sliceFlag) Set(v string) error {
*f = append(*f, v)
return nil
}
+25
View File
@@ -0,0 +1,25 @@
// 测试目标:HTTP 服务,验证纯 TCP 隧道承载 HTTP(webshell/文件服务场景)。
package main
import (
"flag"
"io"
"log"
"net/http"
)
func main() {
addr := flag.String("addr", "127.0.0.1:9001", "监听地址")
flag.Parse()
mux := http.NewServeMux()
mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"hello":"git.zeroonesoft.cn/golib/zonat","via":"tcp-tunnel"}`))
})
mux.HandleFunc("/echo", func(w http.ResponseWriter, r *http.Request) {
io.Copy(w, r.Body)
})
log.Println("http-demo 监听", *addr)
log.Fatal(http.ListenAndServe(*addr, mux))
}
+62
View File
@@ -0,0 +1,62 @@
// zonat 节点入口:TCP 桥接监听 + 隧道注册表 + 纯 TCP 隧道监听。
// 无管理 API、无数据库;隧道由 agent 经桥接控制流注册。
// agent 登录用 JWT 校验(HS256);token 正式部署由 cloud 签发,
// 手工/测试场景可用 -print-token 现场签发。
package main
import (
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"git.zeroonesoft.cn/golib/zonat/internal/node"
)
func main() {
addr := flag.String("addr", ":5212", "桥接监听地址")
tunnelBind := flag.String("tunnel-bind", "0.0.0.0", "隧道监听绑定地址")
jwtSecret := flag.String("jwt-secret", "", "agent 登录 JWT 校验密钥(HS256)")
sweep := flag.Duration("sweep", 30*time.Second, "TTL 清扫间隔")
// 便捷签发:node -jwt-secret s3cret -print-token -agent a1 -ttl 24h
printToken := flag.Bool("print-token", false, "用 -jwt-secret 签发一张 agent 登录 token 后退出")
tokenAgent := flag.String("agent", "", "print-token 时写入 agentId claim")
tokenTTL := flag.Duration("ttl", 24*time.Hour, "print-token 签发的有效期")
flag.Parse()
if *printToken {
if *jwtSecret == "" || *tokenTTL <= 0 {
slog.Error("print-token 需要 -jwt-secret 与正的 -ttl")
os.Exit(1)
}
tok, err := node.SignToken(*jwtSecret, *tokenAgent, *tokenTTL)
if err != nil {
slog.Error("签发失败", "err", err.Error())
os.Exit(1)
}
fmt.Println(tok)
return
}
if *jwtSecret == "" {
slog.Error("缺少 -jwt-secret(agent 登录 JWT 校验密钥)")
os.Exit(1)
}
n := node.New()
n.JwtSecret = *jwtSecret
n.BindTunnel = *tunnelBind
n.SweepInterval = *sweep
if err := n.Start(*addr); err != nil {
slog.Error("节点启动失败", "err", err.Error())
os.Exit(1)
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
n.Stop()
}
+30
View File
@@ -0,0 +1,30 @@
// 测试目标:TCP 回显服务,模拟 VNC/RDP/3000 等内网 TCP 目标。
package main
import (
"flag"
"io"
"log"
"net"
)
func main() {
addr := flag.String("addr", "127.0.0.1:9000", "监听地址")
flag.Parse()
l, err := net.Listen("tcp", *addr)
if err != nil {
log.Fatal(err)
}
log.Println("tcp-echo 监听", l.Addr().String())
for {
conn, err := l.Accept()
if err != nil {
log.Fatal(err)
}
go func(c net.Conn) {
defer c.Close()
io.Copy(c, c)
}(conn)
}
}
+110
View File
@@ -0,0 +1,110 @@
// HTTP 穿透测试:验证"隧道模式全用 tcp"成立——真实 HTTP 服务(含 keep-alive、
// 大 body)经纯 TCP 隧道完整可用,无需 zonat 的 http 反向代理模式。
package e2e
import (
"bytes"
"crypto/rand"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"git.zeroonesoft.cn/golib/zonat/agent"
"git.zeroonesoft.cn/golib/zonat/internal/node"
)
func startHTTPServer(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"hello": "zonat", "via": "tcp-tunnel"})
})
mux.HandleFunc("/echo", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Write(body)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// registerHTTP 把 httptest.Server 登记为一条隧道,返回公网端口
func registerHTTP(t *testing.T, n *node.Node, a *agent.Agent, srv *httptest.Server, id string) int {
t.Helper()
port, err := a.RegisterTunnel(buildTunnel(t, n, a, id, srv.Listener.Addr().String(), 0))
if err != nil {
t.Fatal(err)
}
return port
}
func TestHTTPGetPostThroughTunnel(t *testing.T) {
srv := startHTTPServer(t)
n, a := startEnv(t)
port := registerHTTP(t, n, a, srv, "http-tunnel")
base := "http://127.0.0.1:" + strconv.Itoa(port)
// GET
resp, err := http.Get(base + "/hello")
if err != nil {
t.Fatal(err)
}
var body map[string]string
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK || body["hello"] != "zonat" {
t.Fatalf("GET /hello 异常: status=%d body=%v", resp.StatusCode, body)
}
// POST 大 body(1MB,跨 smux 多帧)
payload := make([]byte, 1<<20)
if _, err := rand.Read(payload); err != nil {
t.Fatal(err)
}
resp2, err := http.Post(base+"/echo", "application/octet-stream", bytes.NewReader(payload))
if err != nil {
t.Fatal(err)
}
got, err := io.ReadAll(resp2.Body)
resp2.Body.Close()
if err != nil {
t.Fatal(err)
}
if resp2.StatusCode != http.StatusOK || !bytes.Equal(got, payload) {
t.Fatalf("POST /echo 异常: status=%d len(got)=%d", resp2.StatusCode, len(got))
}
}
func TestHTTPKeepAliveThroughTunnel(t *testing.T) {
srv := startHTTPServer(t)
n, a := startEnv(t)
port := registerHTTP(t, n, a, srv, "http-ka")
// 同一 client 复用 TCP 连接(keep-alive),10 次请求走同一条隧道流
client := &http.Client{Timeout: 10 * time.Second}
for i := 0; i < 10; i++ {
resp, err := client.Get("http://127.0.0.1:" + strconv.Itoa(port) + "/hello")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("第 %d 次请求异常: %d", i+1, resp.StatusCode)
}
}
}
+69
View File
@@ -0,0 +1,69 @@
// 日志注入测试:验证 agent 包可携带宿主项目的日志实现,
// 日志统一走注入的 Logger(嵌入到 capricorn 等项目时日志不再"各写各的")。
package e2e
import (
"fmt"
"strings"
"sync"
"testing"
"time"
"git.zeroonesoft.cn/golib/zonat/agent"
)
// captureLogger 捕获日志的假实现(模拟宿主项目的 logger)
type captureLogger struct {
mu sync.Mutex
lines []string
}
func (c *captureLogger) Debugf(format string, args ...any) {}
func (c *captureLogger) appendf(format string, args ...any) {
c.mu.Lock()
c.lines = append(c.lines, fmt.Sprintf(format, args...))
c.mu.Unlock()
}
func (c *captureLogger) Infof(format string, args ...any) { c.appendf(format, args...) }
func (c *captureLogger) Warnf(format string, args ...any) { c.appendf(format, args...) }
func (c *captureLogger) Errorf(format string, args ...any) { c.appendf(format, args...) }
func (c *captureLogger) contains(sub string) bool {
c.mu.Lock()
defer c.mu.Unlock()
for _, l := range c.lines {
if strings.Contains(l, sub) {
return true
}
}
return false
}
func TestLoggerInjection(t *testing.T) {
target := startEchoServer(t)
n := startNode(t)
cap := &captureLogger{}
a := agent.New("agent-log")
a.Logger = cap
a.Backoff = 100 * time.Millisecond
a.PingEvery = 0
go a.Run()
t.Cleanup(a.Close)
if _, err := a.RegisterTunnel(buildTunnel(t, n, a, "log-tunnel", target, 0)); err != nil {
t.Fatal(err)
}
// 注入的 logger 必须收到 agent 内部日志
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cap.contains("已连接节点") {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("注入的 logger 未收到 agent 日志")
}
+391
View File
@@ -0,0 +1,391 @@
// 端到端穿透测试:节点 + agent + 内网目标全部进程内启动,
// 验证 公网口 → 桥接(smux/TCP) → 内网目标 的完整链路,不依赖 capricorn。
// 每条隧道自带节点地址与 JWT,独立连接登录;agent 不再有集中连接。
package e2e
import (
"bytes"
"crypto/rand"
"encoding/binary"
"fmt"
"io"
"net"
"strconv"
"sync"
"testing"
"time"
"git.zeroonesoft.cn/golib/zonat/agent"
"git.zeroonesoft.cn/golib/zonat/internal/node"
)
// startEchoServer 启动 TCP 回显服务(模拟 VNC/RDP/3000 等内网 TCP 目标)
func startEchoServer(t *testing.T) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
go func() {
for {
conn, err := l.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
io.Copy(c, c)
}(conn)
}
}()
return l.Addr().String()
}
// startNode 启动一个节点(随机端口,JWT 密钥 test-jwt-secret)
func startNode(t *testing.T) *node.Node {
t.Helper()
n := node.New()
n.JwtSecret = "test-jwt-secret"
n.BindTunnel = "127.0.0.1"
n.SweepInterval = 200 * time.Millisecond
if err := n.Start("127.0.0.1:0"); err != nil {
t.Fatal(err)
}
t.Cleanup(n.Stop)
return n
}
// startAgent 启动一个 agent(不连接任何节点,隧道注册时才连)
func startAgent(t *testing.T, id string) *agent.Agent {
t.Helper()
a := agent.New(id)
a.Backoff = 100 * time.Millisecond
a.PingEvery = 200 * time.Millisecond
go a.Run()
t.Cleanup(a.Close)
return a
}
// startEnv 单节点 + agent-1
func startEnv(t *testing.T) (*node.Node, *agent.Agent) {
t.Helper()
n := startNode(t)
return n, startAgent(t, "agent-1")
}
// mintToken 签发该节点可认的登录 JWT
func mintToken(t *testing.T, n *node.Node, agentId string) string {
t.Helper()
tok, err := node.SignToken(n.JwtSecret, agentId, time.Hour)
if err != nil {
t.Fatal(err)
}
return tok
}
// buildTunnel 构造一条登记到指定节点的隧道定义
func buildTunnel(t *testing.T, n *node.Node, a *agent.Agent, id, target string, ttl int) agent.Tunnel {
t.Helper()
host, portStr, _ := net.SplitHostPort(target)
portNum, _ := strconv.Atoi(portStr)
return agent.Tunnel{
Id: id, NodeAddr: n.Addr(), Token: mintToken(t, n, a.AgentId),
TargetIp: host, TargetPort: portNum, TTLSec: ttl,
}
}
func mustRegister(t *testing.T, n *node.Node, a *agent.Agent, target string) (id string, port int) {
t.Helper()
id = fmt.Sprintf("t-%d", time.Now().UnixNano())
port, err := a.RegisterTunnel(buildTunnel(t, n, a, id, target, 0))
if err != nil {
t.Fatal(err)
}
return id, port
}
// roundTrip 建立一条穿透连接,发送 payload 并读回,返回实际回读内容
func roundTrip(t *testing.T, port int, payload []byte) []byte {
t.Helper()
conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 2*time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
t.Fatal(err)
}
if _, err := conn.Write(payload); err != nil {
t.Fatal(err)
}
got := make([]byte, len(payload))
if _, err := io.ReadFull(conn, got); err != nil {
t.Fatal(err)
}
return got
}
func TestTCPEchoRoundTrip(t *testing.T) {
target := startEchoServer(t)
n, a := startEnv(t)
_, port := mustRegister(t, n, a, target)
payload := []byte("hello 内网穿透")
if got := roundTrip(t, port, payload); !bytes.Equal(got, payload) {
t.Fatalf("回显不匹配 got=%q want=%q", got, payload)
}
}
// TestMultiNodeTunnels 本次多节点重构的验收用例:
// 一个 agent 的两条隧道分别登录两个不同节点,各自独立可用。
func TestMultiNodeTunnels(t *testing.T) {
target1 := startEchoServer(t)
target2 := startEchoServer(t)
n1 := startNode(t)
n2 := startNode(t)
a := startAgent(t, "multi-1")
p1, err := a.RegisterTunnel(buildTunnel(t, n1, a, "t-node1", target1, 0))
if err != nil {
t.Fatal(err)
}
p2, err := a.RegisterTunnel(buildTunnel(t, n2, a, "t-node2", target2, 0))
if err != nil {
t.Fatal(err)
}
if !a.Connected("t-node1") || !a.Connected("t-node2") {
t.Fatal("两条隧道应同时保持连接")
}
if got := roundTrip(t, p1, []byte("via-node-1")); !bytes.Equal(got, []byte("via-node-1")) {
t.Fatal("节点1的隧道回显失败")
}
if got := roundTrip(t, p2, []byte("via-node-2")); !bytes.Equal(got, []byte("via-node-2")) {
t.Fatal("节点2的隧道回显失败")
}
// 踢掉其中一个节点上的会话,只影响该节点的隧道
n1.Kick("multi-1")
time.Sleep(300 * time.Millisecond)
if !a.Connected("t-node2") {
t.Fatal("节点2的隧道不应受节点1踢会话影响")
}
}
func TestTCPEchoConcurrent(t *testing.T) {
target := startEchoServer(t)
n, a := startEnv(t)
_, port := mustRegister(t, n, a, target)
const workers, rounds = 20, 10
var wg sync.WaitGroup
errCh := make(chan error, workers)
for i := 0; i < workers; i++ {
wg.Add(1)
go func(seed byte) {
defer wg.Done()
payload := bytes.Repeat([]byte{seed}, 1024)
for r := 0; r < rounds; r++ {
conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 2*time.Second)
if err != nil {
errCh <- err
return
}
if _, err := conn.Write(payload); err != nil {
conn.Close()
errCh <- err
return
}
got := make([]byte, len(payload))
if _, err := io.ReadFull(conn, got); err != nil {
conn.Close()
errCh <- err
return
}
conn.Close()
if !bytes.Equal(got, payload) {
errCh <- fmt.Errorf("回显不匹配 seed=%d", seed)
return
}
}
}(byte(i + 1))
}
wg.Wait()
select {
case err := <-errCh:
t.Fatal(err)
default:
}
}
func TestTCPEchoLargePayload(t *testing.T) {
target := startEchoServer(t)
n, a := startEnv(t)
_, port := mustRegister(t, n, a, target)
// 4MB:远大于 smux 单帧(32KB),验证分帧与重组
payload := make([]byte, 4<<20)
if _, err := rand.Read(payload); err != nil {
t.Fatal(err)
}
if got := roundTrip(t, port, payload); !bytes.Equal(got, payload) {
t.Fatal("大包回显不匹配")
}
}
func TestUnregisterStopsListener(t *testing.T) {
target := startEchoServer(t)
n, a := startEnv(t)
id, port := mustRegister(t, n, a, target)
if got := roundTrip(t, port, []byte("ok")); !bytes.Equal(got, []byte("ok")) {
t.Fatal("注销前回显失败")
}
if err := a.UnregisterTunnel(id); err != nil {
t.Fatal(err)
}
time.Sleep(200 * time.Millisecond)
if conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 1*time.Second); err == nil {
conn.Close()
t.Fatal("注销后端口仍可连接")
}
}
func TestTTLAutoDelete(t *testing.T) {
target := startEchoServer(t)
n, a := startEnv(t)
port, err := a.RegisterTunnel(buildTunnel(t, n, a, "ttl-tunnel", target, 1))
if err != nil {
t.Fatal(err)
}
if got := roundTrip(t, port, []byte("x")); len(got) != 1 {
t.Fatal("TTL 隧道不可用")
}
// ttl=1s + 清扫间隔 200ms,2s 后应已删除并收到关闭推送
time.Sleep(2 * time.Second)
if conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 1*time.Second); err == nil {
conn.Close()
t.Fatal("TTL 过期后端口仍可连接")
}
}
func TestDuplicateTunnelId(t *testing.T) {
target := startEchoServer(t)
n, a := startEnv(t)
tun := buildTunnel(t, n, a, "dup", target, 0)
if _, err := a.RegisterTunnel(tun); err != nil {
t.Fatal(err)
}
// 同 agent 重复注册同 ID 视为更新配置:停旧起新,应成功
if _, err := a.RegisterTunnel(tun); err != nil {
t.Fatalf("同 agent 重复注册应顶替成功: %v", err)
}
// 其它 agent 的活隧道占用同 ID:应拒绝
b := startAgent(t, "agent-2")
if _, err := b.RegisterTunnel(buildTunnel(t, n, b, "dup", target, 0)); err == nil {
t.Fatal("不同 agent 注册同一隧道ID应失败")
}
}
// TestJWTAuthRejected JWT 认证拒绝:错误密钥签名 / 已过期 / agentId 不一致
func TestJWTAuthRejected(t *testing.T) {
target := startEchoServer(t)
n := startNode(t)
expired, err := node.SignToken(n.JwtSecret, "agent-x", -time.Minute)
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
token func(t *testing.T) string
}{
{"错误密钥签名", func(t *testing.T) string {
tok, err := node.SignToken("wrong-secret", "agent-x", time.Hour)
if err != nil {
t.Fatal(err)
}
return tok
}},
{"过期token", func(*testing.T) string { return expired }},
{"agentId不一致", func(t *testing.T) string { return mintToken(t, n, "someone-else") }},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
a := startAgent(t, "agent-x")
host, portStr, _ := net.SplitHostPort(target)
portNum, _ := strconv.Atoi(portStr)
_, err := a.RegisterTunnel(agent.Tunnel{
Id: "bad-jwt", NodeAddr: n.Addr(), Token: c.token(t),
TargetIp: host, TargetPort: portNum,
})
if err == nil {
t.Fatal("JWT 校验未通过仍登录成功")
}
})
}
}
func TestAgentReRegisterAfterKick(t *testing.T) {
target := startEchoServer(t)
n, a := startEnv(t)
portCh := make(chan int, 8)
a.OnTunnelPort = func(id string, port int) {
if port > 0 {
portCh <- port
}
}
if _, err := a.RegisterTunnel(buildTunnel(t, n, a, "keep", target, 0)); err != nil {
t.Fatal(err)
}
firstPort := <-portCh
// 节点踢掉会话,该隧道的 worker 应自动重连并重注册
if !n.Kick("agent-1") {
t.Fatal("Kick 失败")
}
var newPort int
select {
case newPort = <-portCh:
case <-time.After(5 * time.Second):
t.Fatal("重连后未重注册隧道")
}
if newPort == firstPort {
t.Log("重注册端口与原端口相同(随机分配碰撞,可接受)")
}
if got := roundTrip(t, newPort, []byte("after-kick")); !bytes.Equal(got, []byte("after-kick")) {
t.Fatal("重连后穿透失败")
}
}
// TestBinarySafePayload 首字节为 0x16(TLS 握手特征)等敏感字节的数据
// 必须原样通过——完整版的嗅探劫持问题在本库中已不存在。
func TestBinarySafePayload(t *testing.T) {
target := startEchoServer(t)
n, a := startEnv(t)
_, port := mustRegister(t, n, a, target)
// 0x16 0x03 0x01 开头:zonat IsHttps 会把它劫持到兜底站
payload := make([]byte, 0, 4096)
head := []byte{0x16, 0x03, 0x01, 0x00}
payload = append(payload, head...)
var l uint16 = 4000
binary.BigEndian.PutUint16(payload[3:5], l)
rest := make([]byte, 4096-len(payload))
if _, err := rand.Read(rest); err != nil {
t.Fatal(err)
}
payload = append(payload, rest...)
if got := roundTrip(t, port, payload); !bytes.Equal(got, payload) {
t.Fatal("二进制载荷被篡改(疑似协议嗅探干扰)")
}
}
+7
View File
@@ -0,0 +1,7 @@
module git.zeroonesoft.cn/golib/zonat
go 1.24.0
require github.com/xtaci/smux v1.5.57
require github.com/golang-jwt/jwt/v5 v5.3.1
+4
View File
@@ -0,0 +1,4 @@
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/xtaci/smux v1.5.57 h1:N72VbGoSYxgcm6mPOYX0QzEZNVD3UI/JlVvAtXF+WrY=
github.com/xtaci/smux v1.5.57/go.mod h1:IGQ9QYrBphmb/4aTnLEcJby0TNr3NV+OslIOMrX825Q=
+60
View File
@@ -0,0 +1,60 @@
// jwt.go agent 登录令牌的 JWT 校验与签发(HS256)。
// 正式部署由签发方(如 cloud)生成 token 下发给各隧道;节点只做校验。
// SignToken 供 cmd/node -print-token(运维手工签发)与测试使用。
package node
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
// verifyToken 校验 agent 登录 JWT:
// - HS256/384/512 签名(显式白名单,拒绝 none 与其它算法)
// - 必须带 exp 且未过期
// - 若带 agentId 或 sub claim,则必须与登录 AgentId 一致(防跨 agent 冒用)
func verifyToken(token, secret, agentId string) error {
if secret == "" {
return errors.New("节点未配置JWT密钥")
}
parsed, err := jwt.Parse(token, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("签名算法不允许:%v", t.Header["alg"])
}
return []byte(secret), nil
}, jwt.WithValidMethods([]string{
jwt.SigningMethodHS256.Alg(),
jwt.SigningMethodHS384.Alg(),
jwt.SigningMethodHS512.Alg(),
}), jwt.WithExpirationRequired())
if err != nil {
return err
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
return errors.New("claims类型错误")
}
for _, k := range []string{"agentId", "sub"} {
if v, ok := claims[k].(string); ok && v != "" && v != agentId {
return fmt.Errorf("token %s 与登录agentId不一致", k)
}
}
return nil
}
// SignToken 用节点密钥签发一张 agent 登录 JWT。
// ttl<=0 会得到立即过期的 token(仅测试构造过期样例时有意义)。
func SignToken(secret, agentId string, ttl time.Duration) (string, error) {
if secret == "" {
return "", errors.New("未配置JWT密钥")
}
now := time.Now()
claims := jwt.MapClaims{
"agentId": agentId,
"iat": now.Unix(),
"exp": now.Add(ttl).Unix(),
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
}
+307
View File
@@ -0,0 +1,307 @@
// Package node 轻量穿透节点(zonat server 的裁剪版):
// TCP 桥接登录(JWT)→ smux 会话 → 隧道注册表(纯内存)→ 纯 TCP 隧道监听。
// 相比 zonat server 裁掉:KCP 桥接、udp/http(s) 隧道、协议嗅探与兜底站、
// 管理 REST、数据库、限流与流量统计。
// 隧道生命周期由 agent 经控制流帧命令注册/注销,节点不再对外暴露任何管理端口。
// 一个 agent 的多条隧道各持一条桥接连接(可分布多节点),因此同一 agentId
// 同时存在多个会话是常态;同名隧道顶替按"隧道 ID + agentId"粒度在注册时进行。
package node
import (
"errors"
"fmt"
"log/slog"
"net"
"strconv"
"sync"
"time"
"git.zeroonesoft.cn/golib/zonat/agent"
)
// loginTimeout 登录握手读超时
const loginTimeout = 10 * time.Second
// Node 穿透节点
type Node struct {
JwtSecret string // agent 登录 JWT 校验密钥(HS256 系)
BindTunnel string // 隧道监听绑定地址(默认 0.0.0.0)
SweepInterval time.Duration // TTL 清扫间隔(默认 30s)
listener net.Listener
mu sync.Mutex
stopped bool
sessions map[*Session]struct{} // 该节点上的全部 agent 会话(一个 agent 多隧道=多会话)
tunnels map[string]*TunnelServer // tunnelId -> 隧道
}
// New 创建节点
func New() *Node {
return &Node{
BindTunnel: "0.0.0.0",
SweepInterval: 30 * time.Second,
sessions: make(map[*Session]struct{}),
tunnels: make(map[string]*TunnelServer),
}
}
// Start 启动桥接监听与 TTL 清扫
func (n *Node) Start(addr string) error {
l, err := net.Listen("tcp", addr)
if err != nil {
return err
}
n.listener = l
slog.Info("节点 桥接监听", "addr", l.Addr().String())
go n.acceptLoop()
go n.sweepLoop()
return nil
}
// Stop 停止节点,关闭所有隧道与会话
func (n *Node) Stop() {
n.mu.Lock()
if n.stopped {
n.mu.Unlock()
return
}
n.stopped = true
sessions := make([]*Session, 0, len(n.sessions))
for s := range n.sessions {
sessions = append(sessions, s)
}
tunnels := make([]*TunnelServer, 0, len(n.tunnels))
for _, t := range n.tunnels {
tunnels = append(tunnels, t)
}
n.mu.Unlock()
if n.listener != nil {
n.listener.Close()
}
for _, t := range tunnels {
t.close()
}
for _, s := range sessions {
s.close()
}
}
func (n *Node) isStopped() bool {
n.mu.Lock()
defer n.mu.Unlock()
return n.stopped
}
// Addr 桥接监听地址
func (n *Node) Addr() string {
if n.listener == nil {
return ""
}
return n.listener.Addr().String()
}
func (n *Node) acceptLoop() {
for {
conn, err := n.listener.Accept()
if err != nil {
if !n.isStopped() {
slog.Warn("节点 桥接接受连接错误", "err", err.Error())
}
return
}
go n.handleConn(conn)
}
}
// handleConn 登录握手(JWT 校验)+ 建立 smux 会话。
// 新版 agent 每条隧道一条连接,同一 agentId 的多条会话并存。
func (n *Node) handleConn(conn net.Conn) {
conn.SetReadDeadline(time.Now().Add(loginTimeout))
f, err := agent.ReadFrame(conn)
if err != nil {
conn.Close()
return
}
if f.Cmd() != agent.CmdLogin {
conn.Close()
return
}
lg := agent.Login{}
if err := f.Unmarshal(&lg); err != nil || lg.AgentId == "" {
_ = writeRet(conn, agent.CmdLogin, 0, agent.Ret{Code: 1, Msg: "登录失败"})
conn.Close()
return
}
if err := verifyToken(lg.Token, n.JwtSecret, lg.AgentId); err != nil {
slog.Warn("节点 agent登录拒绝", "agent", lg.AgentId, "err", err.Error())
_ = writeRet(conn, agent.CmdLogin, 0, agent.Ret{Code: 1, Msg: "登录失败"})
conn.Close()
return
}
if err := writeRet(conn, agent.CmdLogin, 0, agent.Ret{Code: 0, Msg: "登录成功"}); err != nil {
conn.Close()
return
}
// smux 自管超时,清除登录用的读超时
conn.SetReadDeadline(time.Time{})
sess, err := smuxServer(conn)
if err != nil {
conn.Close()
return
}
s := &Session{agentId: lg.AgentId, node: n, session: sess}
n.mu.Lock()
if n.stopped {
n.mu.Unlock()
conn.Close()
return
}
n.sessions[s] = struct{}{}
n.mu.Unlock()
slog.Info("节点 agent登录", "agent", lg.AgentId, "remote", conn.RemoteAddr().String())
s.loop()
n.detachSession(s)
}
// detachSession 会话断开后:摘除会话,关闭其名下全部隧道
func (n *Node) detachSession(s *Session) {
n.mu.Lock()
delete(n.sessions, s)
var mine []*TunnelServer
for id, t := range n.tunnels {
if t.session == s {
mine = append(mine, t)
delete(n.tunnels, id)
}
}
n.mu.Unlock()
for _, t := range mine {
t.close()
}
slog.Info("节点 agent会话结束", "agent", s.agentId, "关闭隧道", len(mine))
}
// Kick 踢掉指定 agent 的全部会话(运维/测试用)。
// 新版 agent 每条隧道一条会话,Kick 会断其所有隧道,agent 侧各自重连重注册。
func (n *Node) Kick(agentId string) bool {
n.mu.Lock()
var targets []*Session
for s := range n.sessions {
if s.agentId == agentId {
targets = append(targets, s)
}
}
n.mu.Unlock()
for _, s := range targets {
s.close()
}
return len(targets) > 0
}
// registerTunnel 为 agent 注册一条隧道并开始公网监听
func (n *Node) registerTunnel(s *Session, t agent.Tunnel) (agent.Tunnel, error) {
if t.Id == "" {
return t, errors.New("隧道ID不能为空")
}
if t.TargetIp == "" || t.TargetPort <= 0 || t.TargetPort > 65535 {
return t, fmt.Errorf("目标地址错误:%s", t.TargetAddr())
}
if t.ListenPort < 0 || t.ListenPort > 65535 {
return t, fmt.Errorf("监听端口错误:%d", t.ListenPort)
}
// 凭据只用于登录,节点侧不留存也不回传(CmdTarget 不携带)
t.NodeAddr, t.Token = "", ""
n.mu.Lock()
defer n.mu.Unlock()
if n.stopped {
return t, errors.New("节点已停止")
}
// 同 ID 隧道已存在:同 agent 重登顶替(更新配置)或死会话残留可替换,
// 其它 agent 的活隧道拒绝
if old, ok := n.tunnels[t.Id]; ok {
if old.session.agentId != s.agentId && !old.session.isDead() {
return t, fmt.Errorf("隧道已存在:%s", t.Id)
}
old.close()
old.session.close() // 顶替旧连接,避免旧 worker 与新 worker 争抢
delete(n.tunnels, t.Id)
slog.Info("节点 同agent隧道顶替", "id", t.Id, "agent", s.agentId)
}
ts := &TunnelServer{tunnel: t, session: s}
ts.touch()
l, err := net.Listen("tcp", net.JoinHostPort(n.BindTunnel, strconv.Itoa(t.ListenPort)))
if err != nil {
return t, err
}
ts.listener = l
_, portStr, _ := net.SplitHostPort(l.Addr().String())
ts.tunnel.ListenPort, _ = strconv.Atoi(portStr)
n.tunnels[t.Id] = ts
go ts.acceptLoop()
slog.Info("节点 隧道注册", "id", t.Id, "listen", ts.tunnel.ListenPort,
"target", t.TargetAddr(), "ttl", t.TTLSec)
return ts.tunnel, nil
}
// unregisterTunnel 删除隧道(关闭公网监听)
func (n *Node) unregisterTunnel(id string) bool {
n.mu.Lock()
ts, ok := n.tunnels[id]
if ok {
delete(n.tunnels, id)
}
n.mu.Unlock()
if !ok {
return false
}
ts.close()
slog.Info("节点 隧道删除", "id", id)
return true
}
func (n *Node) sweepLoop() {
ticker := time.NewTicker(n.SweepInterval)
defer ticker.Stop()
for range ticker.C {
if n.isStopped() {
return
}
n.sweepOnce()
}
}
// sweepOnce 清扫 TTL 过期且空闲的隧道,并向所属 agent 推送关闭通知
func (n *Node) sweepOnce() {
now := time.Now()
n.mu.Lock()
var expired []*TunnelServer
for id, ts := range n.tunnels {
ttl := time.Duration(ts.tunnel.TTLSec) * time.Second
if ttl > 0 && ts.connCount.Load() == 0 && now.Sub(ts.lastActiveTime()) > ttl {
expired = append(expired, ts)
delete(n.tunnels, id)
}
}
n.mu.Unlock()
for _, ts := range expired {
ts.close()
ts.session.pushClosed(ts.tunnel.Id)
slog.Info("节点 隧道TTL过期删除", "id", ts.tunnel.Id)
}
}
func writeRet(conn net.Conn, cmd byte, sid uint32, ret agent.Ret) error {
f := agent.NewFrame(agent.FrameVersion, cmd, sid)
if err := f.Marshal(ret); err != nil {
return err
}
return agent.WriteFrame(conn, f)
}
+137
View File
@@ -0,0 +1,137 @@
package node
import (
"net"
"sync"
"sync/atomic"
"git.zeroonesoft.cn/golib/zonat/agent"
"github.com/xtaci/smux"
)
// Session 一个 agent 连接的会话:smux 服务端 + 控制流处理。
// 数据流由节点侧隧道监听主动 OpenStream;agent 主动打开的第一条流是控制流,
// 用于注册/注销隧道与心跳。
type Session struct {
agentId string
node *Node
session *smux.Session
control net.Conn // agent 打开的控制流
controlMu sync.Mutex // 控制流写锁(handleControl 与 pushClosed 并发写)
dead atomic.Bool
}
func (s *Session) isDead() bool { return s.dead.Load() }
func (s *Session) close() {
s.dead.Store(true)
s.session.Close()
}
// OpenStream 建立一条到 agent 的数据流
func (s *Session) OpenStream() (net.Conn, error) {
return s.session.OpenStream()
}
// loop 接受 agent 打开的控制流,直到会话结束
func (s *Session) loop() {
for {
stream, err := s.session.AcceptStream()
if err != nil {
s.close()
return
}
// agent 打开的第一条流是控制流,pushClosed 依赖它;
// 赋值与写入共用 controlMu,避免与推送协程竞争
s.controlMu.Lock()
if s.control == nil {
s.control = stream
}
s.controlMu.Unlock()
go s.handleControl(stream)
}
}
// handleControl 处理控制流上的帧:注册/注销隧道、心跳。
// 单条控制流内的帧顺序处理;应答与节点推送共用 controlMu 串行写入。
func (s *Session) handleControl(stream net.Conn) {
defer stream.Close()
for {
f, err := agent.ReadFrame(stream)
if err != nil {
return
}
switch f.Cmd() {
case agent.CmdRegisterTunnel:
t := agent.Tunnel{}
ret := agent.Ret{Code: 1, Msg: "注册失败"}
if err := f.Unmarshal(&t); err != nil {
ret.Msg = err.Error()
} else if nt, err := s.node.registerTunnel(s, t); err != nil {
ret.Msg = err.Error()
} else {
ret = agent.Ret{Code: 0, Msg: "注册成功", Port: nt.ListenPort}
}
s.reply(stream, f, ret)
case agent.CmdUnregisterTunnel:
t := agent.Tunnel{}
ret := agent.Ret{Code: 1, Msg: "注销失败"}
if err := f.Unmarshal(&t); err != nil {
ret.Msg = err.Error()
} else if s.node.unregisterTunnel(t.Id) {
ret = agent.Ret{Code: 0, Msg: "注销成功"}
} else {
ret.Msg = "隧道不存在"
}
s.reply(stream, f, ret)
case agent.CmdPing:
s.writeControl(func() error {
return agent.WriteFrame(stream, agent.NewFrame(agent.FrameVersion, agent.CmdPing, f.StreamID()))
})
case agent.CmdLogin, agent.CmdTarget, agent.CmdTunnelClosed:
// 不该出现在 agent→节点方向,忽略
default:
s.reply(stream, f, agent.Ret{Code: 1, Msg: "未知命令"})
}
}
}
func (s *Session) reply(stream net.Conn, req agent.Frame, ret agent.Ret) {
s.writeControl(func() error {
f := agent.NewFrame(agent.FrameVersion, req.Cmd(), req.StreamID())
if err := f.Marshal(ret); err != nil {
return err
}
return agent.WriteFrame(stream, f)
})
}
// pushClosed 节点主动通知 agent:隧道已被删除(TTL 过期等)
func (s *Session) pushClosed(tunnelId string) {
if s.control == nil {
return
}
s.writeControl(func() error {
f := agent.NewFrame(agent.FrameVersion, agent.CmdTunnelClosed, 0)
if err := f.Marshal(agent.Tunnel{Id: tunnelId}); err != nil {
return err
}
return agent.WriteFrame(s.control, f)
})
}
func (s *Session) writeControl(fn func() error) {
s.controlMu.Lock()
defer s.controlMu.Unlock()
if s.dead.Load() {
return
}
// 写失败意味着会话已死,由读循环感知并收尾
_ = fn()
}
func smuxServer(conn net.Conn) (*smux.Session, error) {
return smux.Server(conn, agent.SmuxConfig())
}
+107
View File
@@ -0,0 +1,107 @@
package node
import (
"io"
"log/slog"
"net"
"sync/atomic"
"time"
"git.zeroonesoft.cn/golib/zonat/agent"
)
// TunnelServer 一条已注册隧道的公网侧监听。
// 纯 TCP 字节管道:无协议嗅探、无 HTTP 代理、无证书,任何 TCP 协议原样通过。
type TunnelServer struct {
tunnel agent.Tunnel
session *Session
listener net.Listener
connCount atomic.Int64
lastActive atomic.Int64 // unix nano
closed atomic.Bool
}
// Port 公网监听端口
func (t *TunnelServer) Port() int { return t.tunnel.ListenPort }
func (t *TunnelServer) touch() { t.lastActive.Store(time.Now().UnixNano()) }
func (t *TunnelServer) lastActiveTime() time.Time {
return time.Unix(0, t.lastActive.Load())
}
func (t *TunnelServer) acceptLoop() {
for {
conn, err := t.listener.Accept()
if err != nil {
return
}
if t.closed.Load() {
conn.Close()
return
}
go t.handleConn(conn)
}
}
// handleConn 公网连接 → smux 新流 → 首帧告知目标 → agent 回执后双向管道。
// 与 zonat 多路复用路径一致,仅去掉协议嗅探与限流。
func (t *TunnelServer) handleConn(conn net.Conn) {
defer conn.Close()
if t.session.isDead() {
return
}
stream, err := t.session.OpenStream()
if err != nil {
return
}
defer stream.Close()
tf := agent.NewFrame(agent.FrameVersion, agent.CmdTarget, 0)
if err := tf.Marshal(t.tunnel); err != nil {
return
}
if err := agent.WriteFrame(stream, tf); err != nil {
return
}
rf, err := agent.ReadFrame(stream)
if err != nil {
return
}
ret := agent.Ret{}
if err := rf.Unmarshal(&ret); err != nil || ret.Code != 0 {
slog.Warn("节点 agent拨号失败", "id", t.tunnel.Id, "msg", ret.Msg)
return
}
// 公网侧 TCP 保活(smux 流自身由会话级 keepalive 保护)
if tc, ok := conn.(*net.TCPConn); ok {
tc.SetKeepAlive(true)
tc.SetKeepAlivePeriod(5 * time.Second)
}
t.connCount.Add(1)
t.touch()
defer func() {
t.connCount.Add(-1)
t.touch()
}()
pipe(conn, stream)
}
func (t *TunnelServer) close() {
if t.closed.CompareAndSwap(false, true) && t.listener != nil {
t.listener.Close()
}
}
// pipe 双向拷贝,任一方向结束即关闭两端
func pipe(a, b net.Conn) {
go func() {
io.Copy(b, a)
b.Close()
}()
io.Copy(a, b)
a.Close()
}