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

252 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)。