From 295a2c229694c754c64e6070a951993435350fd7 Mon Sep 17 00:00:00 2001 From: ginuerzh Date: Wed, 7 Oct 2026 05:07:13 +0000 Subject: [PATCH] docs: add tun device probe implementation plan --- .../plans/2026-10-07-tun-device-probe.md | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-07-tun-device-probe.md diff --git a/docs/superpowers/plans/2026-10-07-tun-device-probe.md b/docs/superpowers/plans/2026-10-07-tun-device-probe.md new file mode 100644 index 0000000..06adea2 --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-tun-device-probe.md @@ -0,0 +1,251 @@ +# 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)** + +```go +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 计数器)** + +```go +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(组包纯函数)** + +```go +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 停滞可观测)** + +```go +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** + +```go +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: 接线(幂等小改)** + +```go +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 不够格) + +```bash +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)。