mirror of
https://github.com/go-gost/x.git
synced 2026-10-19 16:16:32 +00:00
252 lines
9.9 KiB
Markdown
252 lines
9.9 KiB
Markdown
# 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)。
|