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
+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("二进制载荷被篡改(疑似协议嗅探干扰)")
}
}