VPN 网关 阶段 6:Server 数据面(WireGuard + netstack)Implementation Plan
VPN 网关 阶段 6:Server 数据面(WireGuard + netstack)Implementation Plan
- 日期:2026-09-23
- 分支:
codex/vpn-phase6-server-data-plane(stacked 在codex/vpn-phase7-agent-icmp之上,基点a859223) - 规格:内嵌 VPN 网关(WireGuard)设计 §2.2、§3、§4.3、§5.2、§5.3、§5.4、§8、§11、§12.2、§13、§14、§15 阶段 6、§18 验收 1-7 与 10
- 决策载体:ADR 0002(内存态 TUN + netstack 已批准,Server 不需要
CAP_NET_ADMIN) - 技术前提:
test/spike/vpn-task0/REPORT.md(Task 1-4 全 PASS)与该报告「必须回流到阶段 1 的设计修正」1-7 项 - 前置阶段:阶段 4 PR 记录(纯逻辑与管理 API、
NodePublicKey留空、flows/vpn-nodes返回 501)、阶段 7 PR 记录(icmp-echo协议、能力协商、streamProtocolCapability、VPNAgentCapabilityProbe)
1. 目标
让 tunnelmesh-server 在 -tags vpn 构建下真正持有一个 WireGuard 网关,把用户 VPN 流量经既有 relay 送到 Agent:
- 主模块引入
gvisor.dev/gvisor与golang.zx2c4.com/wireguard,且只有//go:build vpn文件引用它们;默认构建的tunnelmesh-server不链接任何一个。 - 内存态
tun.Device桥接 netstackchannel.Endpoint(无/dev/net/tun、无CAP_NET_ADMIN,只绑定一个 UDP 端口)。 - 逐包管线:源 IP 反查 peer → 令牌桶限速 →
vpn.PacketPolicy→ 按 IP 协议分派;每一次丢弃都落到规格 §12.2 已发布的error_class并进入聚合审计vpn_packet_denied。 - TCP 在 netstack 内终结(promiscuous + 按目标动态
AddProtocolAddress+gonet.ListenTCP+ 引用计数回收),accepted conn 经relay.NodeTransport.OpenStream(protocol="tcp")与内网服务对接。 - UDP 数据报经
OpenStream(protocol="udp")中继,回程包由网关自建 IPv4/UDP 头并经WritePackets出向。 - ICMP echo 经阶段 7 的
icmp-echo流中继(protocol.EncodeICMPEchoRequest/DecodeICMPEchoReply),应答同样自建 ICMP echo reply 出向;能力不足按icmp_unsupported丢弃且不开流。 - peer 集合以数据库为权威、内存为副本:启动全量装载,签发/修改/轮换/吊销后热加载;吊销与轮换立即断流并使旧公钥失效。
- 节点身份从
TUNNELMESH_VPN_NODE_PRIVATE_KEY注入,VPNPeerServiceDeps.NodePublicKey不再为空,config:reveal从恒 409 变为可用。 - 生命周期:
shutdown_timeout内 drain、空闲流回收、子网租约周期续租、关闭顺序确定(流 → WireGuard device → NIC/stack → goroutine)。 GET /api/v1/vpn-peers/{peerId}/flows与GET /api/v1/vpn-nodes从 501 变为真实数据;控制台展示活跃流、去掉常驻的vpn.dataPlanePending提示。- 快速失败:无 tag 构建 +
enabled: true→ 启动失败并输出this binary was built without VPN support; rebuild with -tags vpn;tagged 构建 + 缺节点私钥 → 启动失败并点名环境变量。 - 文档:新增用户/部署/运维三份 VPN 文档,更正阶段 4「
TUNNELMESH_VPN_NODE_PRIVATE_KEY本版不被读取」等已过时表述,实测并记录带/不带 tag 的二进制体积增量。
2. 非目标
- 不做阶段 8 的可观测性与发布面:不新增
tunnelmesh_vpn_*指标向量、不动deploy/grafana、不加告警规则、不写 SLO、不改scripts/build-release.sh的产物矩阵、不建test/e2e/vpn/。本阶段只复用现有向量(见 D12)。 - 不做 Schema 变更:
SchemaVersion保持 15,migrations/零改动,不建vpn_flows表(规格 §7.1 明确运行时流状态不入库)。 - 不新增稳定错误码:
internal/vpn/errors.go的 10 个码集合不变,internal/server/vpn_peer_openapi_test.go的双向相等守卫必须继续绿灯。 - 不做 IPv6、不做 IP 分片重组、不做 ICMP echo 之外的任何 ICMP 类型、不做 P2P/NAT traversal、不做任意远程命令执行(规格 §4.3、ADR 0002 保留的禁令)。
- 不改 Agent:阶段 7 已交付
icmp-echo与 TCP/UDP 出口,本阶段只是第一个真正调用它们的一方。 - 不改
Dockerfile:见 D1(本阶段不抬go指令,因此 Task 0 记录的ARG GO_VERSION=1.23落后问题不由本阶段触发,仍属阶段 8 的部署面)。 - 不做跨节点 VPN 状态/流查询 RPC:
vpn-nodes只报告本节点,flows只服务本网关承载的 peer(见 D13)。 - 不实现源地址保留:内网侧看到的仍是 Agent 宿主机地址(规格 §4.3 与 ADR 0002 已定)。
3. 前置核实结论(实测,非推断)
以下每条都在本机(darwin/arm64、go1.27.1)实测过,命令与结果一并记录:
go指令不需要抬到 1.26.3(对阶段 1/3 记录的事实更正)。 两个依赖各自的go.mod都声明go 1.23.1(~/go/pkg/mod/gvisor.dev/gvisor@v0.0.0-20250503011706-39ed1f5ac29c/go.mod第 3 行、~/go/pkg/mod/golang.zx2c4.com/wireguard@v0.0.0-20260522210424-ecfc5a8d5446/go.mod第 3 行)。 在go.mod保持go 1.26.0的副本里加入两个依赖后,go build -tags vpn、go vet -tags vpn、go test -tags vpn(含-race)与不带 tag 的go build ./...全部通过。 因此阶段 1 PR 记录里「抬升 go 指令到 1.26.3(gVisor 要求)时必须同步抬高 Dockerfile ARG」的前提不成立, 本阶段go.mod只新增 require 行,Dockerfile不改。- 离线可解析。
GOPROXY=off go get gvisor.dev/gvisor@v0.0.0-20250503011706-39ed1f5ac29c golang.zx2c4.com/wireguard@v0.0.0-20260522210424-ecfc5a8d5446成功,只新增gvisor.dev/gvisor、golang.zx2c4.com/wireguard两条直接依赖与github.com/google/btree v1.1.2、golang.zx2c4.com/wintun、golang.org/x/time v0.7.0三条 indirect;golang.org/x/sys v0.48.0、x/net v0.59.0、x/crypto v0.57.0版本不变(与主模块已有版本一致)。 加入后主模块go list -m all | wc -l= 228(spike 模块的 96 条与此无关,两者是独立 module)。 - 不运行
go mod tidy。 实测离线go mod tidy会因 grpc 的测试依赖gonum.org/v1/gonum无法解析而失败 (module lookup disabled by GOPROXY=off),与本阶段无关且早于本阶段;go.mod/go.sum由go get精确写入。 - 全 tagged 包会打断默认构建。 一个只有
//go:build vpn文件的包,在不带 tag 时go test ./internal/vpnprobe/输出FAIL ... [setup failed](build constraints exclude all Go files)。 因此本阶段所有 tagged 文件都放进internal/server(该包有大量 untagged 文件),不新建 vpn 专属包。 - 数据面要用的 gVisor / wireguard-go 符号在钉死版本下全部存在且可编译:
stack.New、CreateNIC、SetPromiscuousMode、SetRouteTable、SetTransportProtocolHandler、AddProtocolAddress、RemoveAddress、channel.New、Endpoint.WritePackets、Endpoint.InjectInbound、stack.NewPacketBuffer+PacketBufferOptions.Payload、stack.PacketBufferList、gonet.ListenTCP、header.IPv4EmptySubnet、tcpip.AddrFrom4Slice、device.NewDevice、conn.Bind、tun.Device。一个覆盖以上全部符号的编译探针在go 1.26.0下go vet -tags vpn与go test -tags vpn均通过。 tcpip.Error不实现error(只是fmt.Stringer),因此错误判别只能用类型断言terr.(*tcpip.ErrDuplicateAddress),errors.As无法编译(Task 0 报告第 5、7 项,已在源码复核)。channel.Endpoint.WritePackets不做 MTU 校验:队列满时返回*tcpip.ErrNoBufferSpace,其余情况原样入队 (pkg/tcpip/link/channel/channel.go:277-291)。超限丢弃必须由网关自己执行(Task 0 第 3 项)。WriteNotify在队列锁外调用(pkg/tcpip/link/channel/channel.go:99-104注释 “Send notification outside of lock”), 所以在通知回调里ep.Read()排空不会与写入方死锁。上游tun/netstack/tun.go的incomingPacket是无缓冲 channel,WriteNotify会阻塞栈的出向路径;本阶段不复用它(D8)。- WireGuard UAPI 支持增量改 peer:
device/uapi.go:236的replace_peers=true会RemoveAllPeers(), 而:317支持单个 peer 的remove=true、:363支持replace_allowed_ips=true、:373支持allowed_ip(前缀-表示删除)。 因此热加载可以只改一个 peer,不必重建全部 peer 的握手状态(D11)。 conn.Bind接口只有 6 个方法(conn/conn.go:34-58:Open(port) ([]ReceiveFunc, uint16, error)、Close、SetMark、Send、ParseEndpoint、BatchSize),conn.NewDefaultBind()走ListenPacket(ctx, network, ":"+port)即通配绑定(Task 0 已实测并纠正过 “listening on 127.0.0.1” 的错误表述)。要尊重server.vpn.listen的 host 必须自己实现 Bind(D7)。- ICMP 走 netstack 是可行的但不必要:
pkg/tcpip/transport/icmp/protocol.go:91-95的ParsePorts对 ICMPv4 返回(0, hdr.Ident(), nil),即注册icmp.NewProtocol4后SetTransportProtocolHandler能被调到。 但stack/nic.go:846-851显示未注册的传输协议直接计unknownL4ProtocolRcvdPacketCounts并判TransportPacketProtocolUnreachable,而 demux 未命中且 handler 返回 false 时会走HandleUnknownDestinationPacket→ 可能由栈自己产生 ICMP 差错包。为保证「网关不主动构造 ICMP echo 之外的任何 ICMP」, UDP 与 ICMP 都在网关自己的管线里处理,netstack 只注册 TCP(D3)。 - 首个 SYN 被丢弃是可以消除的:Task 0 第 7 项的根因是「钩子在 demux 未命中时才触发、监听器注册是异步的」。
本设计的注册点在网关自己的入向管线里、
InjectInbound之前同步完成,因此 demux 必然命中, 不存在客户端 RTO 惩罚(D4)。ADR 0002 的 Consequences 里那条「production connect latency includes one client RTO」 在本阶段被工程手段消除,PR 记录里要写明这一点。 - 阶段 7 交付的接口可直接消费:
protocol.StreamProtocolICMPEcho/EncodeICMPEchoRequest/DecodeICMPEchoReply、server.VPNAgentCapabilityProbe(三态)、server.streamProtocolCapability(开icmp-echo流选路必须经它)。VPNPeerServiceDeps.NodePublicKey当前为空 ⇒config:reveal恒 409vpn_node_disabled(internal/server/vpn_peer_service.go:950nodeIdentity())。 - 前端已按 200 形状写好:
web/src/api/vpn.ts的VpnNodeStatus/VpnFlow每个字段都可选、listVpnPeerFlows已实现但视图未用(web/src/tests/vpn.spec.ts:176断言源码里不出现该函数名)、web/src/views/VpnPeers.vue:10常驻vpn.dataPlanePending提示、web/src/views/Servers.vue:28-40已渲染节点区块。 本阶段要让这三处从「占位」变成「真实」,其中vpn.spec.ts:176的守卫必须反向。 - OpenAPI 守卫测试写死了 2 个 phase 标记:
internal/server/vpn_peer_openapi_test.go:112-118断言x-tunnelmesh-phase恰好出现 2 次。两个端点落地后标记必须移除,该断言改为 0。 - 二进制体积增量尚未实测:Task 0 报告明确「真实增量必须在引入
//go:build vpn后用带/不带 tag 的两次构建实测」。 本阶段是唯一引入该 tag 的阶段,因此这项实测落在 T15,数字写进 PR 记录,不作推测。
4. 架构决策
D1:go.mod 只加依赖,不抬 go 指令,Dockerfile 不动
第 3 节第 1 条实测推翻了「gVisor 要求 go 1.26.3」的前提。抬 go 指令会连带抬高 CI(go-version-file: go.mod)
与镜像工具链的要求,而收益为零,违反 KISS 与「配置变更要有真实动因」。因此:
go.mod 新增 2 条直接 require 与 3 条 indirect,go 1.26.0 保持不变;Dockerfile 的 ARG GO_VERSION=1.23
是 Task 0 已记录的既有问题,不由本阶段触发也不由本阶段修(属阶段 8 的部署面),本阶段只在 PR 记录里重申。
D2:build tag 的边界按「是否导入重依赖」划,不按「是否属于数据面」划
规格 §5.2 写的是「数据面文件全部带 //go:build vpn」。本阶段按更严格的判据执行:只有 import 了 gvisor 或
wireguard 的文件才带 tag。流表、令牌桶、拒绝聚合、报文解析、peer 表这五类是纯 Go(encoding/binary、net/netip、
internal/vpn、storage、relay 接口),不带 tag。理由:
- 它们在默认构建里就能被
go test ./...与-race覆盖,不必等 tagged 门禁,反馈更快; - 结构上保证隔离:一旦这些文件误 import gvisor,默认构建立刻编译失败,而不是悄悄把重依赖带进所有部署;
- 规格 §5.4 的意图是「不用 VPN 的部署二进制不含 netstack 与 wireguard-go」,纯逻辑文件不违反该意图。
这是对规格 §5.2 的显式偏离,记入 PR 记录的「与计划的偏差」。tagged 文件仍严格用规格给出的文件名
(vpn_device.go、vpn_stack.go、vpn_packet.go、vpn_flows.go→拆分见 D2.1、vpn_icmp.go、vpn_lifecycle.go、
vpn_gateway.go),新增 vpn_bind.go(规格未预见需要自定义 Bind,见 D7)、vpn_wire.go、vpn_tcp.go、vpn_udp.go、
vpn_peers.go、vpn_ratelimit.go、vpn_denials.go、vpn_runtime.go、vpn_stub.go。
D2.1:vpn_flows.go 是 untagged 的流表,协议中继各自成文件
规格把「per-peer 流表、并发与速率限额、空闲回收、openEgress 纪律」都放进 vpn_flows.go。本阶段拆成:
vpn_flows.go(untagged:流表 + 上限 + 空闲回收 + 快照)、vpn_ratelimit.go(untagged:令牌桶)、
vpn_tcp.go / vpn_udp.go / vpn_icmp.go(tagged:三条协议路径各自的 openEgress 与数据搬运)。
openEgress 纪律(goroutine + cancel + 迟到流回收)在三条路径共用一个 untagged 帮助函数
openVPNEgress(放在 vpn_flows.go),照 internal/server/proxy_entry.go:568 的范式实现,不复用其代码
(ProxyEntry.openEgress 是方法且绑定了 ProxyEntry.config,强行复用会违反接口隔离)。
D3:netstack 只终结 TCP;UDP 与 ICMP 在网关管线内直接中继
见第 3 节第 11 条。收益:栈里不注册 udp/icmp 传输协议 ⇒ 栈永远不会替我们产生 ICMP port-unreachable 或
destination-unreachable,「网关只构造 ICMP echo reply」这条边界由结构保证而不是由测试保证;UDP 数据报边界
天然精确(一次 Read = 一个数据报 = 一次 WritePackets);每个包都经过同一条策略与计数路径。
代价:UDP/ICMP 的 IPv4 头与校验和由网关自己组装,这段代码必须有单测覆盖(T8、T9)。
入向 UDP 校验和不重复校验:WireGuard 已对每个数据报做 AEAD 认证,坏校验和只可能来自恶意 peer,
其后果是内网服务忽略一个坏数据报;为此新增一个 error_class 不值得(已发布的类集合是契约)。此取舍写入用户文档。
D4:监听器在 InjectInbound 之前同步注册,消除首包丢失
入向管线在判定「TCP 且该 (目标地址, 目标端口) 没有监听器」时,同步完成
AddProtocolAddress(dst/32) + gonet.ListenTCP + 启动 accept 泵,然后才 InjectInbound。
因此 demux 必然命中,首个 SYN 不被丢弃,用户不会白等一次客户端 RTO(Linux 初始 RTO 1s)。
非 SYN 段命中未注册四元组时同样注册(让栈按标准 TCP 回 RST,而不是静默吃掉),受同一套上限约束。
这消除了 Task 0 第 7 项与 ADR 0002 Consequences 里那条延迟惩罚。
D5:注册冲突按「不变量破裂」处理,绝不动不属于自己的监听器
Task 0 实测过 32 个 goroutine 抢同一四元组时恰好 1 个成功、31 个拿到 *tcpip.ErrDuplicateAddress 与
port is in use,并警告失败方不得 RemoveAddress 或关闭监听器。本设计里注册发生在网关自己的互斥锁内,
不存在并发失败方;因此这两个错误只可能是「registry 与 stack 状态不一致」(例如上一代 gateway 泄漏),
处理为:计 stack_error、丢包、slog.Error 一次,不做 RemoveAddress、不关闭任何监听器。
D6:目标地址按引用计数回收
listenerRegistry 以 netip.AddrPort 为键,值为 {ln *gonet.TCPListener, refs int};refs 是「已 accept 的连接数 +
在途 accept」。归零时关闭监听器并 RemoveAddress(dstAddr),避免 Task 0 第 2 项警告的无界增长。
回收与新 SYN 的竞争是安全的:注册在包路径内同步完成,地址被移除后到达的 SYN 会重新注册。
RemoveAddress 失败只计数并记日志(地址残留不影响正确性,只影响内存)。
D7:自定义 conn.Bind,绑定 server.vpn.listen 的 host 而不只是端口
conn.NewDefaultBind() 忽略 host(第 3 节第 10 条)。server.vpn.listen 是 host:port 且被
validateVPN 强制(internal/config/config.go:829-831),因此实现 vpnBind:单个 net.UDPConn 精确绑定该地址,
Open(port) 用配置里的 host + 传入的 port(port 为 0 时用配置端口),BatchSize()=1(不做 GSO),
SetMark 返回不支持错误(网关从不设 fwmark),ParseEndpoint 返回携带 netip.AddrPort 的 vpnEndpoint。
这样 listen: "10.0.0.1:51820" 就真的只绑在该地址上,暴露面与配置一致,也让集成测可以绑回环。
D8:设备出向队列有界,满则丢并计数;不复用上游 netTun
上游 tun/netstack/tun.go 的 incomingPacket 是无缓冲 channel,WriteNotify 会阻塞 netstack 的出向路径
(第 3 节第 8 条)。本阶段的 memoryDevice 用容量 1024 的 buffered channel + 非阻塞发送,满则丢弃并计
capacity_exhausted,同时暴露丢弃计数供阶段 8 的告警使用。WriteNotify 里循环 ep.Read() 直到 nil
(一次通知可能对应多个包),Read 一次返回一个包并 DecRef,Close 后 Read 返回 os.ErrClosed。
设备不打开任何文件:File() 返回 nil,且测试断言进程内不存在 /dev/net/tun 的打开路径。
D9:逐包管线的判定顺序是安全语义,不可重排
parse → 非 IPv4/分片 → 源 IP 反查 peer(peer_unknown / peer_revoked / peer_expired)→ 令牌桶(rate_limited)
→ 目标是网关自身 VPN 地址(target_denied)→ vpn.PacketPolicy.Allow(fragment/oversize/protocol/metadata/target/port)
→ 分派。其中:
- 分片与超限在最前,因为非首片没有端口,先放行再判端口等于没有端口白名单(
internal/vpn/policy.go:186-190已定此序); - 网关自身 VPN 地址(
Pool.NodeAddress)恒拒:它不是内网服务,把它交给 Agent 只会得到一次无意义的连接失败; - 策略逐包评估而不是建流时评估一次:peer 可以被 PATCH 改策略、被吊销、到期,正在服务的连接必须立刻失去授权 (规格 §18 验收 5)。已建立的 TCP 流在后续包被拒时关闭,计对应 class。
D10:peer 集合是数据库的内存副本,写库成功后同步;同步失败不回滚数据库
VPNPeerService 在 Issue/Rotate/Update/Revoke 写库成功后调用 VPNPeerSink(新接口,untagged):
ApplyPeer(storage.VPNPeer) / RemovePeer(peerID)。sink 为 nil 时是 no-op(现有测试不受影响)。
应用失败(例如 UAPI 拒绝)记 slog.Error + 计数,不回滚数据库:AGENTS.md 的单一数据源原则要求 DB 是权威,
内存视图落后于 DB 时的修复手段是重启网关(从 ListByNode 全量重装),而不是让管理 API 报一个已经生效的写为失败。
Rotate 改变公钥 ⇒ applier 先 remove=true 旧公钥再加新公钥,旧公钥立即失效(验收 6)。
Revoke ⇒ 移除 WireGuard peer + 立即关闭该 peer 全部在途流(验收 5)。
过期 peer 在装载与逐包两处都判(expires_at 已过 ⇒ peer_expired),不依赖后台清理任务。
D11:WireGuard peer 更新用增量 UAPI
见第 3 节第 9 条。replace_peers=true 会拆掉所有 peer 的握手状态,一次改名就让全节点重连,
与 AGENTS.md「故障隔离」相悖。因此:新增/更新 = public_key=<hex>\nreplace_allowed_ips=true\nallowed_ip=<vpnIP>/32\n,
删除 = public_key=<hex>\nremove=true\n。节点自身身份(private_key + listen_port)只在启动时设置一次。
UAPI 字符串由 untagged 的 internal/vpn/uapi.go 渲染并做黄金文件测试,tagged 侧只负责 IpcSet
(这样密钥格式的正确性在默认构建里就能测)。
D12:指标只复用现有向量,专用向量留给阶段 8
- 字节:
ObserveBytes("vpn", "ingress"|"egress", protocol, n)(bytes_total有component标签)。 - 流:
ObserveStream(protocol, result, errorClass);实测NormalizeProtocol(internal/observability/metrics.go:205-212) 会把icmp-echo归一成unknown,因此本阶段把icmp-echo加进白名单(新增一个低基数标签值,不新增指标)。 - 租约:续租失败走
ObserveRegistryLease("vpn_subnet_renew", result, class)。 - 网关只依赖一个 untagged 的
vpnMetrics小接口(依赖倒置),阶段 8 增加tunnelmesh_vpn_*向量时改适配器即可, 数据面代码零改动。 - 诚实记录:
streams_total/streams_active/stream_errors_total没有component标签, 因此 VPN 流与既有 Agent 隧道流共用同一组序列,直到阶段 8 的专用向量落地。这一点写进 PR 记录与运维文档。
D13:flows 与 vpn-nodes 的可观测边界
GET /api/v1/vpn-peers/{peerId}/flows:只有本网关正在承载该 peer 时才答 200({items:[...]},可为空); 本节点没有运行中的网关、或该 peer 属于别的节点时答 501vpn_not_implemented,消息说明「本节点无法观测该 peer 的流」。 200 + 空列表会被读成「该 peer 没有流量」,而真相可能是「流量在别的节点上」,因此不选它。稳定码集合不变。GET /api/v1/vpn-nodes:答 200,items里是本节点一条:nodeId、enabled、listen、endpointHost来自配置,subnet/allocated来自VPNIPLeaseRepository.ListByHolder,capacity由子网前缀算出(hosts-3, 与Pool.AllocateAddress的可用区间一致),peers来自CountByNode,icmpCapable=cfg.ICMPEnabled且网关在运行。 该端点在 tagged 与 untagged 构建下都可用(untagged 构建里enabled必为 false,否则启动已失败)。 跨节点的 fleet 视图需要集群 RPC,与阶段 7 的能力可见性技术债合并记录,不在本阶段做。docs/api/openapi.yaml:两个端点补 200 响应 schema,移除x-tunnelmesh-phase: 6标记, 重写 501 的描述(不再是「本版未实现」而是「本节点无法观测」);守卫测试的phases != 2改为!= 0。
D14:drain 是有时限的终止,不是 GoAway
WireGuard 客户端没有「去别的节点重连」的语义,因此 VPN 的 drain 不套用 FrameGoAway。Close() 顺序固定:
标记 draining(新流一律拒,计 capacity_exhausted)→ 关闭全部 TCP 监听器并 RemoveAddress →
等待在途流至多 shutdown_timeout → 强制关闭剩余流 → 关闭 WireGuard device(连带 UDP socket 与 tun)→
RemoveNIC + stack.Close() + ep.Close() → 停止空闲回收与租约续租 goroutine → 冲刷拒绝聚合器。
5 分钟止损路径仍是「关 server.vpn.enabled 重启」(ADR 0002),不依赖 drain 成功。
D15:子网租约周期续租,续租失败不拆流
网关启动后按 vpnSubnetLeaseTTL/3(internal/server/vpn_peer_api.go:23 当前为 1min ⇒ 20s)续租本节点持有的每个子网
(VPNIPLeaseRepository.Renew)。这回答阶段 4 PR 记录的开放问题:1 分钟 TTL 是合适的,
因为续租是周期性的且不在包路径上。续租返回 ErrVPNIPLeaseStaleEpoch 时:slog.Error +
ObserveRegistryLease(...,"failed","stale_epoch") + 停止该子网的续租循环,但不关闭在途 peer——
一次数据库抖动不应该把正在服务的流量黑洞掉;新的签发会被既有的 lease fence 挡住(mapLeaseError → 500)。
D16:ICMP 在途计入 per-peer 流上限,且开流前复核能力
每个在途 echo 都占用一条 Agent 侧 stream,因此它就是一条流:进流表(flows 端点里以 icmp-echo 呈现)、
计入 max_flows_per_peer 与全局上限,另外受节点级 icmp_max_concurrent 信号量约束。
任一超限立刻丢包并计 capacity_exhausted,不排队(与阶段 7 D9 同理:排队会让限额变成空话)。
开流前调用 VPNAgentCapabilityProbe.ProbeICMPEcho:CapabilityUnsupported ⇒ 丢弃并计 icmp_unsupported,不开流;
CapabilityUnverified ⇒ 照阶段 7 D7 放行,由 Agent 的应答状态决定结果;
应答 status=unsupported ⇒ icmp_unsupported,timeout ⇒ icmp_timeout,capacity_exhausted ⇒ 同名 class,
unreachable ⇒ egress_unavailable,cancelled ⇒ stack_error,ok ⇒ 构造 echo reply 出向。
关联 ID 由网关分配(阶段 7 已定:不使用内核改写的 ICMP id),并写入 protocol.ICMPEchoRequest.CorrelationID。
5. 全局约束
- 每个任务先写失败测试、实测红灯原文,再写最小实现;红灯原文首行写进该任务的提交 body(AGENTS.md TDD 流程)。
go.mod/go.sum只允许出现第 3 节第 2 条列出的新增行;不得移动任何既有依赖的版本;不运行go mod tidy。migrations/**、internal/storage/db.go的SchemaVersion(15)、internal/vpn/errors.go的 10 个稳定码、 OpenAPI 的 vpn 路径集合(9 条)全部不变。允许改的只有:两个端点的响应 schema/描述、x-tunnelmesh-phase标记的移除。- 任何 import gvisor 或 wireguard 的文件必须以
//go:build vpn开头;门禁里用go list -deps ./cmd/tunnelmesh-server | grep -Ec 'gvisor|golang.zx2c4.com/wireguard'必须为0来证明。 - 私钥、Token、DSN、包载荷、握手字节不进日志/审计/指标。审计只含 peer ID、
error_class、目标网段(/24)与聚合计数; 指标标签只用已发布的低基数值,peer ID、VPN IP、完整目标地址不得作为标签(规格 §13)。 - 一个任务一个提交,
<type>(<scope>): <subject>,subject ≤50 字符、祈使句、无句号。 - 时点记录零改写:阶段 1/3/4/5/7 的计划与 PR 记录只链接不修改;对它们的事实更正(尤其 D1 关于
go指令的更正) 只写在本计划与本阶段的 PR 记录里。 - 门禁跑两遍:默认与
-tags vpn(go test ./... -count=1、go test -race ./... -timeout 30m -count=1、go vet ./...)。 T13 动前端,因此前端门禁与scripts/verify-web-embed.sh也要跑。 - 中间提交(T1-T11)不把网关装配进
ServerRuntime,装配在 T12 一次完成。理由:避免出现「配置开了、 进程也不报错、但半装配的网关既不监听也不转发」的静默中间态。T12 之后规格 §18 验收 10 成立。 - tagged 集成测必须在无特权环境可跑(不打开
/dev/net/tun、不需要 root、不需要ping_group_range): TCP/UDP/ICMP 的入向都用ep.InjectInbound或直接调用管线函数,出向从ep.Read()取, 端到端矩阵测用两个进程内 WireGuard device 经回环 UDP 对接(照test/spike/vpn-task0/wgbridge范式)。
6. 文件清单
| 文件 | 任务 | tag | 内容 |
|---|---|---|---|
docs/superpowers/plans/2026-09-23-vpn-phase6-server-data-plane.md |
— | — | 本计划 |
go.mod、go.sum |
T1 | — | 新增 gvisor、wireguard 与 3 条 indirect |
internal/server/vpn_runtime.go |
T1 | untagged | seam:VPNDataPlane、VPNPeerSink、VPNFlowSnapshot、VPNNodeStatus、VPNGatewayDeps、vpnMetrics 与适配器、ErrVPNBuildTagMissing、startVPNGateway、openVPNEgress |
internal/server/vpn_stub.go |
T1 | !vpn |
vpnBuildTagged=false、newVPNGateway 返回 ErrVPNBuildTagMissing |
internal/server/vpn_gateway.go |
T1 | vpn |
vpnBuildTagged=true、vpnGateway 骨架、newVPNGateway |
internal/server/vpn_runtime_test.go |
T1 | untagged | 快速失败三态、依赖隔离断言 |
internal/vpn/nodekey.go、nodekey_test.go |
T2 | untagged | NodeIdentity、NodeIdentityFromEnvironment、ErrNodeKeyMissing/ErrNodeKeyInvalid |
internal/vpn/uapi.go、uapi_test.go |
T2 | untagged | WireGuard UAPI 行渲染(节点身份、单 peer 增改、单 peer 删除)+ 黄金文件 |
internal/server/vpn_peer_api.go |
T2、T13 | untagged | SetVPN(cfg,nodeID,nodePublicKey)、SetVPNDataPlane、flows/vpn-nodes 真实实现 |
internal/server/vpn_peer_api_test.go |
T2、T13 | untagged | reveal 从 409 变 200;flows/vpn-nodes 响应与边界 |
internal/server/vpn_peer_service.go |
T2、T7 | untagged | 消费 NodePublicKey;写库成功后调用 VPNPeerSink |
internal/server/vpn_peer_service_test.go |
T7 | untagged | sink 调用时序与失败不回滚 |
internal/server/vpn_device.go、vpn_device_test.go |
T3 | vpn |
memoryDevice:tun.Device + channel.Notification、有界队列、丢弃计数 |
internal/server/vpn_stack.go、vpn_stack_test.go |
T4 | vpn |
newVPNStack、promiscuous、路由表、TCP handler、listenerRegistry |
internal/server/vpn_wire.go、vpn_wire_test.go |
T5 | untagged | IPv4/TCP/UDP/ICMP 头解析成 vpnWirePacket |
internal/server/vpn_flows.go、vpn_flows_test.go |
T6 | untagged | 流表、上限、空闲回收、快照、openVPNEgress |
internal/server/vpn_ratelimit.go、vpn_ratelimit_test.go |
T6 | untagged | 令牌桶(0 = 不限) |
internal/server/vpn_denials.go、vpn_denials_test.go |
T6 | untagged | vpn_packet_denied 窗口聚合 + 有界键数 + 冲刷 |
internal/server/vpn_peers.go、vpn_peers_test.go |
T7 | untagged | peer 表:源 IP 索引、策略、状态/到期、Apply/Remove、applier 接口 |
internal/server/vpn_packet.go、vpn_packet_test.go |
T8 | vpn |
入向管线与分派、拒绝计数 |
internal/server/vpn_udp.go、vpn_udp_test.go |
T8 | vpn |
UDP 流中继、回程包构造、WritePackets |
internal/server/vpn_icmp.go、vpn_icmp_test.go |
T9 | vpn |
echo 中继、关联 ID、能力复核、reply 构造 |
internal/observability/metrics.go、metrics_test.go |
T9 | untagged | NormalizeProtocol 接受 icmp-echo |
internal/server/vpn_tcp.go、vpn_tcp_test.go |
T10 | vpn |
accept 泵、上限、openEgress、splice、字节计数 |
internal/server/vpn_bind.go、vpn_bind_test.go |
T11 | vpn |
vpnBind(conn.Bind)、vpnEndpoint |
internal/server/vpn_lifecycle.go、vpn_lifecycle_test.go |
T12 | vpn |
Start/Drain/Close 顺序、空闲回收 goroutine、租约续租 |
internal/server/runtime.go |
T12 | untagged | 装配 gateway、注入 API、shutdown 关闭 |
docs/api/openapi.yaml |
T13 | — | flows/vpn-nodes 的 200 schema、移除 phase 标记 |
internal/server/vpn_peer_openapi_test.go |
T13 | untagged | phases 2 → 0 |
web/src/views/VpnPeers.vue、web/src/tests/vpn.spec.ts、web/src/i18n/messages/{zh-CN,en-US}.ts |
T13 | — | 活跃流抽屉/表格、去掉 dataPlanePending、守卫反向、i18n 中英对齐 |
internal/server/vpn_gateway_test.go |
T14 | vpn |
端到端矩阵:真实 WireGuard 握手 + TCP/UDP/ICMP + 吊销即断 + 策略拒绝 |
docs/user-guide/vpn.md、docs/deployment/vpn-gateway.md、docs/operations/vpn.md |
T15 | — | 新增三份文档 |
docs/operations/configuration.md、docs/operations/config-examples.md、docs/architecture/overview.md、docs/user-guide/server-admin.md、README.md、README.zh-CN.md、docs/README.md |
T15 | — | 活文档更新与索引重生成 |
docs/pull-requests/2026-09-23-vpn-phase6-server-data-plane.md |
T15 | — | PR 记录(含二进制体积实测) |
明确不改
migrations/**、internal/storage/**(Repository 契约阶段 3 已定,本阶段只消费)、Dockerfile、scripts/build-release.sh。internal/agent/**、internal/client/**、internal/protocol/**(阶段 7 已交付icmp-echo与编解码,本阶段只调用)。internal/vpn/errors.go的 10 个稳定码、internal/vpn/policy.go的判定顺序与已发布error_class集合。- 阶段 1/3/4/5/7 的计划与 PR 记录、ADR 0002、设计规格、
test/spike/**。 deploy/**(Grafana Row、告警规则属阶段 8)、.github/workflows/ci.yml(CI 缺 Go 门禁是 Task 0 记录的既有问题,属阶段 8)。web/package.json、web/package-lock.json(不新增前端依赖)。
7. 任务分解
T1:依赖、构建隔离与快速失败
Step 1(红灯):internal/server/vpn_runtime_test.go 断言
vpnBuildTagged == false(默认构建)、startVPNGateway 在 Enabled:false 时返回 nil 网关且无错误、
在 Enabled:true 且无 tag 时返回的错误 errors.Is(err, ErrVPNBuildTagMissing) 且消息含 -tags vpn、
在 Enabled:true 且缺 TUNNELMESH_VPN_NODE_PRIVATE_KEY 时返回的错误消息点名该环境变量(用 tagged 构建也要成立,
因此该检查在 untagged 代码里)、go list -deps ./cmd/tunnelmesh-server 不含 gvisor/wireguard。
Run go test ./internal/server/ -run VPNRuntime -count=1 → 编译失败 undefined: startVPNGateway。
Step 2(实现):go get 两个依赖(GOPROXY=off,版本与 spike 钉死的一致);写 vpn_runtime.go(seam 类型 +
startVPNGateway:先查 vpnBuildTagged,再查节点私钥,再调 newVPNGateway);写 vpn_stub.go(!vpn);
写 vpn_gateway.go(vpn,骨架:保存 deps、Close 幂等、PeerFlows 返回空、NodeStatus 由配置组装、
ApplyPeer/RemovePeer 暂返回 nil,后续任务逐个替换)。
Step 3(绿灯 + 提交):go build ./... && go build -tags vpn ./... && go test ./internal/server/ -run VPN -count=1
&& go test -tags vpn ./internal/server/ -run VPN -count=1 全绿。提交 feat(server): isolate the vpn data plane build。
T2:节点身份与 reveal 打通
Step 1(红灯):internal/vpn/nodekey_test.go 断言 base64(含 raw/std)与 hex 两种编码都能解析、
32 字节全零被拒、非 32 字节被拒、缺失环境变量返回 ErrNodeKeyMissing、
派生公钥与 GenerateKeyPair 的公钥格式一致(ValidatePublicKey 通过);
internal/vpn/uapi_test.go 断言节点身份行、单 peer 增改行(含 replace_allowed_ips=true)、单 peer 删除行
(含 remove=true)与 testdata/ 黄金文件逐字节一致,且任何一行都不含私钥以外的密钥材料泄漏(peer 块只含公钥)。
internal/server/vpn_peer_api_test.go 更正:装配了节点公钥后 config:reveal 返回 200 且 ini 含 [Peer] PublicKey。
Run → undefined: vpn.NodeIdentityFromEnvironment。
Step 2(实现):internal/vpn/nodekey.go(复用 DecodeKey/DerivePublicKey/ValidatePrivateKey)、
internal/vpn/uapi.go;SetVPN 增第三参 nodePublicKey;API.SetVPNDataPlane(VPNDataPlane);
VPNPeerService 在 Issue/Rotate/Update/Revoke 写库成功后调用 sink(本任务先接一个 no-op sink 字段,T7 补真实语义测试)。
更新 SetVPN 的 2 个调用点(runtime.go:164、vpn_peer_api_test.go:292)。
Step 3(绿灯 + 提交):go test ./internal/vpn/ ./internal/server/ -count=1 绿。
提交 feat(vpn): inject the node wireguard identity。
T3:内存态 TUN 设备(tagged)
Step 1(红灯):internal/server/vpn_device_test.go(//go:build vpn)断言
newMemoryDevice(mtu, queueSize) 返回的对象满足 tun.Device 与 channel.Notification;
Write 一个合法 IPv4 包后能从注入侧读到它(用 stack 无关的直接断言:设备把包交给 InjectInbound,
测试用一个记录型 endpoint 或真实 channel.Endpoint + ep.Read());WriteNotify 后 Read 取到出向包且字节一致;
队列满时 WriteNotify 不阻塞、丢弃计数 +1;Close 后 Read 返回 os.ErrClosed、Close 幂等;
File() 返回 nil;MTU() 返回配置值;BatchSize() 返回 1;Events() 至少给出一个 tun.EventUp。
Run go test -tags vpn ./internal/server/ -run VPNDevice -count=1 → 编译失败 undefined: newMemoryDevice。
Step 2(实现):vpn_device.go 照 tun/netstack/tun.go 的 netTun 结构自行实现(D8),
不导入 wireguard/tun/netstack 包(避免连带引入它那份无缓冲队列与 DNS 代码)。
Step 3(绿灯 + 提交):go test -tags vpn ./internal/server/ -run VPNDevice -count=1 -race 绿。
提交 feat(server): bridge wireguard to an in-memory tun。
T4:netstack 装配与 TCP 监听器注册表(tagged)
Step 1(红灯):internal/server/vpn_stack_test.go 断言
newVPNStack(mtu) 后 SetPromiscuousMode 生效(对未拥有目标的 SYN 能出 SYN-ACK);
registerTCPListener(dstAddr, dstPort) 首次调用后注入 SYN 能收到 SYN-ACK(校验 flags&TCPFlagSyn != 0,
照 Task 0 评审修正,不能把 RST-ACK 当 SYN-ACK)、Accept 返回的 conn RemoteAddr 是注入方地址;
同一 (addr,port) 二次调用返回同一个监听器且 refs 递增;release 到 0 后监听器关闭且地址被移除
(再次注入 SYN 无 SYN-ACK);未注册四元组的非 SYN 段被消费(handler 返回 true)且出向队列在 300ms 内
不出现 IP 协议号 1 的包(照 Task 0 的 drainFor 正向证明法);AddProtocolAddress 返回
*tcpip.ErrDuplicateAddress 时计 stack_error 且不调用 RemoveAddress(用注入的假 stack 钩子或先手动占用地址制造冲突)。
Run → undefined: newVPNStack。
Step 2(实现):vpn_stack.go:stack.New(只注册 ipv4.NewProtocol + tcp.NewProtocol,见 D3)、
channel.New(1024, mtu, "")、CreateNIC、SetPromiscuousMode、默认路由、
SetTransportProtocolHandler(tcp, ...)(demux 未命中时消费并计数)、listenerRegistry(互斥锁 + 引用计数 + D5/D6)。
Step 3(绿灯 + 提交):go test -tags vpn ./internal/server/ -run VPNStack -count=1 -race 绿。
提交 feat(server): terminate vpn tcp in netstack。
T5:报文解析(untagged)
Step 1(红灯):internal/server/vpn_wire_test.go 断言
parseVPNWirePacket 对合法 IPv4+TCP/UDP/ICMP 返回正确的 {Version, Protocol, Src, Dst, Fragment, Size, SrcPort, DstPort,
ICMPType, ICMPCode, ICMPID, ICMPSeq, Payload};IHL>5 的选项头被正确跳过;MF=1 或 frag_offset>0 标记为分片;
非 IPv4(version 6、version 4 但长度不足)返回明确错误;截断的 TCP/UDP/ICMP 头返回错误而不是 panic;
ICMP echo request(type 8 code 0)与 echo reply(type 0)被区分,其它类型(0x0b TTL exceeded)被标记为不支持;
UDP 载荷偏移与长度正确(含 Length 字段与实际字节不一致时以实际字节为准并标记畸形)。
Run → undefined: parseVPNWirePacket。
Step 2(实现):vpn_wire.go,纯 encoding/binary + net/netip,不导入 gvisor。
Step 3(绿灯 + 提交):go test ./internal/server/ -run VPNWire -count=1 绿。
提交 feat(server): parse vpn tunnel packets。
T6:流表、令牌桶与拒绝聚合(untagged)
Step 1(红灯):三个测试文件。
vpn_flows_test.go:登记/查询/移除;per-peer 上限与全局上限触发 capacity_exhausted;空闲回收只关掉超过
idle_timeout 的流;Snapshot(peerID) 的字段与 VPNFlowSnapshot 一致且不含载荷;并发登记同一 key 只产生一条流
(32 goroutine 竞争,照 Task 0 的并发实测范式);openVPNEgress 在 opener 为 nil 时返回 egress_unavailable、
在 opener 阻塞超过 connect_timeout 时返回 egress_timeout 且迟到成功的流被关闭(不泄漏,照
proxy_entry.go:568 的 abandon 范式)。
vpn_ratelimit_test.go:rate=0 恒放行;令牌按时间补充(注入 clock);突发不超过桶容量;并发调用不产生负令牌。
vpn_denials_test.go:同一 (peerID, class, /24) 在窗口内只写一条审计且 count 正确;窗口翻滚后重新计数;
键数超过上限后新键落入溢出桶而不是无界增长;审计 details 不含目标完整地址与任何密钥;Flush 在关闭时被调用。
Run → undefined: newVPNFlowTable。
Step 2(实现):三个文件 + VPNFlowSnapshot 字段定形(id、protocol、target、port、startedAt、
bytesSent、bytesReceived,与 web/src/api/vpn.ts 的 VpnFlow 逐字段对齐)。
Step 3(绿灯 + 提交):go test ./internal/server/ -run 'VPNFlow|VPNRate|VPNDenial' -count=1 -race 绿。
提交 feat(server): track vpn flows limits and denials。
T7:peer 表与热加载(untagged)
Step 1(红灯):internal/server/vpn_peers_test.go 断言
ApplyPeer 后按源 IP 能查到 peer 与其 vpn.PacketPolicy;status=disabled/revoked 的 peer 查不到(分别计
peer_revoked);expires_at 已过 ⇒ peer_expired;同一 IP 换成另一个 peer ID ⇒ 旧索引被替换;
RemovePeer 后查询返回 peer_unknown 且回调 onRemove 被调用一次(网关用它断流);
公钥变化时回调 onReplacePublicKey(old, new)(网关用它做 UAPI 增量更新,D10/D11);
ApplyPeer 对 allowed_ips 为空或非法的行返回错误而不是静默建成一个全通策略;
SnapshotForNode 给出 peers 计数与 subnet/allocated 所需的输入。
internal/server/vpn_peer_service_test.go 新增:Issue/Rotate/Update/Revoke 成功后 sink 各被调用一次且顺序在写库之后;
sink 返回错误时数据库里的行仍然存在(不回滚)且错误被记录。
Run → undefined: newVPNPeerTable。
Step 2(实现):vpn_peers.go(vpnPeerTable:map[peerID]entry + map[vpnIP]peerID 双索引、回调钩子);
VPNPeerService 接 VPNPeerSink;SetVPNDataPlane 把网关注册为 sink。
Step 3(绿灯 + 提交):go test ./internal/server/ -run 'VPNPeer' -count=1 -race 绿。
提交 feat(server): hot reload vpn peers。
T8:入向管线与 UDP 中继(tagged)
Step 1(红灯):vpn_packet_test.go 用假 peer 表 + 假 metrics + 假 audit 覆盖 D9 的每一个分支,
断言丢弃时计数的 class 恰好是 peer_unknown/peer_revoked/peer_expired/rate_limited/target_denied/
fragment_dropped/oversize_dropped/protocol_unsupported/metadata_denied/port_denied,
且合法包被分派到对应协议处理器;非 IPv4 包被丢弃并计 protocol_unsupported。
vpn_udp_test.go:一个入向 UDP 数据报 ⇒ 假 opener 收到一条 relay.StreamRequest{Protocol:"udp", AgentID:peer.AgentID,
TargetHost:dst, TargetPort:dstPort};从假流写回的应答 ⇒ 出向队列出现一个 IPv4/UDP 包,其源=内网服务地址、
目的=peer VPN 地址、端口对调、载荷一致、IP 协议号 17(照 Task 3 的四条断言);同一四元组的第二个数据报复用同一条流
(opener 只被调用一次);超过 MTU 的应答被丢弃并计 oversize_dropped;流关闭后四元组被回收,
下一个数据报重新开流;per-peer 流上限触发时数据报被丢弃并计 capacity_exhausted 且不开流。
Run go test -tags vpn ./internal/server/ -run 'VPNPacket|VPNUDP' -count=1 → undefined: (*vpnGateway).handlePacket。
Step 2(实现):vpn_packet.go(管线 + 分派 + 计数 + 拒绝聚合入口)、vpn_udp.go
(udpFlowTable 复用 T6 流表、openVPNEgress、回程包构造 buildIPv4UDP、WritePackets)。
Step 3(绿灯 + 提交):go test -tags vpn ./internal/server/ -run 'VPNPacket|VPNUDP' -count=1 -race 绿。
提交 feat(server): relay vpn udp datagrams。
T9:ICMP echo 中继(tagged)
Step 1(红灯):vpn_icmp_test.go:echo request ⇒ 假 opener 收到
relay.StreamRequest{Protocol:"icmp-echo", TargetHost:dst, TargetPort:0} 且写入的数据报能被
protocol.DecodeICMPEchoRequest 解出、CorrelationID 非空、Identifier/Sequence 与入向包一致;
写回一个 status:"ok" 的 ICMPEchoReply ⇒ 出向队列出现 ICMP echo reply(type 0、code 0、id/seq 与请求一致、
载荷一致、校验和正确、源=目标内网地址、目的=peer VPN 地址);status:"timeout" ⇒ 计 icmp_timeout 且不出向;
status:"unsupported" ⇒ icmp_unsupported;status:"capacity_exhausted" ⇒ capacity_exhausted;
status:"unreachable" ⇒ egress_unavailable;status:"cancelled" ⇒ stack_error;
probe 返回 CapabilityUnsupported ⇒ 不开流且计 icmp_unsupported;CapabilityUnverified ⇒ 开流;
节点 icmp_enabled=false 或 peer icmp_enabled=false ⇒ 由 T8 的策略链拦下(此处断言不会走到开流);
在途数达到 icmp_max_concurrent ⇒ 计 capacity_exhausted 且不开流;超过 icmp_timeout 未回应答 ⇒ 计 icmp_timeout
并释放在途名额;ICMP echo 之外的类型(TTL exceeded)⇒ 计 protocol_unsupported 且不开流。
internal/observability/metrics_test.go:NormalizeProtocol("icmp-echo") == "icmp-echo"。
Run → undefined: (*vpnGateway).handleICMPEcho。
Step 2(实现):vpn_icmp.go(关联 ID 分配、在途信号量、超时 time.AfterFunc、reply 构造 buildIPv4ICMPEchoReply);
internal/observability/metrics.go 的 NormalizeProtocol 白名单加 icmp-echo。
Step 3(绿灯 + 提交):go test -tags vpn ./internal/server/ -run VPNICMP -count=1 -race 与
go test ./internal/observability/ -count=1 绿。提交 feat(server): relay vpn icmp echo。
T10:TCP 中继(tagged)
Step 1(红灯):vpn_tcp_test.go:注入完整三次握手(SYN → 读 SYN-ACK → ACK)后
假 opener 收到 relay.StreamRequest{Protocol:"tcp", TargetHost:dst, TargetPort:dstPort};
从 accepted conn 写入的字节出现在假流里、假流写回的字节出现在出向队列的 TCP 段载荷里;
半关闭(对端 FIN)⇒ 假流被 Close 且流表条目移除、监听器 refs 递减;假流 EOF ⇒ conn 被关闭;
connect_timeout 超时 ⇒ conn 被关闭且计 egress_timeout;opener 返回错误 ⇒ 计 egress_unavailable;
peer 在 accept 之后被吊销 ⇒ 该 conn 被关闭(RemovePeer 的 onRemove 回调生效);
per-peer 流上限 ⇒ 新连接立刻被关闭且计 capacity_exhausted;idle_timeout 内无字节 ⇒ 连接被回收;
两个 peer 连同一个 (dst,port) ⇒ 只有一个监听器、两条流、各自的 RemoteAddr 正确映射回各自 peer。
Run → undefined: (*vpnGateway).serveTCPConn。
Step 2(实现):vpn_tcp.go:accept 泵 goroutine、peer 反查、上限检查、openVPNEgress、
复用 spliceWithIdleTimeout(internal/server/proxy_entry.go:799,untagged,同包可直接调用)、
字节计数与 ObserveBytes/ObserveStream、监听器引用计数释放。
Step 3(绿灯 + 提交):go test -tags vpn ./internal/server/ -run VPNTCP -count=1 -race 绿。
提交 feat(server): relay vpn tcp streams。
T11:WireGuard 端点与自定义 Bind(tagged)
Step 1(红灯):vpn_bind_test.go:newVPNBind("127.0.0.1:0") 后 Open(0) 返回实际端口且
net.ListenUDP 到该端口会失败(证明端口真的被占用、绑定在指定 host 上);Send 一个数据报能被对端 UDP socket 收到;
ParseEndpoint("127.0.0.1:1234") 的 DstToString/DstIP 正确、非法字符串返回错误;BatchSize()==1;
SetMark(1) 返回不支持错误;Close 后 ReceiveFunc 返回 net.ErrClosed。
vpn_gateway_test.go 的第一段(本任务只写握手部分):两个 device.NewDevice(服务端用 vpnBind + memoryDevice +
newVPNStack,客户端用上游 tun/netstack.CreateNetTUN)经回环完成真实 Noise 握手,
IpcGet 里能看到该 peer 的 handshake 时间戳非零,且服务端 ApplyPeer 之后客户端能被路由到(allowed_ip 生效)。
Run → undefined: newVPNBind。
Step 2(实现):vpn_bind.go(vpnBind、vpnEndpoint、ReceiveFunc);
vpn_gateway.go 补齐:创建 memoryDevice + newVPNStack + device.NewDevice、启动时 IpcSet 节点身份与
listen_port、ApplyPeer/RemovePeer 走 vpn.RenderPeerUAPI/RenderRemoveUAPI + IpcSet、
把 peer 表回调接到 UAPI、从 ListByNode 全量装载。
Step 3(绿灯 + 提交):go test -tags vpn ./internal/server/ -run 'VPNBind|VPNGateway' -count=1 -race 绿。
提交 feat(server): serve the wireguard endpoint。
T12:生命周期与运行时装配(tagged + untagged)
Step 1(红灯):vpn_lifecycle_test.go:Start(ctx) 后 UDP 端口可被外部 socket 探测到;
Close() 在 shutdown_timeout 内返回且顺序正确(用记录型桩断言:先拒新流、再关监听器、再关流、
再关 device、最后关 stack;断言方法调用序列而不是靠时序猜测);drain 期间新流被拒并计 capacity_exhausted;
在途流在 shutdown_timeout 到点后被强制关闭;Close 幂等且不 panic;空闲回收 goroutine 与租约续租 goroutine
在 Close 后退出(用 goleak 风格的手写断言:等待 done channel 关闭);
租约续租按 TTL/3 调用 Renew,返回 ErrVPNIPLeaseStaleEpoch 时停止该子网的续租且不关闭在途 peer。
internal/server/runtime_test.go(或既有 runtime 测试文件):VPN.Enabled=true 且无 tag ⇒ NewServerRuntime 返回
含 -tags vpn 的错误;Enabled=false ⇒ 不创建任何 VPN 资源(网关为 nil、无 UDP 端口被绑定)。
Run → undefined: (*vpnGateway).Start。
Step 2(实现):vpn_lifecycle.go(D14 的顺序、D15 的续租);runtime.go 装配(startVPNGateway →
runtime.vpnGateway、API.SetVPNDataPlane、ServeListener 的 shutdown 链里 Close、ServerRuntime.Close 里兜底)。
Step 3(绿灯 + 提交):go test -tags vpn ./internal/server/ -run 'VPNLifecycle|Runtime' -count=1 -race 与
默认构建同范围测试绿。提交 feat(server): run the vpn gateway lifecycle。
T13:管理 API 与控制台
Step 1(红灯):vpn_peer_api_test.go 新增:装了假 VPNDataPlane 后 GET /flows 返回 200 且 items
逐字段等于快照;网关为 nil 时仍 501 vpn_not_implemented;peer 属于别的节点时 501;
越权(普通用户读他人 peer 的 flows)⇒ 404 而不是 501(先解析 peer 再问网关,避免用状态码探测归属);
GET /vpn-nodes 返回 200 且本节点条目的 enabled/listen/endpointHost/subnet/allocated/capacity/peers/
icmpCapable 与夹具一致;untagged 构建下 enabled=false 时 icmpCapable=false。
vpn_peer_openapi_test.go:phases != 0 断言(当前是 != 2,先改测试让它红)。
web/src/tests/vpn.spec.ts:把 expect(source).not.toContain('listVpnPeerFlows') 反向为断言 VpnPeers.vue
确实用了它并渲染活跃流;删除 vpn.dataPlanePending 的断言。
Run → 失败(handler 仍返回 501;前端守卫仍反向)。
Step 2(实现):vpn_peer_api.go 的两个 handler;docs/api/openapi.yaml 补 VPNFlowListEnvelope/
VPNNodeListEnvelope schema、移除两个 x-tunnelmesh-phase、重写 501 描述;
web/src/views/VpnPeers.vue 增活跃流抽屉(照既有使用说明抽屉范式)、去掉常驻提示;
web/src/i18n/messages/{zh-CN,en-US}.ts 增删对应键(中英对齐,vpn.spec.ts 的 i18n 守卫会检查)。
Step 3(绿灯 + 提交):go test ./internal/server/ -run VPN -count=1 + cd web && npm test -- --run && npm run build
bash scripts/verify-web-embed.sh绿。提交feat(server): expose vpn flows and node status。
T14:端到端矩阵测(tagged)
Step 1(红灯):vpn_gateway_test.go 扩展成矩阵:一个真实 WireGuard 客户端(上游 CreateNetTUN)
经回环 UDP 连到被测网关,网关的 opener 是一个把字节回显/记录的假 Agent transport。断言规格 §18 的 1-6:
握手成功且 peer 拿到 VPN IP(客户端从自己的 netstack 里 ping 不通网关自身地址 ⇒ target_denied 计数 +1);
TCP:客户端 DialContextTCPAddrPort 到内网目标 ⇒ 假 Agent 收到 Protocol:"tcp" 与正确目标 ⇒ 双向字节一致 ⇒
首包没有被丢弃(断言握手在 200ms 内完成,这是 D4 的正向证据,若回退到异步注册该断言会因 1s RTO 而失败);
UDP:一个数据报往返,出向包的源/目的/端口/载荷与 Task 3 的四条断言一致;
ICMP:echo request ⇒ 假 Agent 收到 Protocol:"icmp-echo" ⇒ 回 status:"ok" ⇒ 客户端收到 echo reply;
吊销:RemovePeer 后在途 TCP 流被关闭、客户端新的握手不被接受(IpcGet 里没有该 peer);
轮换:旧公钥立即失效(用旧密钥的客户端握手不成功);
策略:目标不在 allowed_ips ⇒ 客户端连不通且 target_denied 计数 +1、审计里出现一条聚合的 vpn_packet_denied;
云 metadata 地址(169.254.169.254)⇒ metadata_denied;不支持的协议(构造 IP 协议号 47 的 GRE 包注入)⇒
protocol_unsupported;分片包 ⇒ fragment_dropped。
Run → 失败(矩阵里若干断言尚未成立,逐条列出红灯原文)。
Step 2(实现):只修实现,不改断言(除非断言本身与规格冲突,此时在 PR 记录里写明)。
Step 3(绿灯 + 提交):go test -tags vpn ./internal/server/ -run VPNGateway -count=3 -race 连续 3 次绿
(照 Task 0 的「连续 3 次」范式,排除端口与调度偶发)。提交 test(server): prove the vpn data plane end to end。
T15:文档、体积实测与 PR 记录
- 实测二进制增量:
go build -o /tmp/tm-server-notag ./cmd/tunnelmesh-server与go build -tags vpn -o /tmp/tm-server-vpn ./cmd/tunnelmesh-server,记录两个字节数与差值(Task 0 明确要求实测、不得推测)。 - 新增
docs/user-guide/vpn.md(导入配置、各平台步骤、能力边界逐条照规格 §4.3、 「IP 层没有错误通道,失败只表现为连不通」照规格 §12.2、UDP 校验和不重复校验的取舍、排障);docs/deployment/vpn-gateway.md(公网 UDP 放行、通配 vs 指定 host 绑定、endpoint_hostDNS、TUNNELMESH_VPN_NODE_PRIVATE_KEY注入、IP 池与子网规划、-tags vpn构建与体积增量实测值、 无 tag 构建 +enabled: true的快速失败信息、CAP_NET_ADMIN明确不需要);docs/operations/vpn.md(IP 池容量与耗尽、drain 与 5 分钟止损、租约续租与 stale epoch、error_class全集与含义、当前指标复用现状与阶段 8 的专用向量、常见故障)。 - 更新
docs/operations/configuration.md(TUNNELMESH_VPN_NODE_PRIVATE_KEY从「本版不被读取」改为已消费, 补全部server.vpn.*键的取值范围与默认值)、docs/operations/config-examples.md(三份示例同步)、docs/architecture/overview.md(数据面链路与 ASCII 图)、docs/user-guide/server-admin.md(VPN 章节: reveal 可用、活跃流可见、节点状态可见、集群限制)、README.md:15/README.zh-CN.md:15的组件表补 VPN 端点。 python3 scripts/gen_doc_index.py连跑两次幂等;相对链接死链核验;go test ./scripts/ -count=1(doc_claims_test.go的反转主张守卫必须继续绿)。- 写
docs/pull-requests/2026-09-23-vpn-phase6-server-data-plane.md,格式照阶段 7 的 PR 记录: 标题、目标分支、关联记录、摘要、用户影响、API/Schema/配置影响、安全与授权影响、 测试证据(红灯表 + 未执行表 + 体积实测)、与计划的偏差(D1/D2/D3/D4 对规格与既有记录的更正)、 顺带发现、发布步骤、回滚步骤、Reviewer 关注点、集成状态。 - 提交
docs(vpn): describe the server data plane,随后跑第 9 节全量门禁,push。
8. 任务间接口
VPNDataPlane(untagged,T1 定形,T11/T12 实现):ApplyPeer(storage.VPNPeer) error、RemovePeer(peerID string) error、PeerFlows(peerID string) ([]VPNFlowSnapshot, bool)(bool = 本节点是否承载该 peer,D13)、NodeStatus(ctx) (VPNNodeStatus, error)、Start(ctx) error、Close() error。VPNPeerSink(untagged,T1 定形,T7 消费):ApplyPeer(storage.VPNPeer) error、RemovePeer(peerID string) error;VPNDataPlane是它的超集,网关同时充当两者。VPNFlowSnapshot:ID string、Protocol string、Target string、Port int、StartedAt time.Time、BytesSent int64、BytesReceived int64;JSON 键与web/src/api/vpn.ts的VpnFlow一致。VPNNodeStatus:NodeID、Enabled、Listen、EndpointHost、Subnet、Allocated、Capacity、Peers、ICMPCapable;JSON 键与VpnNodeStatus一致。VPNGatewayDeps(untagged):Config config.VPNConfig、Node vpn.NodeIdentity、NodeID string、Opener relay.NodeTransport、Peers storage.VPNPeerRepository、Leases storage.VPNIPLeaseRepository、Audits storage.AuditRepository、Metrics vpnMetrics、Capabilities VPNAgentCapabilityProbe、Now func() time.Time。vpnMetrics(untagged 小接口,D12):Bytes(direction, protocol string, n int64)、Stream(protocol, result, errorClass string)、PacketDropped(direction, errorClass string)、Lease(result, errorClass string);observabilityVPNMetrics是它到*observability.Metrics的适配器。vpn.NodeIdentity(T2):PrivateKey string、PublicKey string;vpn.NodeIdentityFromEnvironment() (NodeIdentity, error)。vpn.RenderNodeUAPI(NodeIdentity, listenPort int) (string, error)、vpn.RenderPeerUAPI(publicKey string, vpnIP net.IP) (string, error)、vpn.RenderPeerRemoveUAPI(publicKey string) (string, error)(T2 定形,T11 消费)。parseVPNWirePacket([]byte) (vpnWirePacket, error)(T5 定形,T8/T9/T10 消费)。newVPNFlowTable(cfg) *vpnFlowTable、newPacketBucket(rate int, now func() time.Time) *packetBucket、newDenialAggregator(audits, window, maxKeys) *denialAggregator(T6 定形,T8-T12 消费)。openVPNEgress(ctx, opener, request, timeout) (io.ReadWriteCloser, context.CancelFunc, error)(T6 定形, T9/T10 与 T8 的 UDP 共用;语义照proxy_entry.go:568,含迟到流回收)。newVPNPeerTable(hooks) *vpnPeerTable(T7 定形,T8-T12 消费)。newMemoryDevice(mtu, queueSize int) (*memoryDevice, error)(T3)、newVPNStack(mtu int) (*vpnStack, error)(T4)、newVPNBind(listen string) (*vpnBind, error)(T11):三者由 T11 的vpnGateway组装。- 阶段 7 交付、本阶段直接消费:
protocol.StreamProtocolICMPEcho、protocol.EncodeICMPEchoRequest、protocol.DecodeICMPEchoReply、server.VPNAgentCapabilityProbe、server.streamProtocolCapability、server.CapabilitySupported/Unsupported/Unverified。
9. 整体验证
# 默认构建
go build ./...
go test ./... -count=1
go test -race ./... -timeout 30m -count=1
go vet ./...
# 带 tag(规格 §14 的门禁命令)
go build -tags vpn ./...
go test -tags vpn ./... -count=1
go test -tags vpn -race ./... -timeout 30m -count=1
go vet -tags vpn ./...
# 隔离与格式
go list -deps ./cmd/tunnelmesh-server | grep -Ec 'gvisor|golang.zx2c4.com/wireguard' # 必须为 0
go list -deps -tags vpn ./cmd/tunnelmesh-server | grep -Ec 'gvisor' # 必须 > 0
gofmt -l internal scripts
git diff --check
go test ./scripts/ -count=1
# 依赖面只允许出现计划里的新增行
git diff --stat codex/vpn-phase7-agent-icmp..HEAD -- go.mod go.sum
git status --porcelain migrations web/package.json web/package-lock.json Dockerfile # 必须为空
# 前端
cd web && npm test -- --run && npm run build && cd .. && bash scripts/verify-web-embed.sh
# 文档索引与死链
python3 scripts/gen_doc_index.py && python3 scripts/gen_doc_index.py && git status --porcelain docs
# 二进制体积实测(写进 PR 记录)
go build -o /tmp/tm-server-notag ./cmd/tunnelmesh-server && stat -f%z /tmp/tm-server-notag
go build -tags vpn -o /tmp/tm-server-vpn ./cmd/tunnelmesh-server && stat -f%z /tmp/tm-server-vpn
本阶段没有 Schema 变更,双方言迁移测不适用。Docker/Compose 验证不适用(Dockerfile 与 compose 文件零改动,
见 D1);容器内的非 root 运行与「无 CAP_NET_ADMIN」主张仍受 Task 0 第 8 项的 Linux 取证门禁约束,
本机(macOS)无法取证,PR 记录里必须写成「未在 Linux 取证 + 原因」,不得写成已通过。
真实 wg 客户端的端到端验证属阶段 8 的 test/e2e/vpn/。
10. 回滚注意事项
- 回滚顺序:T15 → T14 → T13 → T12 → T11 → T10 → T9 → T8 → T7 → T6 → T5 → T4 → T3 → T2 → T1。
T1 必须最后回滚:
go.mod的两个依赖被 T3/T4/T11 的 tagged 文件引用,只回滚 T1 会让-tags vpn构建失败 (默认构建不受影响,因为 tagged 文件被排除)。 - T2 改了
SetVPN的签名与VPNPeerServiceDeps.NodePublicKey的来源;回滚 T2 会让config:reveal回到恒 409vpn_node_disabled,已签发的 peer 行不受影响(密文与公钥都在库里,只是渲染不出 ini)。 - T13 移除了 OpenAPI 的两个
x-tunnelmesh-phase标记并把守卫测试的期望值从 2 改成 0; 回滚 T13 必须同时回滚这两处,否则守卫测试红。 - 不回滚代码也能止损:
server.vpn.enabled: false+ 重启即可让进程不创建任何 VPN 资源(ADR 0002 的 5 分钟止损路径); 默认构建(不带-tags vpn)本身就不含数据面,是最强的止损。 - 数据面无持久化状态:流表、监听器注册表、peer 内存视图、拒绝聚合器全在内存,回滚不需要数据补偿、不需要迁移、
不需要清理。数据库里的
vpn_peers/vpn_ip_leases行在回滚后仍可被阶段 4/5 的管理 API 读写。 - 回滚后 UDP 端口不再被持有,云安全组里为该端口开的放行规则可以保留(无监听者时只是丢包), 但运维文档要说明这一点,避免「端口开着但没人听」被误判为故障。
- 与阶段 7 的关系:本阶段是
icmp-echo的第一个调用方。回滚本阶段后 Agent 的 ICMP 引擎闲置但无害 (能力仍通告,Server 不再开流),不需要同时回滚阶段 7。 - 与阶段 8 的关系:本阶段只复用现有指标向量。回滚本阶段会让
streams_total{protocol="icmp-echo"}这条序列 停止增长(NormalizeProtocol的白名单新增项可以保留,它不产生数据);阶段 8 的专用向量不受影响。