init: 自 zomaintain/backend/zonat 平移独立成库; module path 改为 git.zeroonesoft.cn/golib/zonat; 内部 import 同步重写
This commit is contained in:
@@ -0,0 +1 @@
|
||||
bin/
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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("二进制载荷被篡改(疑似协议嗅探干扰)")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user