From 4738c98c55fc554a573600e10d54efff5374572c Mon Sep 17 00:00:00 2001 From: maxyu Date: Wed, 8 Jul 2026 10:42:53 +0800 Subject: [PATCH 01/23] =?UTF-8?q?docs(plan):=20e2e=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=20T00-T08?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 spec 拆解:MESH_TEST_TLS 开关、Docker 编排、helpers/metrics、 netem 预设、连通性/性能/故障三场景、run.sh 聚合、CI workflow。 Co-Authored-By: Claude --- docs/superpowers/plans/2026-07-08-e2e-impl.md | 1315 +++++++++++++++++ 1 file changed, 1315 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-e2e-impl.md diff --git a/docs/superpowers/plans/2026-07-08-e2e-impl.md b/docs/superpowers/plans/2026-07-08-e2e-impl.md new file mode 100644 index 0000000..005fe9a --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-e2e-impl.md @@ -0,0 +1,1315 @@ +# e2e 测试(基于 Docker)实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为 mesh VPN 建立一套基于 Docker 的 e2e 测试:3 个 Linux 容器(1 server + 2 client),通过 TUN + tc netem 验证连通性、性能、故障恢复,输出 JSON 指标和 PASS/FAIL 报告。 + +**Architecture:** server/client 跑在 privileged Linux 容器内(`/dev/net/tun` + `NET_ADMIN`);引入 `MESH_TEST_TLS=off` 开关让 server 用自签证书、client 跳过证书校验;bash 脚本驱动 3 个场景(连通性/性能/故障),结果落到 `results//` 并聚合为 `summary.txt`。 + +**Tech Stack:** Go 1.22+(mesh 本身)、Docker + docker-compose v2、ubuntu:24.04 镜像、iperf3、nuttcp、tc/netem、bash、jq。 + +**Spec:** `docs/superpowers/specs/2026-07-08-e2e-docker-design.md` + +--- + +## 文件结构 + +本计划产出/修改的文件: + +**代码改动(T00):** +- `internal/config/config.go` — 增加 `TLSTestMode bool` 字段与 `MESH_TEST_TLS` 环境变量读取。 +- `internal/api/server.go` — `ListenAndServeTLS` 支持 test mode 自签证书路径。 +- `internal/client/join.go` — `Join` 支持 `insecureTLS` 参数,自签时跳过校验。 +- `internal/client/up.go` — `Up` 读取 client config 的 insecure 标记,传给 tunnel client。 +- `internal/client/config.go` — `ClientConfig` 增加 `InsecureTLS bool`。 +- `internal/tunnel/client.go` — `NewTunnelClient` 增加 `tlsConfig *tls.Config` 参数。 + +**测试基建(T01–T08):** +- `tests/e2e/Dockerfile.server` — server 容器镜像。 +- `tests/e2e/Dockerfile.client` — client 容器镜像。 +- `tests/e2e/docker-compose.yml` — 3 容器编排。 +- `tests/e2e/fixtures/meshd.yaml` — server 配置模板。 +- `tests/e2e/fixtures/entrypoint-server.sh` — server 容器启动逻辑。 +- `tests/e2e/fixtures/entrypoint-client.sh` — client 容器启动逻辑(等待→join→up)。 +- `tests/e2e/fixtures/netem.sh` — tc netem 预设封装。 +- `tests/e2e/lib/helpers.sh` — wait_ready / register / show_logs 等通用函数。 +- `tests/e2e/lib/metrics.sh` — RTT/吞吐/丢包采集与 JSON 输出。 +- `tests/e2e/scenarios/01-connectivity.sh` — P0 连通性场景。 +- `tests/e2e/scenarios/02-performance.sh` — P0 性能场景。 +- `tests/e2e/scenarios/03-failure.sh` — P1 故障场景。 +- `tests/e2e/run.sh` — 一键入口(启动/跑场景/收尾/聚合)。 +- `.github/workflows/e2e.yml` — CI 触发。 + +--- + +## Task 0: MESH_TEST_TLS 开关(自签证书支持) + +**Files:** +- Modify: `internal/config/config.go` +- Modify: `internal/api/server.go` +- Modify: `internal/client/config.go` +- Modify: `internal/client/join.go` +- Modify: `internal/client/up.go` +- Modify: `internal/tunnel/client.go` +- Test: `internal/config/config_test.go` +- Test: `internal/client/join_test.go`(新建) + +**背景:** 容器内 server 拿不到 Let's Encrypt 证书。需要让 server 在 `MESH_TEST_TLS=off` 时用自签证书,client 端跳过证书校验。这是整个 e2e 的前置条件。 + +- [ ] **Step 1: 写 config 解析的失败测试** + +在 `internal/config/config_test.go` 末尾追加: + +```go +func TestLoadTLSTestModeFromEnv(t *testing.T) { + t.Setenv("MESH_TEST_TLS", "off") + cfg := Default() + cfg.applyTestMode() + if !cfg.TLSTestMode { + t.Fatal("expected TLSTestMode=true when MESH_TEST_TLS=off") + } + + t.Setenv("MESH_TEST_TLS", "") + cfg2 := Default() + cfg2.applyTestMode() + if cfg2.TLSTestMode { + t.Fatal("expected TLSTestMode=false when MESH_TEST_TLS unset") + } +} +``` + +- [ ] **Step 2: 运行测试,确认失败** + +Run: `go test ./internal/config/... -run TestLoadTLSTestModeFromEnv -v` +Expected: FAIL — `cfg.TLSTestMode undefined` / `cfg.applyTestMode undefined`。 + +- [ ] **Step 3: 实现 config 字段与方法** + +在 `internal/config/config.go` 的 `Config` struct 加字段,并在 `Load` 末尾调用 `applyTestMode`: + +```go +type Config struct { + Domain string `yaml:"domain"` + ListenAddr string `yaml:"listen_addr"` + Network string `yaml:"network"` + DataDir string `yaml:"data_dir"` + CertDir string `yaml:"cert_dir"` + TunName string `yaml:"tun_name"` + TunMTU int `yaml:"tun_mtu"` + TLSTestMode bool `yaml:"-"` // 由 MESH_TEST_TLS 环境变量控制,不入 yaml +} + +// applyTestMode reads MESH_TEST_TLS env and enables self-signed TLS mode. +// MESH_TEST_TLS=off (or 1/true/on) switches the server off Let's Encrypt. +func (c *Config) applyTestMode() { + v := strings.ToLower(strings.TrimSpace(os.Getenv("MESH_TEST_TLS"))) + switch v { + case "off", "1", "true", "on", "self": + c.TLSTestMode = true + } +} +``` + +并在 `Load` 的 `return cfg, nil` 前插入 `cfg.applyTestMode()`。import 增加 `"strings"`。 + +- [ ] **Step 4: 运行测试,确认通过** + +Run: `go test ./internal/config/... -run TestLoadTLSTestModeFromEnv -v` +Expected: PASS。 + +- [ ] **Step 5: 让 server 在 test mode 下用自签证书** + +修改 `internal/api/server.go` 的 `ListenAndServeTLS`。替换整个函数体为: + +```go +// ListenAndServeTLS starts the HTTPS server. In production it uses autocert +// (Let's Encrypt); when cfg.TLSTestMode is set it uses an in-memory +// self-signed certificate so the server can run in containers without a +// real domain. +func (s *Server) ListenAndServeTLS(ctx context.Context) error { + srv := &http.Server{ + Addr: s.cfg.ListenAddr, + Handler: s.Handler(), + } + + go func() { + <-ctx.Done() + srv.Close() //nolint:errcheck + }() + + if s.cfg.TLSTestMode { + tlsCfg, err := selfSignedTLSConfig() + if err != nil { + return fmt.Errorf("self-signed cert: %w", err) + } + srv.TLSConfig = tlsCfg + return srv.ListenAndServeTLS("", "") + } + + m := &autocert.Manager{ + Cache: autocert.DirCache(s.cfg.CertDir), + Prompt: autocert.AcceptTOS, + HostPolicy: autocert.HostWhitelist(s.cfg.Domain), + } + srv.TLSConfig = &tls.Config{ + GetCertificate: m.GetCertificate, + MinVersion: tls.VersionTLS12, + } + // HTTP-01 ACME challenge listener on :80 + go http.ListenAndServe(":80", m.HTTPHandler(nil)) //nolint:errcheck + return srv.ListenAndServeTLS("", "") +} + +// selfSignedTLSConfig builds an in-memory self-signed certificate valid for +// the configured domain and common loopback names, used only in test mode. +func selfSignedTLSConfig() (*tls.Config, error) { + host := s.cfg.Domain + if host == "" { + host = "localhost" + } + tpl := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: host}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + DNSNames: []string{host, "localhost", "server"}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, + } + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + der, err := x509.CreateCertificate(rand.Reader, &tpl, &tpl, &priv.PublicKey, priv) + if err != nil { + return nil, err + } + return &tls.Config{ + Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: priv}}, + MinVersion: tls.VersionTLS12, + }, nil +} +``` + +注意 `selfSignedTLSConfig` 需要访问 `s.cfg.Domain`,所以把它改成方法 `func (s *Server) selfSignedTLSConfig()`,并把上面调用处 `selfSignedTLSConfig()` 改为 `s.selfSignedTLSConfig()`。 + +新增 import:`"crypto/ecdsa"`、`"crypto/elliptic"`、`"crypto/rand"`、`"crypto/x509"`、`"crypto/x509/pkix"`、`"math/big"`、`"net"`、`"time"`。 + +- [ ] **Step 6: 让 client config 携带 insecure 标记** + +修改 `internal/client/config.go` 的 `ClientConfig`: + +```go +type ClientConfig struct { + ServerDomain string `json:"server_domain"` + DeviceSecret string `json:"device_secret"` + DeviceIP string `json:"device_ip"` + DeviceID string `json:"device_id"` + NetworkCIDR string `json:"network_cidr"` + InsecureTLS bool `json:"insecure_tls,omitempty"` +} +``` + +- [ ] **Step 7: 让 join 接受 insecure 选项并保存到 config** + +修改 `internal/client/join.go` 的 `Join` 签名与实现: + +```go +func Join(domain, tok string, insecureTLS bool) error { + if err := os.MkdirAll(ConfigDir(), 0700); err != nil { + return err + } + if _, err := LoadClientConfig(); err == nil { + return fmt.Errorf("already registered; run 'mesh leave' first") + } + + hostname, _ := os.Hostname() + reqBody, _ := json.Marshal(map[string]string{ + "token": tok, + "hostname": hostname, + }) + + httpClient := &http.Client{Timeout: 10 * time.Second} + if insecureTLS { + httpClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } + url := fmt.Sprintf("https://%s/api/devices/register", domain) + resp, err := httpClient.Post(url, "application/json", bytes.NewReader(reqBody)) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusUnauthorized: + return fmt.Errorf("invalid token") + case http.StatusOK: + default: + return fmt.Errorf("registration failed: HTTP %d", resp.StatusCode) + } + + var regResp struct { + AssignedIP string `json:"assigned_ip"` + DeviceSecret string `json:"device_secret"` + DeviceID string `json:"device_id"` + NetworkCIDR string `json:"network_cidr"` + } + if err := json.NewDecoder(resp.Body).Decode(®Resp); err != nil { + return fmt.Errorf("parse response: %w", err) + } + + cfg := &ClientConfig{ + ServerDomain: domain, + DeviceSecret: regResp.DeviceSecret, + DeviceIP: regResp.AssignedIP, + DeviceID: regResp.DeviceID, + NetworkCIDR: regResp.NetworkCIDR, + InsecureTLS: insecureTLS, + } + if err := SaveClientConfig(cfg); err != nil { + return err + } + + fmt.Printf("Registered!\n IP: %s\n Network: %s\n", regResp.AssignedIP, regResp.NetworkCIDR) + return nil +} +``` + +新增 import:`"crypto/tls"`、`"time"`。 + +更新 `cmd/mesh/main.go` 的 `joinCmd`:增加 `--insecure` flag,传给 `client.Join`: + +```go +func joinCmd() *cobra.Command { + var tok, insecureStr string + cmd := &cobra.Command{ + Use: "join ", + Args: cobra.ExactArgs(1), + Short: "向服务器注册并加入 mesh 网络(无需 root)", + RunE: func(c *cobra.Command, args []string) error { + insecure := insecureStr != "" + if err := client.Join(args[0], tok, insecure); err != nil { + return err + } + fmt.Println("Now run 'sudo mesh up' to start the tunnel.") + return nil + }, + } + cmd.Flags().StringVar(&tok, "token", "", "注册令牌(由服务器管理员提供)") + cmd.Flags().StringVar(&insecureStr, "insecure", "", "跳过 TLS 证书校验(用于自签测试环境),任意非空值启用") + _ = cmd.MarkFlagRequired("token") + return cmd +} +``` + +- [ ] **Step 8: 让 tunnel client 接受 TLS 配置** + +修改 `internal/tunnel/client.go` 的 `NewTunnelClient`,增加 `tlsConfig *tls.Config` 参数,并在 `connect` 的 `websocket.Dial` 里传: + +```go +import "crypto/tls" + +func NewTunnelClient(serverURL, secret, localIP, network string, mtu int, statusDir string, tlsConfig *tls.Config) (*TunnelClient, error) { + // ... 原有 TUN 创建逻辑不变 ... + return &TunnelClient{ + serverURL: serverURL, + secret: secret, + mtu: mtu, + tun: dev, + statusDir: statusDir, + tlsConfig: tlsConfig, + }, nil +} +``` + +在 `TunnelClient` struct 加字段 `tlsConfig *tls.Config`。 + +在 `connect` 里改 dial: + +```go +conn, _, err := websocket.Dial(ctx, tc.serverURL, &websocket.DialOptions{ + HTTPHeader: header, + HTTPClient: &http.Client{Transport: &http.Transport{TLSClientConfig: tc.tlsConfig}}, +}) +``` + +新增 import `"net/http"`。 + +修改 `internal/client/up.go`,根据 `cfg.InsecureTLS` 构造 tlsConfig: + +```go +func Up(ctx context.Context) error { + cfg, err := LoadClientConfig() + if err != nil { + return fmt.Errorf("not registered; run 'mesh join' first") + } + + wsURL := fmt.Sprintf("wss://%s/tunnel", cfg.ServerDomain) + var tlsCfg *tls.Config + if cfg.InsecureTLS { + tlsCfg = &tls.Config{InsecureSkipVerify: true} + } + tc, err := tunnel.NewTunnelClient(wsURL, cfg.DeviceSecret, cfg.DeviceIP, cfg.NetworkCIDR, 1300, ConfigDir(), tlsCfg) + if err != nil { + return fmt.Errorf("setup tunnel: %w", err) + } + defer tc.Close() + + fmt.Printf("Mesh VPN up (IP: %s)\n", cfg.DeviceIP) + return tc.Run(ctx) +} +``` + +新增 import `"crypto/tls"`。 + +- [ ] **Step 9: 更新 NewTunnelClient 的调用点** + +`NewTunnelClient` 签名加了 `tlsConfig` 参数,全仓调用点需同步。当前唯一调用点是 `internal/client/up.go:19`(已在 Step 8 改过)。跑一次确认无遗漏: + +Run: `grep -rn "NewTunnelClient" --include="*.go" .` +Expected: 只剩定义处(`internal/tunnel/client.go`)和 `up.go` 已带 `tlsCfg` 参数的调用,无其它调用点。若有遗漏,给该调用末尾补 `nil`(测试场景无 TLS)。 + +- [ ] **Step 10: 编译 + 全量测试** + +Run: `go build ./... && go vet ./... && go test ./... -count=1` +Expected: 全部通过。 + +- [ ] **Step 11: 提交** + +```bash +git add internal/ cmd/ +git commit -m "$(cat <<'EOF' +feat(e2e): T00 增加 MESH_TEST_TLS 开关支持自签证书 + +- config 增加 TLSTestMode,由 MESH_TEST_TLS 环境变量控制 +- server 在 test mode 用内存自签证书,不依赖 Let's Encrypt +- client join/up 支持 --insecure 跳过证书校验 +- NewTunnelClient 增加 tlsConfig 参数 + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 1: Docker 镜像与 compose 编排 + +**Files:** +- Create: `tests/e2e/Dockerfile.server` +- Create: `tests/e2e/Dockerfile.client` +- Create: `tests/e2e/docker-compose.yml` +- Create: `tests/e2e/fixtures/meshd.yaml` +- Create: `tests/e2e/fixtures/entrypoint-server.sh` +- Create: `tests/e2e/fixtures/entrypoint-client.sh` + +**背景:** 三个容器需要 root + NET_ADMIN + /dev/net/tun。server 镜像含 meshd,client 镜像含 mesh + iperf3 + tc。 + +- [ ] **Step 1: 写 server Dockerfile** + +`tests/e2e/Dockerfile.server`: + +```dockerfile +FROM golang:1.25-bookworm AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=1 go build -o /out/meshd ./cmd/meshd + +FROM ubuntu:24.04 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates iproute2 iputils-ping curl jq bash && \ + rm -rf /var/lib/apt/lists/* +COPY --from=build /out/meshd /usr/local/bin/meshd +COPY tests/e2e/fixtures/meshd.yaml /etc/mesh/meshd.yaml +COPY tests/e2e/fixtures/entrypoint-server.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] +``` + +- [ ] **Step 2: 写 client Dockerfile** + +`tests/e2e/Dockerfile.client`: + +```dockerfile +FROM golang:1.25-bookworm AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=1 go build -o /out/mesh ./cmd/mesh + +FROM ubuntu:24.04 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates iproute2 iputils-ping iperf3 nuttcp fping curl jq bash && \ + rm -rf /var/lib/apt/lists/* +COPY --from=build /out/mesh /usr/local/bin/mesh +COPY tests/e2e/fixtures/entrypoint-client.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] +``` + +- [ ] **Step 3: 写 server 配置** + +`tests/e2e/fixtures/meshd.yaml`: + +```yaml +domain: "server" +listen_addr: ":443" +network: "10.100.0.0/24" +data_dir: "/etc/mesh" +cert_dir: "/etc/mesh/certs" +tun_name: "mesh0" +tun_mtu: 1300 +``` + +- [ ] **Step 4: 写 server entrypoint** + +`tests/e2e/fixtures/entrypoint-server.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +export MESH_TEST_TLS=off + +meshd init +exec meshd run +``` + +- [ ] **Step 5: 写 client entrypoint(占位,正式 join 逻辑在 T04)** + +`tests/e2e/fixtures/entrypoint-client.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +# 等待 server 起来 +echo "waiting for server..." +for i in $(seq 1 60); do + if curl -kfsS https://server:443/api/devices >/dev/null 2>&1; then + echo "server is up" + break + fi + sleep 1 +done + +# 读取由 run.sh 注入的 TOKEN,注册本设备 +: "${MESH_TOKEN:?MESH_TOKEN must be set}" +HOSTNAME_ID="${HOSTNAME_ID:-$(hostname)}" +mesh join server --token "$MESH_TOKEN" --insecure 1 + +# 启动隧道(前台) +exec mesh up +``` + +- [ ] **Step 6: 写 docker-compose.yml** + +`tests/e2e/docker-compose.yml`: + +```yaml +version: "3.9" +services: + server: + build: + context: ../.. + dockerfile: tests/e2e/Dockerfile.server + image: mesh-e2e/server:dev + container_name: mesh-server + privileged: true + cap_add: [NET_ADMIN, NET_RAW] + networks: + meshnet: + aliases: [server] + + client-a: + build: + context: ../.. + dockerfile: tests/e2e/Dockerfile.client + image: mesh-e2e/client:dev + container_name: mesh-client-a + privileged: true + cap_add: [NET_ADMIN, NET_RAW] + devices: ["/dev/net/tun:/dev/net/tun"] + environment: + - MESH_TOKEN + - HOSTNAME_ID=client-a + depends_on: [server] + networks: [meshnet] + + client-b: + build: + context: ../.. + dockerfile: tests/e2e/Dockerfile.client + image: mesh-e2e/client:dev + container_name: mesh-client-b + privileged: true + cap_add: [NET_ADMIN, NET_RAW] + devices: ["/dev/net/tun:/dev/net/tun"] + environment: + - MESH_TOKEN + - HOSTNAME_ID=client-b + depends_on: [server] + networks: [meshnet] + +networks: + meshnet: + driver: bridge +``` + +- [ ] **Step 7: 验证 server 镜像能构建并启动** + +Run: +```bash +MESH_TOKEN=dummy docker compose -f tests/e2e/docker-compose.yml build server +MESH_TOKEN=dummy docker compose -f tests/e2e/docker-compose.yml up -d server +sleep 5 +docker compose -f tests/e2e/docker-compose.yml logs server | head -20 +``` +Expected: 日志出现 `Mesh VPN server starting` 和 `Initialized.`;`curl -k https://localhost:8443` 在容器内能拿到 cover page(需端口映射,下一步验证用 exec): +```bash +docker exec mesh-server curl -kfsS https://server:443/api/devices +``` +Expected: 返回 `[]`(空设备列表)。 + +收尾:`docker compose -f tests/e2e/docker-compose.yml down`。 + +- [ ] **Step 8: 提交** + +```bash +git add tests/e2e/ +git commit -m "$(cat <<'EOF' +feat(e2e): T01 Docker 镜像与 compose 编排 + +- Dockerfile.server / Dockerfile.client(多阶段构建) +- docker-compose 3 容器拓扑(privileged + NET_ADMIN + /dev/net/tun) +- fixtures: meshd.yaml / entrypoint-server.sh / entrypoint-client.sh + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 2: lib/helpers.sh 与 lib/metrics.sh + +**Files:** +- Create: `tests/e2e/lib/helpers.sh` +- Create: `tests/e2e/lib/metrics.sh` + +**背景:** 场景脚本需要通用函数:容器执行、等待、注册、日志收集、指标采集与 JSON 写入。 + +- [ ] **Step 1: 写 helpers.sh** + +`tests/e2e/lib/helpers.sh`: + +```bash +#!/usr/bin/env bash +# 通用辅助函数。所有场景脚本 source 本文件。 + +# 项目根目录(tests/e2e 的上两级) +E2E_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "$E2E_DIR/../.." && pwd)" +COMPOSE="$E2E_DIR/docker-compose.yml" +RESULTS_DIR="${RESULTS_DIR:-$E2E_DIR/results}" + +# 在指定容器内执行命令 +dex() { docker exec "$@"; } + +# 等 server 容器内 meshd 可达 +wait_for_server() { + echo "waiting for server meshd..." + for i in $(seq 1 60); do + if dex mesh-server curl -kfsS https://server:443/api/devices >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + echo "ERROR: server did not become ready" >&2 + return 1 +} + +# 等 client 容器内 mesh0 起来并有 IP +wait_for_client() { + local c="$1" + for i in $(seq 1 60); do + if dex "$c" ip -o addr show mesh0 2>/dev/null | grep -q inet; then + return 0 + fi + sleep 1 + done + echo "ERROR: client $c mesh0 did not come up" >&2 + return 1 +} + +# 从 server 容器取 register token +get_token() { + dex mesh-server sh -c 'sqlite3 /etc/mesh/mesh.db "SELECT token FROM tokens LIMIT 1" 2>/dev/null || true' + # sqlite3 可能不在 server 镜像里;fallback 用 meshd 启动日志里打印的 token +} + +# 收集所有容器最近日志到 results 目录 +dump_logs() { + local out="$1" + mkdir -p "$out" + for c in mesh-server mesh-client-a mesh-client-b; do + docker logs "$c" >"$out/$c.log" 2>&1 || true + done +} + +# 断言:命令退出码符合预期 +assert() { + local label="$1"; shift + if "$@"; then + echo " [PASS] $label" + echo "1" >>"$ASSERT_OK" + else + echo " [FAIL] $label" + echo "1" >>"$ASSERT_FAIL" + fi +} +``` + +注意 `get_token` 的 sqlite3 路径:server 镜像没装 sqlite3。改方案——在 entrypoint-server.sh 里把 token 写到 `/etc/mesh/token`: + +回 T01 的 `entrypoint-server.sh`,改为: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +export MESH_TEST_TLS=off + +meshd init | tee /tmp/init.log +# meshd init 打印 "Token: xxx",提取写入文件供测试读取 +grep -oP 'Token: \K\S+' /tmp/init.log > /etc/mesh/token 2>/dev/null || true + +exec meshd run +``` + +`get_token` 改为: + +```bash +get_token() { + dex mesh-server cat /etc/mesh/token +} +``` + +- [ ] **Step 2: 写 metrics.sh** + +`tests/e2e/lib/metrics.sh`: + +```bash +#!/usr/bin/env bash +# 指标采集与 JSON 输出。 + +# ping RTT 统计:输出 "p50 p95 p99 loss_pct" +# 用法: rtt_stats +rtt_stats() { + local c="$1" dst="$2" count="${3:-200}" + local tmp; tmp=$(dex "$c" ping -c "$count" -i 0.05 -q "$dst" 2>&1) || true + # 最后一行: rtt min/avg/max/mdev = x/y/z/w ms + local rttline; rttline=$(echo "$tmp" | grep -oP 'rtt min/avg/max/mdev = \K[^ ]+(?= ms)' || true) + local loss; loss=$(echo "$tmp" | grep -oP '\K(\d+)(?=% packet loss)' || echo "100") + # ping -q 不直接给百分位,用 avg/mdev 近似;精确百分位留给 fping + echo "${rttline:-0} ${loss}" +} + +# iperf3 TCP 吞吐(Mbps) +iperf_tcp() { + local client="$1" dst="$2" streams="${3:-1}" time="${4:-10}" + dex "$client" iperf3 -c "$dst" -t "$time" -P "$streams" -J 2>/dev/null \ + | jq -r '.end.sum_received.bits_per_second / 1e6' 2>/dev/null || echo "0" +} + +# iperf3 UDP 丢包率 +iperf_udp_loss() { + local client="$1" dst="$2" rate="${3:-100M}" time="${4:-10}" + dex "$client" iperf3 -c "$dst" -u -b "$rate" -t "$time" -J 2>/dev/null \ + | jq -r '.end.sum.lost_percent // 100' 2>/dev/null || echo "100" +} + +# 往 JSON 结果文件写一个 key/value +json_set() { + local file="$1" key="$2" val="$3" + jq --arg k "$key" --argjson v "$val" '.[$k] = $v' "$file" >"$file.tmp" && mv "$file.tmp" "$file" +} +``` + +- [ ] **Step 3: 提交** + +```bash +git add tests/e2e/lib/ tests/e2e/fixtures/entrypoint-server.sh +git commit -m "$(cat <<'EOF' +feat(e2e): T02 helpers.sh 与 metrics.sh + +- helpers: 容器执行/等待/日志收集/断言 +- metrics: rtt/iperf TCP/iperf UDP 丢包 + JSON 写入 +- entrypoint-server 导出 token 到 /etc/mesh/token + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 3: fixtures/netem.sh(tc netem 预设) + +**Files:** +- Create: `tests/e2e/fixtures/netem.sh` + +**背景:** 场景脚本需要按预设切换链路质量。封装成函数避免重复。 + +- [ ] **Step 1: 写 netem.sh** + +`tests/e2e/fixtures/netem.sh`: + +```bash +#!/usr/bin/env bash +# 在容器内设置 tc netem 预设。用容器内 eth0。 +# 用法: netem (在目标容器内执行) +# preset: clean | baseline | wan | bad | satellite + +netem() { + local preset="${1:-clean}" + case "$preset" in + clean|baseline) + tc qdisc del dev eth0 root 2>/dev/null || true + ;; + wan) + tc qdisc del dev eth0 root 2>/dev/null || true + tc qdisc add dev eth0 root netem delay 80ms 10ms loss 1% + ;; + bad) + tc qdisc del dev eth0 root 2>/dev/null || true + tc qdisc add dev eth0 root netem delay 200ms 50ms loss 5% + ;; + satellite) + tc qdisc del dev eth0 root 2>/dev/null || true + tc qdisc add dev eth0 root netem delay 600ms 100ms loss 2% + ;; + *) + echo "unknown netem preset: $preset" >&2 + return 1 + ;; + esac +} +``` + +- [ ] **Step 2: 把 netem.sh 拷进 client 镜像** + +回 T01 的 `Dockerfile.client`,在 COPY mesh 后加一行: + +```dockerfile +COPY tests/e2e/fixtures/netem.sh /usr/local/bin/netem-preset.sh +RUN chmod +x /usr/local/bin/netem-preset.sh +``` + +(不直接放到 PATH,避免误触发;场景脚本里 `source` 它。) + +- [ ] **Step 3: 手测 netem 能用** + +Run: +```bash +MESH_TOKEN=x docker compose -f tests/e2e/docker-compose.yml run --rm client-a bash -c \ + 'source /usr/local/bin/netem-preset.sh; netem wan; tc qdisc show dev eth0' +``` +Expected: 输出含 `qdisc netem` 与 `delay 80ms`。 + +- [ ] **Step 4: 提交** + +```bash +git add tests/e2e/fixtures/netem.sh tests/e2e/Dockerfile.client +git commit -m "$(cat <<'EOF' +feat(e2e): T03 netem 预设封装(clean/baseline/wan/bad/satellite) + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 4: 场景 01 — 连通性与路由(P0) + +**Files:** +- Create: `tests/e2e/scenarios/01-connectivity.sh` + +**背景:** 验证 TUN 路由注入、跨 client/server ping、不存在 IP 丢包、client 断开/重连。这是 e2e 最核心的正确性证明。 + +- [ ] **Step 1: 写场景脚本** + +`tests/e2e/scenarios/01-connectivity.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../lib/helpers.sh" + +OUT="$RESULTS_DIR/01-connectivity" +mkdir -p "$OUT" +ASSERT_OK="$OUT/ok"; : >"$ASSERT_OK" +ASSERT_FAIL="$OUT/fail"; : >"$ASSERT_FAIL" +LOG="$OUT/01-connectivity.log"; : >"$LOG" +exec > >(tee -a "$LOG") 2>&1 + +echo "=== Scenario 01: connectivity & routing ===" + +wait_for_server +wait_for_client mesh-client-a +wait_for_client mesh-client-b + +SERVER_IP="10.100.0.1" +A_IP=$(dex mesh-client-a ip -o -4 addr show mesh0 | awk '{print $4}' | cut -d/ -f1) +B_IP=$(dex mesh-client-b ip -o -4 addr show mesh0 | awk '{print $4}' | cut -d/ -f1) +echo "client-a=$A_IP client-b=$B_IP" + +# 01.3 路由表:10.100.0.0/24 走 mesh0 +assert "client-a has mesh route" \ + bash -c "dex mesh-client-a ip route | grep -q '10.100.0.0/24.*mesh0'" + +# 01.4 ping server +assert "client-a ping server" \ + dex mesh-client-a ping -c 3 -W 2 "$SERVER_IP" + +# 01.5 ping peer +assert "client-a ping client-b" \ + dex mesh-client-a ping -c 3 -W 2 "$B_IP" + +# 01.6 不存在的 IP 必须 100% 丢包 +NONEXIST=$(dex mesh-client-a ping -c 3 -W 1 10.100.0.99 2>&1 || true) +LOSS=$(echo "$NONEXIST" | grep -oP '\K\d+(?=% packet loss)' || echo 0) +assert "unreachable IP 100% loss" \ + bash -c "[ '$LOSS' -eq 100 ]" + +# 01.7 断开 client-b +docker compose -f "$COMPOSE" stop client-b >/dev/null +sleep 3 +assert "client-b offline: a->b fails" \ + bash -c "! dex mesh-client-a ping -c 3 -W 2 $B_IP 2>/dev/null" + +# 01.8 重启 client-b,恢复 +docker compose -f "$COMPOSE" start client-b >/dev/null +wait_for_client mesh-client-b +assert "client-b reconnect: a->b works" \ + dex mesh-client-a ping -c 3 -W 3 "$B_IP" + +# 01.9 fping 并发不丢 +assert "fping burst no loss" \ + bash -c "dex mesh-client-a fping -q -c 20 -p 100 $B_IP 2>&1 | grep -q '0% loss' || \ + dex mesh-client-a fping -q -c 20 -p 100 $B_IP 2>&1 | grep -oP '\K\d+(?=% loss)' | head -1 | awk '{exit !(\$1<5)}'" + +OK=$(wc -l <"$ASSERT_OK" | tr -d ' ') +FAIL=$(wc -l <"$ASSERT_FAIL" | tr -d ' ') +echo "=== 01 result: PASS=$OK FAIL=$FAIL ===" +jq -n --arg ok "$OK" --arg fail "$FAIL" '{pass:($ok|tonumber), fail:($fail|tonumber)}' >"$OUT/01-connectivity.json" +test "$FAIL" -eq 0 +``` + +- [ ] **Step 2: 跑场景验证** + +Run: +```bash +MESH_TOKEN=$(docker exec mesh-server cat /etc/mesh/token 2>/dev/null || echo x) \ + bash tests/e2e/scenarios/01-connectivity.sh +``` +Expected: 末尾 `01 result: PASS=6 FAIL=0`,退出码 0。 + +- [ ] **Step 3: 提交** + +```bash +git add tests/e2e/scenarios/01-connectivity.sh +git commit -m "$(cat <<'EOF' +feat(e2e): T04 场景 01 连通性与路由 + +- mesh0 路由注入、ping server/peer、不存在 IP 100% 丢包 +- client-b 断开/重连、fping 并发 + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 5: 场景 02 — 性能与抖动(P0) + +**Files:** +- Create: `tests/e2e/scenarios/02-performance.sh` + +**背景:** 量化 TCP 吞吐、UDP 丢包、RTT,在 baseline / wan / bad 三种链路下对比,输出 JSON。 + +- [ ] **Step 1: 写场景脚本** + +`tests/e2e/scenarios/02-performance.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../lib/helpers.sh" +source "$(dirname "$0")/../lib/metrics.sh" + +OUT="$RESULTS_DIR/02-performance" +mkdir -p "$OUT" +JSON="$OUT/02-performance.json" +echo '{}' >"$JSON" +LOG="$OUT/02-performance.log"; : >"$LOG" +exec > >(tee -a "$LOG") 2>&1 + +echo "=== Scenario 02: performance ===" +wait_for_client mesh-client-a +wait_for_client mesh-client-b +B_IP=$(dex mesh-client-b ip -o -4 addr show mesh0 | awk '{print $4}' | cut -d/ -f1) + +# 在 client-b 起一个 iperf3 server(daemon) +dex mesh-client-b sh -c 'pkill iperf3 2>/dev/null; iperf3 -s -D' + +# ---- baseline ---- +dex mesh-client-b sh -c 'source /usr/local/bin/netem-preset.sh; netem baseline' +TCP1=$(iperf_tcp mesh-client-a "$B_IP" 1 10) +json_set "$JSON" tcp_1stream_mbps "$TCP1" +TCP4=$(iperf_tcp mesh-client-a "$B_IP" 4 10) +json_set "$JSON" tcp_4stream_mbps "$TCP4" +UDPL=$(iperf_udp_loss mesh-client-a "$B_IP" 100M 10) +json_set "$JSON" udp_100m_loss_pct "$UDPL" + +# ---- wan ---- +dex mesh-client-b sh -c 'source /usr/local/bin/netem-preset.sh; netem wan' +WAN_TCP=$(iperf_tcp mesh-client-a "$B_IP" 1 10) +json_set "$JSON" wan_tcp_mbps "$WAN_TCP" +WAN_UDP=$(iperf_udp_loss mesh-client-a "$B_IP" 100M 10) +json_set "$JSON" wan_udp_loss_pct "$WAN_UDP" + +# ---- RTT (baseline) ---- +dex mesh-client-b sh -c 'source /usr/local/bin/netem-preset.sh; netem baseline' +RTT=$(rtt_stats mesh-client-a "$B_IP" 200) +AVG=$(echo "$RTT" | awk '{print $1}') +LOSS=$(echo "$RTT" | awk '{print $2}') +json_set "$JSON" rtt_avg_ms "$AVG" +json_set "$JSON" rtt_loss_pct "$LOSS" + +# ---- strict 门槛(可选)---- +if [ "${STRICT:-0}" = "1" ]; then + awk_exit=0 + awk -v tcp="$TCP1" -v udp="$WAN_UDP" ' + BEGIN{ + if (tcp+0 < 30) {print "STRICT FAIL: tcp_1stream="tcp" < 30"; awk_exit=1} + if (udp+0 > 5) {print "STRICT FAIL: wan_udp_loss="udp" > 5"; awk_exit=1} + exit awk_exit + }' +fi + +dex mesh-client-b pkill iperf3 2>/dev/null || true +echo "=== 02 done ===" +cat "$JSON" +``` + +- [ ] **Step 2: 跑场景验证** + +Run: +```bash +bash tests/e2e/scenarios/02-performance.sh +``` +Expected: 输出 JSON 含 `tcp_1stream_mbps`、`wan_tcp_mbps` 等字段,数值非 0。 + +- [ ] **Step 3: 提交** + +```bash +git add tests/e2e/scenarios/02-performance.sh +git commit -m "$(cat <<'EOF' +feat(e2e): T05 场景 02 性能与抖动 + +- iperf3 TCP 1流/4流、UDP 丢包 +- baseline/wan 链路对比 +- RTT 采集,可选 STRICT 硬门槛 + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 6: 场景 03 — 故障 / 重连 / 优雅退出(P1) + +**Files:** +- Create: `tests/e2e/scenarios/03-failure.sh` + +- [ ] **Step 1: 写场景脚本** + +`tests/e2e/scenarios/03-failure.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/../lib/helpers.sh" + +OUT="$RESULTS_DIR/03-failure" +mkdir -p "$OUT" +ASSERT_OK="$OUT/ok"; : >"$ASSERT_OK" +ASSERT_FAIL="$OUT/fail"; : >"$ASSERT_FAIL" +LOG="$OUT/03-failure.log"; : >"$LOG" +exec > >(tee -a "$LOG") 2>&1 + +echo "=== Scenario 03: failure / reconnect / graceful shutdown ===" +wait_for_client mesh-client-a +wait_for_client mesh-client-b +B_IP=$(dex mesh-client-b ip -o -4 addr show mesh0 | awk '{print $4}' | cut -d/ -f1) + +# 03.1 kill server,client 应报错并准备重连 +docker compose -f "$COMPOSE" stop server >/dev/null +sleep 5 +assert "client-a logs reconnect attempt" \ + bash -c "docker logs mesh-client-a 2>&1 | grep -qiE 'connection lost|reconnect'" + +# 03.2 server 重启,client 恢复 +docker compose -f "$COMPOSE" start server >/dev/null +wait_for_server +# client 自动重连(TunnelClient.Run 内 3s 重连) +for i in $(seq 1 60); do + if dex mesh-client-a ping -c 1 -W 2 "$B_IP" >/dev/null 2>&1; then break; fi + sleep 1 +done +assert "server restart: a->b recovers" \ + dex mesh-client-a ping -c 3 -W 3 "$B_IP" + +# 03.3 长流中 server 短暂中断后恢复 +dex mesh-client-b sh -c 'pkill iperf3; iperf3 -s -D' +( dex mesh-client-a iperf3 -c "$B_IP" -t 20 >/dev/null 2>&1 ) & +IPERF_PID=$! +sleep 5 +docker compose -f "$COMPOSE" restart server >/dev/null +wait_for_server +wait $IPERF_PID || true +assert "server restart during iperf: connectivity restored" \ + dex mesh-client-a ping -c 3 -W 3 "$B_IP" + +# 03.6 SIGTERM client 优雅退出(<=2s) +START=$(date +%s) +docker compose -f "$COMPOSE" kill -s SIGTERM client-a >/dev/null +for i in $(seq 1 40); do + state=$(docker inspect -f '{{.State.Status}}' mesh-client-a 2>/dev/null || echo running) + [ "$state" = "exited" ] && break + sleep 0.1 +done +END=$(date +%s) +ELAPSED=$((END - START)) +assert "client SIGTERM exits <=2s" bash -c "[ $ELAPSED -le 2 ]" + +# 重新拉起 client-a 供后续 +docker compose -f "$COMPOSE" start client-a >/dev/null +wait_for_client mesh-client-a + +OK=$(wc -l <"$ASSERT_OK" | tr -d ' ') +FAIL=$(wc -l <"$ASSERT_FAIL" | tr -d ' ') +echo "=== 03 result: PASS=$OK FAIL=$FAIL ===" +jq -n --arg ok "$OK" --arg fail "$FAIL" '{pass:($ok|tonumber), fail:($fail|tonumber)}' >"$OUT/03-failure.json" +test "$FAIL" -eq 0 +``` + +- [ ] **Step 2: 跑场景验证** + +Run: `bash tests/e2e/scenarios/03-failure.sh` +Expected: `03 result: PASS=4 FAIL=0`,退出码 0。 + +- [ ] **Step 3: 提交** + +```bash +git add tests/e2e/scenarios/03-failure.sh +git commit -m "$(cat <<'EOF' +feat(e2e): T06 场景 03 故障/重连/优雅退出 + +- server kill→restart client 自动恢复 +- 长流中 server 重启 +- SIGTERM <=2s 退出 + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 7: run.sh 入口与结果聚合 + +**Files:** +- Create: `tests/e2e/run.sh` + +**背景:** 一键:起容器 → 跑场景 → 收日志 → 生成 summary.txt → 收尾。 + +- [ ] **Step 1: 写 run.sh** + +`tests/e2e/run.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail +E2E_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$E2E_DIR/lib/helpers.sh" + +STRICT="${STRICT:-0}" +SCENARIOS=() + +usage() { echo "Usage: $0 [--all|--quick|--strict|--scenario N]"; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --all) SCENARIOS=(01 02 03);; + --quick) SCENARIOS=(01 02);; # 跳过耗时最长的 03 + --strict) STRICT=1; shift; continue;; + --scenario) SCENARIOS+=("$2"); shift 2;; + *) usage;; + esac + shift || true +done +[ ${#SCENARIOS[@]} -eq 0 ] && SCENARIOS=(01 02 03) + +TS="$(date -u +%Y-%m-%dT%H-%M-%S)" +export RESULTS_DIR="$E2E_DIR/results/$TS" +mkdir -p "$RESULTS_DIR" + +echo "=== e2e run $TS (scenarios: ${SCENARIOS[*]}, strict: $STRICT) ===" + +# 启动容器(需要 MESH_TOKEN) +export MESH_TOKEN="${MESH_TOKEN:-$(uuidgen)}" +docker compose -f "$COMPOSE" up -d --build +trap 'docker compose -f "$COMPOSE" down >/dev/null 2>&1 || true' EXIT + +wait_for_server +# 把真实 token 注入两个 client 容器(entrypoint 已 join) +# 重新读 token 确保 client 用对 +REAL_TOKEN=$(get_token) +docker compose -f "$COMPOSE" stop client-a client-b >/dev/null +MESH_TOKEN="$REAL_TOKEN" docker compose -f "$COMPOSE" start client-a client-b >/dev/null +wait_for_client mesh-client-a +wait_for_client mesh-client-b + +OVERALL=0 +for s in "${SCENARIOS[@]}"; do + echo "--- scenario $s ---" + if ! STRICT="$STRICT" bash "$E2E_DIR/scenarios/${s}-"*.sh; then + OVERALL=1 + fi +done + +dump_logs "$RESULTS_DIR" + +# 聚合 summary +{ + for s in "${SCENARIOS[@]}"; do + json=$(ls "$RESULTS_DIR"/0${s#0}-*/0${s#0}-*.json 2>/dev/null | head -1) + [ -z "$json" ] && json=$(ls "$RESULTS_DIR/${s}"*/*.json 2>/dev/null | head -1) + if [ -n "$json" ]; then + pf=$(jq -r '.pass' "$json"); fl=$(jq -r '.fail' "$json") + st="PASS"; [ "$fl" != "0" ] && st="FAIL" + echo "[${st}] scenario $s: pass=$pf fail=$fl" + fi + done + perf="$RESULTS_DIR/02-performance/02-performance.json" + [ -f "$perf" ] && echo "[PERF] $(jq -r '"tcp=\(.tcp_1stream_mbps)Mbps wan_tcp=\(.wan_tcp_mbps)Mbps rtt_avg=\(.rtt_avg_ms)ms"' "$perf")" + [ "$OVERALL" -eq 0 ] && echo "Overall: PASS" || echo "Overall: FAIL" +} | tee "$RESULTS_DIR/summary.txt" + +exit $OVERALL +``` + +- [ ] **Step 2: 跑完整 e2e** + +Run: `bash tests/e2e/run.sh --all` +Expected: 末尾 `Overall: PASS`,`results//summary.txt` 存在。 + +- [ ] **Step 3: 提交** + +```bash +git add tests/e2e/run.sh +git commit -m "$(cat <<'EOF' +feat(e2e): T07 run.sh 一键入口与结果聚合 + +- 起/收容器、注入 token、跑场景、聚合 summary.txt +- 支持 --all/--quick/--strict/--scenario N +- trap 保证容器 always down + +Co-Authored-By: Claude +EOF +)" +``` + +--- + +## Task 8: CI workflow + +**Files:** +- Create: `.github/workflows/e2e.yml` + +**背景:** push 触发 e2e;失败也要上传 artifact(CodeRabbit 提的 `if: always()`)。 + +- [ ] **Step 1: 写 workflow** + +`.github/workflows/e2e.yml`: + +```yaml +name: e2e +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Build e2e images & run + run: bash tests/e2e/run.sh --quick + - name: Collect report + if: always() + run: | + mkdir -p e2e-report + cp -r tests/e2e/results ./e2e-report/ 2>/dev/null || true + - uses: actions/upload-artifact@v4 + if: always() + with: + name: e2e-report + path: e2e-report/ +``` + +- [ ] **Step 2: 提交** + +```bash +git add .github/workflows/e2e.yml +git commit -m "$(cat <<'EOF' +ci(e2e): T08 GitHub Actions e2e workflow + +- push/PR 触发,--quick 模式 +- if: always() 保证 artifact 上传 + +Co-Authored-By: Claude +EOF +)" +``` + +- [ ] **Step 3: 推送触发 CI 验证** + +Run: `git push -u origin e2e/impl` +打开 PR,确认 CI e2e job 跑通(`Overall: PASS`)。 + +--- + +## 完成标准 + +- [ ] `bash tests/e2e/run.sh --all` 本地 PASS +- [ ] CI `e2e` job 在 PR 上绿 +- [ ] `results//summary.txt` 含 PASS 行 +- [ ] 性能 JSON 含非零吞吐与 RTT +- [ ] T00 的 `MESH_TEST_TLS=off` 在普通单测里也覆盖(不影响生产路径) From 7c75099c9282a93c10144b1acdb6c139cd794140 Mon Sep 17 00:00:00 2001 From: maxyu Date: Wed, 8 Jul 2026 11:30:39 +0800 Subject: [PATCH 02/23] =?UTF-8?q?feat(e2e):=20T00=20=E5=A2=9E=E5=8A=A0=20M?= =?UTF-8?q?ESH=5FTEST=5FTLS=20=E5=BC=80=E5=85=B3=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=87=AA=E7=AD=BE=E8=AF=81=E4=B9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config 增加 TLSTestMode,由 MESH_TEST_TLS 环境变量控制 - server 在 test mode 用内存自签证书,不依赖 Let's Encrypt - client join/up 支持 --insecure 跳过证书校验 - NewTunnelClient 增加 tlsConfig 参数 Co-Authored-By: Claude --- cmd/mesh/main.go | 4 +- internal/api/server.go | 92 ++++++++++++++++++++++++++++------ internal/client/config.go | 1 + internal/client/join.go | 16 +++++- internal/client/up.go | 9 +++- internal/config/config.go | 16 ++++++ internal/config/config_test.go | 16 ++++++ internal/tunnel/client.go | 8 ++- 8 files changed, 142 insertions(+), 20 deletions(-) diff --git a/cmd/mesh/main.go b/cmd/mesh/main.go index 4be0fc1..55fb60a 100644 --- a/cmd/mesh/main.go +++ b/cmd/mesh/main.go @@ -26,12 +26,13 @@ func main() { func joinCmd() *cobra.Command { var tok string + var insecure string cmd := &cobra.Command{ Use: "join ", Args: cobra.ExactArgs(1), Short: "向服务器注册并加入 mesh 网络(无需 root)", RunE: func(c *cobra.Command, args []string) error { - if err := client.Join(args[0], tok); err != nil { + if err := client.Join(args[0], tok, insecure != ""); err != nil { return err } fmt.Println("Now run 'sudo mesh up' to start the tunnel.") @@ -39,6 +40,7 @@ func joinCmd() *cobra.Command { }, } cmd.Flags().StringVar(&tok, "token", "", "注册令牌(由服务器管理员提供)") + cmd.Flags().StringVar(&insecure, "insecure", "", "跳过 TLS 证书校验(仅用于 e2e 测试,任意非空值启用)") _ = cmd.MarkFlagRequired("token") return cmd } diff --git a/internal/api/server.go b/internal/api/server.go index 9fb4248..79b6ddf 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -2,10 +2,18 @@ package api import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" "database/sql" "fmt" + "math/big" + "net" "net/http" + "time" "golang.org/x/crypto/acme/autocert" @@ -51,31 +59,85 @@ func (s *Server) handleTunnel(w http.ResponseWriter, r *http.Request) { s.tunnel.HandleWebSocket(w, r) } -// ListenAndServeTLS starts the HTTPS server using autocert (Let's Encrypt). -// It also starts a :80 listener to serve the HTTP-01 ACME challenge. +// ListenAndServeTLS starts the HTTPS server. +// +// 生产环境使用 autocert(Let's Encrypt)+ :80 ACME challenge listener。 +// 当 cfg.TLSTestMode 为真(即 e2e 测试设置了 MESH_TEST_TLS=off)时, +// 改用内存自签证书,完全不依赖 Let's Encrypt,也不监听 :80。 func (s *Server) ListenAndServeTLS(ctx context.Context) error { - m := &autocert.Manager{ - Cache: autocert.DirCache(s.cfg.CertDir), - Prompt: autocert.AcceptTOS, - HostPolicy: autocert.HostWhitelist(s.cfg.Domain), - } - srv := &http.Server{ Addr: s.cfg.ListenAddr, Handler: s.Handler(), - TLSConfig: &tls.Config{ - GetCertificate: m.GetCertificate, - MinVersion: tls.VersionTLS12, - }, } - // HTTP-01 ACME challenge listener on :80 - go http.ListenAndServe(":80", m.HTTPHandler(nil)) //nolint:errcheck - go func() { <-ctx.Done() srv.Close() //nolint:errcheck }() + if s.cfg.TLSTestMode { + tlsCfg, err := s.selfSignedTLSConfig() + if err != nil { + return fmt.Errorf("self-signed cert: %w", err) + } + srv.TLSConfig = tlsCfg + return srv.ListenAndServeTLS("", "") + } + + // 生产路径:autocert + Let's Encrypt + m := &autocert.Manager{ + Cache: autocert.DirCache(s.cfg.CertDir), + Prompt: autocert.AcceptTOS, + HostPolicy: autocert.HostWhitelist(s.cfg.Domain), + } + srv.TLSConfig = &tls.Config{ + GetCertificate: m.GetCertificate, + MinVersion: tls.VersionTLS12, + } + + // HTTP-01 ACME challenge listener on :80 + go http.ListenAndServe(":80", m.HTTPHandler(nil)) //nolint:errcheck + return srv.ListenAndServeTLS("", "") } + +// selfSignedTLSConfig 生成一份内存中的 ECDSA P-256 自签证书, +// 仅供 e2e 测试使用。NotAfter 24h,IsCA=true, +// DNSNames 含 domain/localhost/server,IPAddresses 含 127.0.0.1。 +func (s *Server) selfSignedTLSConfig() (*tls.Config, error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate key: %w", err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, fmt.Errorf("serial: %w", err) + } + + now := time.Now() + tmpl := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: s.cfg.Domain, + }, + NotBefore: now.Add(-1 * time.Hour), + NotAfter: now.Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + DNSNames: []string{s.cfg.Domain, "localhost", "server"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) + if err != nil { + return nil, fmt.Errorf("create cert: %w", err) + } + + return &tls.Config{ + Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: priv}}, + MinVersion: tls.VersionTLS12, + }, nil +} diff --git a/internal/client/config.go b/internal/client/config.go index 0aee062..c7f7e56 100644 --- a/internal/client/config.go +++ b/internal/client/config.go @@ -14,6 +14,7 @@ type ClientConfig struct { DeviceIP string `json:"device_ip"` DeviceID string `json:"device_id"` NetworkCIDR string `json:"network_cidr"` + InsecureTLS bool `json:"insecure_tls,omitempty"` } // ConfigDir 返回客户端配置目录(~/.mesh)。 diff --git a/internal/client/join.go b/internal/client/join.go index 77ae87f..a9869fa 100644 --- a/internal/client/join.go +++ b/internal/client/join.go @@ -2,15 +2,19 @@ package client import ( "bytes" + "crypto/tls" "encoding/json" "fmt" "net/http" "os" + "time" ) // Join 向服务器注册当前设备,并将返回的配置保存到本地。 // 该操作不需要 root 权限。 -func Join(domain, tok string) error { +// +// insecureTLS 为真时跳过 TLS 证书校验,仅在 e2e 测试(server 使用自签证书)时使用。 +func Join(domain, tok string, insecureTLS bool) error { if err := os.MkdirAll(ConfigDir(), 0700); err != nil { return err } @@ -25,7 +29,14 @@ func Join(domain, tok string) error { }) url := fmt.Sprintf("https://%s/api/devices/register", domain) - resp, err := http.Post(url, "application/json", bytes.NewReader(reqBody)) + httpClient := &http.Client{Timeout: 10 * time.Second} + if insecureTLS { + httpClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } + + resp, err := httpClient.Post(url, "application/json", bytes.NewReader(reqBody)) if err != nil { return fmt.Errorf("connect: %w", err) } @@ -56,6 +67,7 @@ func Join(domain, tok string) error { DeviceIP: regResp.AssignedIP, DeviceID: regResp.DeviceID, NetworkCIDR: regResp.NetworkCIDR, + InsecureTLS: insecureTLS, } if err := SaveClientConfig(cfg); err != nil { return err diff --git a/internal/client/up.go b/internal/client/up.go index d5c3d58..55a64a8 100644 --- a/internal/client/up.go +++ b/internal/client/up.go @@ -2,6 +2,7 @@ package client import ( "context" + "crypto/tls" "fmt" "github.com/maxyu/mesh/internal/tunnel" @@ -16,7 +17,13 @@ func Up(ctx context.Context) error { } wsURL := fmt.Sprintf("wss://%s/tunnel", cfg.ServerDomain) - tc, err := tunnel.NewTunnelClient(wsURL, cfg.DeviceSecret, cfg.DeviceIP, cfg.NetworkCIDR, 1300, ConfigDir()) + + var tlsCfg *tls.Config + if cfg.InsecureTLS { + tlsCfg = &tls.Config{InsecureSkipVerify: true} + } + + tc, err := tunnel.NewTunnelClient(wsURL, cfg.DeviceSecret, cfg.DeviceIP, cfg.NetworkCIDR, 1300, ConfigDir(), tlsCfg) if err != nil { return fmt.Errorf("setup tunnel: %w", err) } diff --git a/internal/config/config.go b/internal/config/config.go index 4f98fae..bec50af 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "strings" "gopkg.in/yaml.v3" ) @@ -15,6 +16,20 @@ type Config struct { CertDir string `yaml:"cert_dir"` TunName string `yaml:"tun_name"` TunMTU int `yaml:"tun_mtu"` + + // TLSTestMode 在 e2e 测试场景启用自签证书,由 MESH_TEST_TLS 环境变量控制。 + // yaml tag "-" 表示该字段不入配置文件,仅供测试开关使用。 + TLSTestMode bool `yaml:"-"` +} + +// applyTestMode 根据环境变量 MESH_TEST_TLS 决定是否启用测试模式(自签证书)。 +// 生产环境不设此变量,行为不受影响。 +func (c *Config) applyTestMode() { + v := strings.ToLower(strings.TrimSpace(os.Getenv("MESH_TEST_TLS"))) + switch v { + case "off", "1", "true", "on", "self": + c.TLSTestMode = true + } } func Default() *Config { @@ -38,5 +53,6 @@ func Load(path string) (*Config, error) { if err := yaml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } + cfg.applyTestMode() return cfg, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e08bc58..f8c8bc2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -25,3 +25,19 @@ func TestLoad(t *testing.T) { t.Fatalf("unexpected: %+v", cfg) } } + +func TestLoadTLSTestModeFromEnv(t *testing.T) { + t.Setenv("MESH_TEST_TLS", "off") + cfg := Default() + cfg.applyTestMode() + if !cfg.TLSTestMode { + t.Fatal("expected TLSTestMode=true when MESH_TEST_TLS=off") + } + + t.Setenv("MESH_TEST_TLS", "") + cfg2 := Default() + cfg2.applyTestMode() + if cfg2.TLSTestMode { + t.Fatal("expected TLSTestMode=false when MESH_TEST_TLS unset") + } +} diff --git a/internal/tunnel/client.go b/internal/tunnel/client.go index 7e90b15..c997272 100644 --- a/internal/tunnel/client.go +++ b/internal/tunnel/client.go @@ -2,6 +2,7 @@ package tunnel import ( "context" + "crypto/tls" "encoding/json" "fmt" "log" @@ -23,6 +24,7 @@ type TunnelClient struct { mtu int tun meshtun.Device statusDir string + tlsConfig *tls.Config connected atomic.Int32 connectedAt atomic.Int64 // unix nano @@ -36,7 +38,9 @@ type TunnelClient struct { // NewTunnelClient creates a TunnelClient, initializes the TUN device, and // configures the network interface. -func NewTunnelClient(serverURL, secret, localIP, network string, mtu int, statusDir string) (*TunnelClient, error) { +// +// tlsConfig 可为 nil(生产环境走系统默认证书校验);e2e 测试传 InsecureSkipVerify 的配置。 +func NewTunnelClient(serverURL, secret, localIP, network string, mtu int, statusDir string, tlsConfig *tls.Config) (*TunnelClient, error) { tunName := meshtun.DefaultTUNName() dev, name, err := meshtun.CreateTUN(tunName, mtu) if err != nil { @@ -52,6 +56,7 @@ func NewTunnelClient(serverURL, secret, localIP, network string, mtu int, status mtu: mtu, tun: dev, statusDir: statusDir, + tlsConfig: tlsConfig, }, nil } @@ -83,6 +88,7 @@ func (tc *TunnelClient) connect(ctx context.Context) error { conn, _, err := websocket.Dial(ctx, tc.serverURL, &websocket.DialOptions{ HTTPHeader: header, + HTTPClient: &http.Client{Transport: &http.Transport{TLSClientConfig: tc.tlsConfig}}, }) if err != nil { return fmt.Errorf("dial: %w", err) From f5bca59f427323c243332852d57d618cd683e512 Mon Sep 17 00:00:00 2001 From: maxyu Date: Wed, 8 Jul 2026 11:52:59 +0800 Subject: [PATCH 03/23] =?UTF-8?q?fix(e2e):=20T00=20code=20review=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default() 调 applyTestMode,配置回退路径也生效 - --insecure 改 bool flag,避免字符串陷阱 - NewTunnelClient 复用 http.Client,防止重连 fd 泄漏 - config test 补边界值(true/self/no/大小写/空格) - 补 join_test.go(insecure 传输与持久化) - peers.go 改用 cfg.InsecureTLS,修生产环境无条件跳过 TLS 校验的安全问题 Co-Authored-By: Claude --- cmd/mesh/main.go | 6 +- docs/superpowers/plans/2026-07-08-e2e-impl.md | 2 +- internal/client/join_test.go | 57 +++++++++++++++++++ internal/client/peers.go | 8 ++- internal/config/config.go | 7 ++- internal/config/config_test.go | 34 +++++++---- internal/tunnel/client.go | 17 +++--- 7 files changed, 105 insertions(+), 26 deletions(-) create mode 100644 internal/client/join_test.go diff --git a/cmd/mesh/main.go b/cmd/mesh/main.go index 55fb60a..2d2e4de 100644 --- a/cmd/mesh/main.go +++ b/cmd/mesh/main.go @@ -26,13 +26,13 @@ func main() { func joinCmd() *cobra.Command { var tok string - var insecure string + var insecure bool cmd := &cobra.Command{ Use: "join ", Args: cobra.ExactArgs(1), Short: "向服务器注册并加入 mesh 网络(无需 root)", RunE: func(c *cobra.Command, args []string) error { - if err := client.Join(args[0], tok, insecure != ""); err != nil { + if err := client.Join(args[0], tok, insecure); err != nil { return err } fmt.Println("Now run 'sudo mesh up' to start the tunnel.") @@ -40,7 +40,7 @@ func joinCmd() *cobra.Command { }, } cmd.Flags().StringVar(&tok, "token", "", "注册令牌(由服务器管理员提供)") - cmd.Flags().StringVar(&insecure, "insecure", "", "跳过 TLS 证书校验(仅用于 e2e 测试,任意非空值启用)") + cmd.Flags().BoolVar(&insecure, "insecure", false, "跳过 TLS 证书校验(仅用于 e2e 测试)") _ = cmd.MarkFlagRequired("token") return cmd } diff --git a/docs/superpowers/plans/2026-07-08-e2e-impl.md b/docs/superpowers/plans/2026-07-08-e2e-impl.md index 005fe9a..1ba9b3a 100644 --- a/docs/superpowers/plans/2026-07-08-e2e-impl.md +++ b/docs/superpowers/plans/2026-07-08-e2e-impl.md @@ -504,7 +504,7 @@ done # 读取由 run.sh 注入的 TOKEN,注册本设备 : "${MESH_TOKEN:?MESH_TOKEN must be set}" HOSTNAME_ID="${HOSTNAME_ID:-$(hostname)}" -mesh join server --token "$MESH_TOKEN" --insecure 1 +mesh join server --token "$MESH_TOKEN" --insecure # 启动隧道(前台) exec mesh up diff --git a/internal/client/join_test.go b/internal/client/join_test.go new file mode 100644 index 0000000..c2b16d6 --- /dev/null +++ b/internal/client/join_test.go @@ -0,0 +1,57 @@ +package client + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// registerHandler 返回一个固定的注册成功响应。 +func registerHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"assigned_ip":"10.100.0.5","device_secret":"s","device_id":"d1","network_cidr":"10.100.0.0/24"}`) +} + +// TestJoinInsecureTLS 覆盖 Join 的 insecure 分支: +// - insecure=true 能连上自签 TLS server(间接证明设置了 InsecureSkipVerify), +// 并把 InsecureTLS=true 持久化到 config。 +// - insecure=false 面对自签证书应当连接失败且不保存 config(证明未跳过校验, +// 生产路径安全)。 +// +// 用 t.Setenv("HOME", ...) 隔离 ConfigDir,避免污染本机 ~/.mesh/config.json。 +func TestJoinInsecureTLS(t *testing.T) { + // httptest.NewTLSServer 使用自签证书,默认客户端校验会失败。 + mux := http.NewServeMux() + mux.HandleFunc("POST /api/devices/register", registerHandler) + srv := httptest.NewTLSServer(mux) + defer srv.Close() + + domain := strings.TrimPrefix(srv.URL, "https://") + + t.Run("insecure_true_connects_and_persists", func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if err := Join(domain, "tok", true); err != nil { + t.Fatalf("Join insecure=true failed: %v", err) + } + cfg, err := LoadClientConfig() + if err != nil { + t.Fatalf("LoadClientConfig: %v", err) + } + if !cfg.InsecureTLS { + t.Fatal("expected InsecureTLS=true persisted in config") + } + }) + + t.Run("insecure_false_rejects_self_signed", func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + // 自签证书 + insecure=false:连接应因证书校验失败而失败,且不保存 config。 + if err := Join(domain, "tok", false); err == nil { + t.Fatal("expected Join to fail against self-signed server when insecure=false") + } + if _, err := LoadClientConfig(); err == nil { + t.Fatal("expected no config saved when Join fails on cert verification") + } + }) +} diff --git a/internal/client/peers.go b/internal/client/peers.go index 4d3c74d..b74ddd4 100644 --- a/internal/client/peers.go +++ b/internal/client/peers.go @@ -23,9 +23,15 @@ func Peers() error { return fmt.Errorf("not registered; run 'mesh join' first") } + // 仅在 e2e 测试模式(join 时带 --insecure)下跳过证书校验, + // 生产环境必须正常校验 Let's Encrypt 证书。 + tlsCfg := &tls.Config{} + if cfg.InsecureTLS { + tlsCfg.InsecureSkipVerify = true + } client := &http.Client{ Timeout: 5 * time.Second, - Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, + Transport: &http.Transport{TLSClientConfig: tlsCfg}, } resp, err := client.Get(fmt.Sprintf("https://%s/api/devices", cfg.ServerDomain)) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index bec50af..0bb0689 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,7 +33,7 @@ func (c *Config) applyTestMode() { } func Default() *Config { - return &Config{ + cfg := &Config{ Domain: "localhost", ListenAddr: ":443", Network: "10.100.0.0/24", @@ -42,6 +42,10 @@ func Default() *Config { TunName: "mesh0", TunMTU: 1300, } + // 在这里调用,确保所有获取 Config 的路径(Default 直用、Load 成功、Load 失败回退) + // 都能读取 MESH_TEST_TLS 环境变量,e2e 测试模式下不会误走 Let's Encrypt。 + cfg.applyTestMode() + return cfg } func Load(path string) (*Config, error) { @@ -53,6 +57,5 @@ func Load(path string) (*Config, error) { if err := yaml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } - cfg.applyTestMode() return cfg, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f8c8bc2..908f556 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -27,17 +27,29 @@ func TestLoad(t *testing.T) { } func TestLoadTLSTestModeFromEnv(t *testing.T) { - t.Setenv("MESH_TEST_TLS", "off") - cfg := Default() - cfg.applyTestMode() - if !cfg.TLSTestMode { - t.Fatal("expected TLSTestMode=true when MESH_TEST_TLS=off") + cases := []struct { + name string + env string + want bool + }{ + {"off", "off", true}, + {"empty", "", false}, + {"true", "true", true}, + {"self", "self", true}, + {"no_invalid", "no", false}, + {"uppercase_off", "OFF", true}, + {"trim_spaces", " off ", true}, + {"on", "on", true}, + {"1", "1", true}, + {"garbage", "garbage", false}, } - - t.Setenv("MESH_TEST_TLS", "") - cfg2 := Default() - cfg2.applyTestMode() - if cfg2.TLSTestMode { - t.Fatal("expected TLSTestMode=false when MESH_TEST_TLS unset") + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("MESH_TEST_TLS", tc.env) + cfg := Default() + if cfg.TLSTestMode != tc.want { + t.Fatalf("MESH_TEST_TLS=%q: expected TLSTestMode=%v, got %v", tc.env, tc.want, cfg.TLSTestMode) + } + }) } } diff --git a/internal/tunnel/client.go b/internal/tunnel/client.go index c997272..f5bd2e6 100644 --- a/internal/tunnel/client.go +++ b/internal/tunnel/client.go @@ -19,12 +19,12 @@ import ( // TunnelClient connects to the mesh VPN server via WebSocket and shuttles // IP packets through a local TUN device. type TunnelClient struct { - serverURL string - secret string - mtu int - tun meshtun.Device - statusDir string - tlsConfig *tls.Config + serverURL string + secret string + mtu int + tun meshtun.Device + statusDir string + httpClient *http.Client connected atomic.Int32 connectedAt atomic.Int64 // unix nano @@ -56,7 +56,8 @@ func NewTunnelClient(serverURL, secret, localIP, network string, mtu int, status mtu: mtu, tun: dev, statusDir: statusDir, - tlsConfig: tlsConfig, + // 复用单个 http.Client,避免每次重连新建 Transport 造成空闲连接/fd 泄漏。 + httpClient: &http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}}, }, nil } @@ -88,7 +89,7 @@ func (tc *TunnelClient) connect(ctx context.Context) error { conn, _, err := websocket.Dial(ctx, tc.serverURL, &websocket.DialOptions{ HTTPHeader: header, - HTTPClient: &http.Client{Transport: &http.Transport{TLSClientConfig: tc.tlsConfig}}, + HTTPClient: tc.httpClient, }) if err != nil { return fmt.Errorf("dial: %w", err) From 4d25e24334b0d26f574e69b1c8b4ff8568d0448f Mon Sep 17 00:00:00 2001 From: maxyu Date: Wed, 8 Jul 2026 12:22:37 +0800 Subject: [PATCH 04/23] =?UTF-8?q?feat(e2e):=20T01=20Docker=20=E9=95=9C?= =?UTF-8?q?=E5=83=8F=E4=B8=8E=20compose=20=E7=BC=96=E6=8E=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile.server / Dockerfile.client(多阶段构建,golang:1.25) - docker-compose 3 容器(privileged + NET_ADMIN + /dev/net/tun) - fixtures: meshd.yaml / entrypoint-server.sh(含 token 提取)/ entrypoint-client.sh Co-Authored-By: Claude --- tests/e2e/Dockerfile.client | 45 ++++++++++++++++ tests/e2e/Dockerfile.server | 45 ++++++++++++++++ tests/e2e/docker-compose.yml | 72 +++++++++++++++++++++++++ tests/e2e/fixtures/entrypoint-client.sh | 20 +++++++ tests/e2e/fixtures/entrypoint-server.sh | 13 +++++ tests/e2e/fixtures/meshd.yaml | 7 +++ 6 files changed, 202 insertions(+) create mode 100644 tests/e2e/Dockerfile.client create mode 100644 tests/e2e/Dockerfile.server create mode 100644 tests/e2e/docker-compose.yml create mode 100755 tests/e2e/fixtures/entrypoint-client.sh create mode 100755 tests/e2e/fixtures/entrypoint-server.sh create mode 100644 tests/e2e/fixtures/meshd.yaml diff --git a/tests/e2e/Dockerfile.client b/tests/e2e/Dockerfile.client new file mode 100644 index 0000000..19fc60a --- /dev/null +++ b/tests/e2e/Dockerfile.client @@ -0,0 +1,45 @@ +# syntax=docker/dockerfile:1 +# T01: mesh 客户端镜像(多阶段构建) +# build context 为仓库根(compose 里 context: ../..) + +######################################## +# Stage 1: 构建 mesh 二进制 +######################################## +FROM golang:1.25-bookworm AS builder + +WORKDIR /src + +# 先拷依赖以利用 Docker 层缓存 +COPY go.mod go.sum ./ +RUN go mod download + +# 拷源码并编译 +COPY . . +RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/mesh ./cmd/mesh + +######################################## +# Stage 2: 运行时 +######################################## +FROM ubuntu:24.04 + +# 装运行时依赖 + 性能测试工具(iperf3 / nuttcp / fping) +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + iproute2 \ + iputils-ping \ + iperf3 \ + nuttcp \ + fping \ + curl \ + jq \ + bash \ + && rm -rf /var/lib/apt/lists/* + +# 拷二进制 +COPY --from=builder /out/mesh /usr/local/bin/mesh + +# 拷 entrypoint(路径相对仓库根,即 build context) +COPY tests/e2e/fixtures/entrypoint-client.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/tests/e2e/Dockerfile.server b/tests/e2e/Dockerfile.server new file mode 100644 index 0000000..657870a --- /dev/null +++ b/tests/e2e/Dockerfile.server @@ -0,0 +1,45 @@ +# syntax=docker/dockerfile:1 +# T01: meshd 服务端镜像(多阶段构建) +# build context 为仓库根(compose 里 context: ../..) + +######################################## +# Stage 1: 构建 meshd 二进制 +######################################## +FROM golang:1.25-bookworm AS builder + +WORKDIR /src + +# 先拷依赖以利用 Docker 层缓存 +COPY go.mod go.sum ./ +RUN go mod download + +# 拷源码并编译(CGO 保留 modernc.org/sqlite 兼容性,bookworm 自带 gcc) +COPY . . +RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/meshd ./cmd/meshd + +######################################## +# Stage 2: 运行时 +######################################## +FROM ubuntu:24.04 + +# 装运行时依赖:ca-certificates(TLS)、iproute2/iputils-ping/curl(排障)、 +# jq(解析 JSON)、bash、sqlite3(测试读 token 的备选) +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + iproute2 \ + iputils-ping \ + curl \ + jq \ + bash \ + sqlite3 \ + && rm -rf /var/lib/apt/lists/* + +# 拷二进制 +COPY --from=builder /out/meshd /usr/local/bin/meshd + +# 拷配置、entrypoint(路径相对仓库根,即 build context) +COPY tests/e2e/fixtures/meshd.yaml /etc/mesh/meshd.yaml +COPY tests/e2e/fixtures/entrypoint-server.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/tests/e2e/docker-compose.yml b/tests/e2e/docker-compose.yml new file mode 100644 index 0000000..a6564f3 --- /dev/null +++ b/tests/e2e/docker-compose.yml @@ -0,0 +1,72 @@ +# T01: mesh VPN e2e 测试编排 +# 3 容器:server / client-a / client-b +# 全部 privileged + NET_ADMIN/NET_RAW + /dev/net/tun(server 也起 TUN 做中继) +# +# 用法: +# MESH_TOKEN= docker compose -f tests/e2e/docker-compose.yml up -d +# docker compose -f tests/e2e/docker-compose.yml down + +services: + server: + build: + context: ../.. + dockerfile: tests/e2e/Dockerfile.server + image: mesh-e2e/server:latest + container_name: mesh-server + privileged: true + cap_add: + - NET_ADMIN + - NET_RAW + devices: + - /dev/net/tun:/dev/net/tun + networks: + meshnet: + aliases: + - server + # 暴露 443 供 host 排障(可选) + ports: + - "8443:443" + + client-a: + build: + context: ../.. + dockerfile: tests/e2e/Dockerfile.client + image: mesh-e2e/client:latest + container_name: mesh-client-a + privileged: true + cap_add: + - NET_ADMIN + - NET_RAW + devices: + - /dev/net/tun:/dev/net/tun + depends_on: + - server + environment: + MESH_TOKEN: ${MESH_TOKEN:?MESH_TOKEN must be set} + HOSTNAME_ID: client-a + networks: + - meshnet + + client-b: + build: + context: ../.. + dockerfile: tests/e2e/Dockerfile.client + image: mesh-e2e/client:latest + container_name: mesh-client-b + privileged: true + cap_add: + - NET_ADMIN + - NET_RAW + devices: + - /dev/net/tun:/dev/net/tun + depends_on: + - server + environment: + MESH_TOKEN: ${MESH_TOKEN:?MESH_TOKEN must be set} + HOSTNAME_ID: client-b + networks: + - meshnet + +networks: + meshnet: + driver: bridge diff --git a/tests/e2e/fixtures/entrypoint-client.sh b/tests/e2e/fixtures/entrypoint-client.sh new file mode 100755 index 0000000..7b224f5 --- /dev/null +++ b/tests/e2e/fixtures/entrypoint-client.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# T01: mesh 客户端 entrypoint +# 等待 server 就绪 -> join -> up(前台) +set -euo pipefail + +# 等待 server 起来(自签证书,curl 必须 -k) +echo "waiting for server..." +for i in $(seq 1 60); do + if curl -kfsS https://server:443/api/devices >/dev/null 2>&1; then + echo "server is up" + break + fi + sleep 1 +done + +: "${MESH_TOKEN:?MESH_TOKEN must be set}" +mesh join server --token "$MESH_TOKEN" --insecure + +# 启动隧道(前台) +exec mesh up diff --git a/tests/e2e/fixtures/entrypoint-server.sh b/tests/e2e/fixtures/entrypoint-server.sh new file mode 100755 index 0000000..3f578b1 --- /dev/null +++ b/tests/e2e/fixtures/entrypoint-server.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# T01: meshd 服务端 entrypoint +# 自签证书模式(MESH_TEST_TLS=off),init 后提取 token 供 e2e 测试读取 +set -euo pipefail + +export MESH_TEST_TLS=off + +meshd init 2>&1 | tee /tmp/init.log +# meshd init 输出 "Initialized.\nToken: xxx",提取 token 写入文件供 e2e 读取 +# 用 sed 比 grep -oP 更可移植(不依赖 PCRE) +sed -n 's/^Token: //p' /tmp/init.log > /etc/mesh/token 2>/dev/null || true + +exec meshd run diff --git a/tests/e2e/fixtures/meshd.yaml b/tests/e2e/fixtures/meshd.yaml new file mode 100644 index 0000000..a2277f0 --- /dev/null +++ b/tests/e2e/fixtures/meshd.yaml @@ -0,0 +1,7 @@ +domain: "server" +listen_addr: ":443" +network: "10.100.0.0/24" +data_dir: "/etc/mesh" +cert_dir: "/etc/mesh/certs" +tun_name: "mesh0" +tun_mtu: 1300 From c29b9a321fb04736608e80db8cb520d37637750e Mon Sep 17 00:00:00 2001 From: maxyu Date: Wed, 8 Jul 2026 12:31:36 +0800 Subject: [PATCH 05/23] =?UTF-8?q?fix(e2e):=20T01=20code=20review=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - client 等 server 超时后 exit 1,避免混淆报错 - compose 加 hostname: client-a/client-b,注册名可读 - 加 .dockerignore,减小 build context - server token 提取失败报错退出 - 去掉 server 镜像未用的 sqlite3 Co-Authored-By: Claude --- .dockerignore | 11 +++++++++++ tests/e2e/Dockerfile.server | 3 +-- tests/e2e/docker-compose.yml | 4 ++-- tests/e2e/fixtures/entrypoint-client.sh | 4 ++++ tests/e2e/fixtures/entrypoint-server.sh | 4 ++++ 5 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fcee75a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# Docker build context 排除(T01 code review I-3) +# 两个 Dockerfile 都用 context: ../..(仓库根),这里裁掉无关内容 +# 必须保留进 context:internal/ cmd/ go.mod go.sum tests/e2e/fixtures/ +.git/ +bin/ +docs/ +.firecrawl/ +graphify-out/ +*.md +.DS_Store +.claude/ diff --git a/tests/e2e/Dockerfile.server b/tests/e2e/Dockerfile.server index 657870a..f30cce4 100644 --- a/tests/e2e/Dockerfile.server +++ b/tests/e2e/Dockerfile.server @@ -23,7 +23,7 @@ RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/meshd ./cmd/meshd FROM ubuntu:24.04 # 装运行时依赖:ca-certificates(TLS)、iproute2/iputils-ping/curl(排障)、 -# jq(解析 JSON)、bash、sqlite3(测试读 token 的备选) +# jq(解析 JSON)、bash RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ iproute2 \ @@ -31,7 +31,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ jq \ bash \ - sqlite3 \ && rm -rf /var/lib/apt/lists/* # 拷二进制 diff --git a/tests/e2e/docker-compose.yml b/tests/e2e/docker-compose.yml index a6564f3..785bc99 100644 --- a/tests/e2e/docker-compose.yml +++ b/tests/e2e/docker-compose.yml @@ -33,6 +33,7 @@ services: dockerfile: tests/e2e/Dockerfile.client image: mesh-e2e/client:latest container_name: mesh-client-a + hostname: client-a privileged: true cap_add: - NET_ADMIN @@ -43,7 +44,6 @@ services: - server environment: MESH_TOKEN: ${MESH_TOKEN:?MESH_TOKEN must be set} - HOSTNAME_ID: client-a networks: - meshnet @@ -53,6 +53,7 @@ services: dockerfile: tests/e2e/Dockerfile.client image: mesh-e2e/client:latest container_name: mesh-client-b + hostname: client-b privileged: true cap_add: - NET_ADMIN @@ -63,7 +64,6 @@ services: - server environment: MESH_TOKEN: ${MESH_TOKEN:?MESH_TOKEN must be set} - HOSTNAME_ID: client-b networks: - meshnet diff --git a/tests/e2e/fixtures/entrypoint-client.sh b/tests/e2e/fixtures/entrypoint-client.sh index 7b224f5..70b63e8 100755 --- a/tests/e2e/fixtures/entrypoint-client.sh +++ b/tests/e2e/fixtures/entrypoint-client.sh @@ -12,6 +12,10 @@ for i in $(seq 1 60); do fi sleep 1 done +if ! curl -kfsS https://server:443/api/devices >/dev/null 2>&1; then + echo "ERROR: server did not become ready within 60s" >&2 + exit 1 +fi : "${MESH_TOKEN:?MESH_TOKEN must be set}" mesh join server --token "$MESH_TOKEN" --insecure diff --git a/tests/e2e/fixtures/entrypoint-server.sh b/tests/e2e/fixtures/entrypoint-server.sh index 3f578b1..eced602 100755 --- a/tests/e2e/fixtures/entrypoint-server.sh +++ b/tests/e2e/fixtures/entrypoint-server.sh @@ -9,5 +9,9 @@ meshd init 2>&1 | tee /tmp/init.log # meshd init 输出 "Initialized.\nToken: xxx",提取 token 写入文件供 e2e 读取 # 用 sed 比 grep -oP 更可移植(不依赖 PCRE) sed -n 's/^Token: //p' /tmp/init.log > /etc/mesh/token 2>/dev/null || true +if [ ! -s /etc/mesh/token ]; then + echo "ERROR: failed to extract token from init output" >&2 + exit 1 +fi exec meshd run From 59809d7d62be3e32042eb7460e17b3ddae2b1988 Mon Sep 17 00:00:00 2001 From: maxyu Date: Wed, 8 Jul 2026 12:36:48 +0800 Subject: [PATCH 06/23] =?UTF-8?q?feat(e2e):=20T02=20helpers.sh=20=E4=B8=8E?= =?UTF-8?q?=20metrics.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - helpers: dex/wait_for_server/wait_for_client/get_token/dump_logs/assert - metrics: rtt_stats/iperf_tcp/iperf_udp_loss/json_set Co-Authored-By: Claude --- tests/e2e/lib/helpers.sh | 85 ++++++++++++++++++++++++++++++++++++++++ tests/e2e/lib/metrics.sh | 49 +++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 tests/e2e/lib/helpers.sh create mode 100644 tests/e2e/lib/metrics.sh diff --git a/tests/e2e/lib/helpers.sh b/tests/e2e/lib/helpers.sh new file mode 100644 index 0000000..f819cf2 --- /dev/null +++ b/tests/e2e/lib/helpers.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# 通用辅助函数。所有场景脚本 source 本文件。 +# +# 提供: +# - dex : 在指定容器内执行命令 +# - wait_for_server : 等 server 容器内 meshd 可达(自签,curl -k) +# - wait_for_client : 等 client 容器内 mesh0 起来并有 IPv4 +# - get_token : 从 server 容器读 /etc/mesh/token(T01 entrypoint 已写入) +# - dump_logs : 收集所有容器最近日志到 results 目录 +# - assert : 断言命令退出码符合预期,写 ok/fail 计数 + +# 项目根目录(本文件位于 tests/e2e/lib/,往上两级到 e2e 目录,再往上两级到仓库根) +E2E_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "$E2E_DIR/../.." && pwd)" +COMPOSE="$E2E_DIR/docker-compose.yml" +RESULTS_DIR="${RESULTS_DIR:-$E2E_DIR/results}" + +# 断言计数文件(assert 函数使用)。调用方应在场景脚本开头初始化: +# ASSERT_OK="$RESULTS_DIR/.ok"; ASSERT_FAIL="$RESULTS_DIR/.fail" +# 未设置时给个默认,避免 set -u 报错。 +ASSERT_OK="${ASSERT_OK:-/tmp/mesh-e2e-ok.$$}" +ASSERT_FAIL="${ASSERT_FAIL:-/tmp/mesh-e2e-fail.$$}" + +# 在指定容器内执行命令 +dex() { docker exec "$@"; } + +# 等 server 容器内 meshd 可达(自签,curl -k) +# 用 server 的 compose 网络 alias "server" 访问,避免依赖 host 端口映射。 +wait_for_server() { + echo "waiting for server meshd..." + local i + for i in $(seq 1 60); do + if dex mesh-server curl -kfsS https://server:443/api/devices >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + echo "ERROR: server did not become ready" >&2 + return 1 +} + +# 等 client 容器内 mesh0 起来并有 IPv4 +# 用法: wait_for_client +wait_for_client() { + local c="$1" + local i + for i in $(seq 1 60); do + if dex "$c" ip -o addr show mesh0 2>/dev/null | grep -q inet; then + return 0 + fi + sleep 1 + done + echo "ERROR: client $c mesh0 did not come up" >&2 + return 1 +} + +# 从 server 容器读 /etc/mesh/token(T01 entrypoint 已写入) +get_token() { + dex mesh-server cat /etc/mesh/token +} + +# 收集所有容器最近日志到 results 目录 +# 用法: dump_logs +dump_logs() { + local out="$1" + mkdir -p "$out" + local c + for c in mesh-server mesh-client-a mesh-client-b; do + docker logs "$c" >"$out/$c.log" 2>&1 || true + done +} + +# 断言:命令退出码符合预期。写 ok/fail 计数到 $ASSERT_OK/$ASSERT_FAIL 文件 +# 用法: assert "