Files
gost-x/docs/superpowers/plans/2026-10-07-tun-device-probe.md
T

9.9 KiB
Raw Blame History

Tun device 写侧探针(x 侧)Implementation Plan

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: x tun handler 在 client 模式下每 30s 往 device 写一个自回环 UDP 探针, 把发送/确认计数交给调用方(wisper 经 metadata 回调收数),读环路零改动。

Architecture: 新文件 probe.go(组包纯函数 + 探针循环,与 keepalive 同款 per-dial-iteration 生命周期);x/observer/stats 加两个计数器(加法字段, core 不动);metadata 加 probe 开关(缺省关,gost 零行为变化)。

Tech Stack: Go, golang.org/x/net/ipv4(已在用)组包,UDP checksum 置零。

Spec: wisper/docs/superpowers/specs/2026-10-07-wisper-tun-probe-design.md (§1/§2 是本 plan 的依据;本 plan 只覆盖 x 侧,wisper 侧见 Plan B wisper/docs/superpowers/plans/2026-10-07-tun-probe-watchdog.md)

Global Constraints

  • core/ 一行不碰(Kind 常量在 x 内定义,见 Task 1 精确值)。
  • 缺省关闭:metadata 无 probe 键时不建 socket、不起 goroutine、不写计数 (gost 路径零变化)。
  • 本 plan 的每一步都不 commit/不 push(仓库约定 no-auto-commit); 每个 Task 结束时工作区只留该 Task 的文件待审。
  • 发版(Task 4 的 tag+push)由 maintainer 手动做,执行人不碰。

Review Focus

  • 探针包绝不能离开本机:dst 必须是自绑 socket 的地址——Task 2 的测试 pin 死。
  • IPv6-only 设备:探针禁用记 warn,不 crash——Task 2 的测试 pin 死。
  • IP 头 checksum 必须正确(内核静默丢坏包)——Task 2 的测试用解析器验。
  • probeReport 为 nil(gost/旧调用方):调用前判空——Task 3 的测试 pin 死。
  • 重拨时旧探针必须退出(per-iteration ctx),且双 writer 并发写 tun fd 是安全的 (datagram 原子,不加锁)——Task 3 接线时保持 keepalive 同款模式。

Task 1: metadata 开关 + 探针计数器

Files:

  • Modify: x/handler/tun/metadata.go
  • Modify: x/observer/stats/stats.go
  • Test: x/handler/tun/metadata_test.go(若无则新建;先 ls 确认)、 x/observer/stats/stats_test.go(追加)

Interfaces:

  • Consumes: mdutil.GetBool 现有模式(metadata.go:23,31)。

  • Produces: metadata.probe bool、metadata.probeReport func(sentDelta, ackedDelta uint64); xstats.KindProbeSent stats.Kind = 101、xstats.KindProbeAcked stats.Kind = 102 (101/102:core 用 1–5,x 内无其他自定义 Kind,测试用 999 避开); Stats.probeSent/probeAcked atomic.Uint64 + Add/Get 的 case (Task 2/3 用 h.options 之外的 stats.Stats 句柄传参,签名见各 Task)。

  • Step 1: 写 failing test(metadata)

func TestParseMetadataProbeDefaultOff(t *testing.T) {
    h := &tunHandler{}
    if err := h.parseMetadata(mdx.NewMetadata(map[string]any{})); err != nil {
        t.Fatal(err)
    }
    if h.md.probe {
        t.Fatal("probe defaults off")
    }
}

(mdx 是 x/metadata 下 metadata 构造的包名,先 grep 确认,tun 包内已有用法。)

  • Step 2: 运行,确认 FAIL(h.md.probe 未定义)

Run: go test ./handler/tun/ -run TestParseMetadataProbe -v Expected: 编译失败。

  • Step 3: 实现 metadata 加两个字段 + parseMetadata 解析

probe = mdutil.GetBool(md, "tun.probe", "probe"); probeReport 从 md.Get("tun.probeReport") 取不到则试 md.Get("probeReport"), 类型断言 func(uint64, uint64),失败留 nil(不断言 panic)。

  • Step 4: 写 failing test(Stats 计数器)
func TestStatsProbeKinds(t *testing.T) {
    s := NewStats(false)
    s.Add(KindProbeSent, 1)
    s.Add(KindProbeAcked, 1)
    if s.Get(KindProbeSent) != 1 || s.Get(KindProbeAcked) != 1 {
        t.Fatal("probe counters not tracked")
    }
}
  • Step 5: 运行,确认 FAIL

Run: go test ./observer/stats/ -run TestStatsProbeKinds -v Expected: FAIL(未知 Kind 被 switch 吞掉,读回 0)。

  • Step 6: 实现 Kind 常量 + Stats 字段 + Add/Get case

  • Step 7: 补 metadata 的 probe:true 解析测试 + report-func 透传测试, 跑全包

Run: go test -count=1 ./handler/tun/ ./observer/stats/ Expected: PASS。


Task 2: 探针组包 + 循环

Files:

  • Create: x/handler/tun/probe.go
  • Test: x/handler/tun/probe_test.go

Interfaces:

  • Consumes: Task 1 的 metadata.probe(调用方判断,本文件不管开关)。
  • Produces: probeInterval = 30 * time.Second、probeRecvTimeout = 5 * time.Second、 probeMagic = "WSPROBE!"(8 字节); func buildProbePacket(src net.IP, dstPort uint16, seq uint64) []byte; func runDeviceProbe(ctx context.Context, dev io.Writer, ip net.IP, report func(sentDelta, ackedDelta uint64), log logger.Logger)。

包格式(IPv4 头 20B + UDP 头 8B + magic 8B + seq 8B = 44B): src == dst == ip(第一个 v4 spoke 地址),UDP checksum 置零(IPv4 下 0 = 无校验, 内核接受),IP 头 checksum 必须正确计算(标准 internet checksum, 全零和取反——这是唯一需要手写的算法,其他照抄 ipv4.Header.Marshal 或手排字节均可, 测试会验)。

循环语义:每 tick 发一次(report(1,0) 先记 sent,write 报错也记——acked 停滞 本身就是信号);SetReadDeadline(now+5s) 等 socket 回包,magic+seq 命中记 report(0,1);超时/错包不记;ctx.Done() 退出。socket 为 net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: 0}),ip.To4()==nil 时直接 返回(IPv6-only 禁用,调用方记 warn——调用方是 Task 3,见下)。

  • Step 1: 写 failing test(组包纯函数)
func TestBuildProbePacket(t *testing.T) {
    src := net.ParseIP("10.10.100.250")
    pkt := buildProbePacket(src, 54321, 7)
    if len(pkt) != 44 { t.Fatalf("len=%d", len(pkt)) }
    if pkt[0] != 0x45 || pkt[9] != 17 { t.Fatal("not v4/UDP") }
    if !net.IP(pkt[12:16]).Equal(src) || !net.IP(pkt[16:20]).Equal(src) {
        t.Fatal("src/dst must both be the spoke address")
    }
    // IP 头 checksum 自验;UDP checksum 必须为零;magic+seq 位置值正确。
}
  • Step 2: 运行,确认 FAIL(函数未定义)

Run: go test ./handler/tun/ -run TestBuildProbePacket -v Expected: 编译失败。

  • Step 3: 实现 buildProbePacket

  • Step 4: 运行,确认 PASS

  • Step 5: 写 failing test(循环:活设备推进 sent,acked 停滞可观测)

func TestRunDeviceProbeReportsSent(t *testing.T) {
    device, kernelSide := net.Pipe() // 同 client_test.go 模式
    defer device.Close()
    defer kernelSide.Close()
    var sent, acked uint64
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    go runDeviceProbe(ctx, device, net.ParseIP("10.10.100.250"),
        func(sd, ad uint64) { sent += sd; acked += ad }, xlogger.Nop())
    // kernelSide 读到第一个包:断言 44B 且 magic 命中;此时 sent>=1 且 acked==0。
}

(xlogger.Nop() 用法同 client_test.go:67;runDeviceProbe 内 socket 绑 10.10.100.250:0 在容器里合法——无该地址也 bind 得上?ListenUDP 绑非本地 IP 会报错!测试改用 127.0.0.1 作 spoke IP,语义等价。)

  • Step 6: 运行,确认 FAIL

  • Step 7: 实现 runDeviceProbe

  • Step 8: 补测试——写坏的设备不 panic 且 acked 永远为 0

func TestRunDeviceProbeDeadDevice(t *testing.T) {
    device, kernelSide := net.Pipe()
    kernelSide.Close() // 写端坏掉:write 报错(备用:device.Close() 后写)
    // 跑两个周期:report 的 sent 在涨,acked==0,全程无 panic。
}
  • Step 9: 跑全包

Run: go test -count=1 ./handler/tun/ Expected: PASS(约 15s,内有既有 14s 的用例)。


Task 3: 接入 handleClient

Files:

  • Modify: x/handler/tun/client.go(handleClient 内 go h.keepalive(...) 旁, 约 62 行)
  • Test: 无新增集成测试(handleClient 需要 Router.Dial,既有测试只到 transportClient 层,keepalive 同款无覆盖;覆盖由 Task 1 的解析测试 + Task 2 的循环测试承担,此处只做接线)

Interfaces:

  • Consumes: Task 1 的 h.md.probe/h.md.probeReport,Task 2 的 runDeviceProbe, handleClient 已有的 iterCtx、conn(device)、ips。

  • Produces: 无新导出(行为:probe 开时每轮 dial 起一个探针,iterCtx 结束即退)。

  • Step 1: 接线(幂等小改)

if h.md.probe {
    go h.probeDevice(iterCtx, conn, ips)
}

其中 probeDevice 为 tunHandler 的小方法:取 ips 第一个 To4() 非空者, nil report 判空(gost 路径),无 v4 时 log.Warn 后返回;其余直调 runDeviceProbe。report 透传 h.md.probeReport(nil-safe:调用前判空, 判空逻辑放在 probeDevice 一处,runDeviceProbe 假定非空——测试只测 runDeviceProbe 非空路径 + probeDevice 的空 report 不起循环)。

  • Step 2: 补 probeDevice 的两个小测试(nil report 不启动; 全 v6 ips 直接返回),跑全包

Run: go test -count=1 ./handler/tun/ ./observer/stats/ Expected: PASS。

  • Step 3: go vet + gofmt -l 扫一遍改动文件

Task 4: 发版 x v0.23.0(maintainer 手动)

  • Step 1: 确认工作区只有本 plan 的文件(git status --short)
  • Step 2: 全量构建 + 相关测试

Run: go build ./... && go test -count=1 ./handler/tun/ ./observer/stats/ Expected: PASS。

  • Step 3: 打 tag 并推(minor:新功能;patch 不够格)
git tag -a v0.23.0 -m "v0.23.0: tun device write-side probe (metadata probe, probe counters)"
git push origin v0.23.0
  • Step 4: 通知 Plan B 可开始(bump 到 v0.23.0)。