VPN 网关 阶段 4:纯逻辑包与管理 API Implementation Plan
VPN 网关 阶段 4:纯逻辑包与管理 API Implementation Plan
- 日期:2026-09-21
- 分支:
codex/vpn-phase4-pure-logic-and-api(stacked 在codex/vpn-phase3-schema-v15之上) - 规格:内嵌 VPN 网关(WireGuard)设计 §5.1、§5.3、§8、§9、§11、§12.1、§14、§15 阶段 4
- 决策载体:ADR 0002
- 前置阶段:阶段 3 PR 记录(Schema v15、
VPNPeerRepository、VPNIPLeaseRepository)
1. 目标
交付规格 §15 的阶段 4:
internal/vpn/纯逻辑包:密钥、错误码、IP 池切分与分配、逐包策略、peer 规格校验与规范编码、 WireGuard ini 渲染。零 I/O、零新依赖、无 build tag,因此可完全单元测试。server.vpn配置:形状对标internal/config/config.go:152的ProxyEntryConfig, 默认enabled: false,全部键有默认值与校验。internal/server/vpn_peer_service.go:签发、吊销、轮换、reveal、列表、详情的业务编排。internal/server/vpn_peer_api.go:规格 §9 的 9 个端点中本阶段可独立成立的 7 个, 挂到internal/server/api.go:331的 switch。docs/api/openapi.yaml同步与全套 API 测试(未授权、越权、分页、幂等、错误响应)。
2. 非目标
- 不写数据面:
internal/server/vpn_device.go/vpn_stack.go/vpn_packet.go/vpn_flows.go/vpn_icmp.go/vpn_lifecycle.go属阶段 6。 - 不新增任何 Go 依赖:
gvisor.dev/gvisor与golang.zx2c4.com/wireguard-go属阶段 6, 只被//go:build vpn文件引用。go.mod/go.sum必须零改动。 - 不改 Schema:
SchemaVersion保持 15,migrations/零改动。阶段 3 的两张表已够用。 - 不动前端:
VpnPeers.vue、api/vpn.ts、导航与 i18n 属阶段 5,web/零改动。 - 不动 Agent:ICMP 能力协商与
internal/agent/icmp_echo.go属阶段 7。 - 不做 Grafana Row、告警规则、build tag 变体与发布脚本:属阶段 8。
- 两个数据面依赖端点本阶段不实现:
GET /api/v1/vpn-peers/{peerId}/flows需要阶段 6 的内存流表,GET /api/v1/vpn-nodes需要阶段 6 的网关运行时状态。二者返回501 vpn_not_implemented并在 OpenAPI 中标注x-tunnelmesh-phase: 6,不返回伪造的空数据(对齐AGENTS.md「优雅降级不得返回假数据」与阶段 1 「不宣称未验证能力」的纪律)。
3. 前置核实结论(实测,非推断)
- 标准库可以生成 WireGuard 兼容密钥。
crypto/ecdh的 X25519 与 RFC 7748 §6.1 官方向量逐字节一致 (实测:alice/bob 的 base-point 乘法与共享密钥三项全部true)。WireGuard 的密钥就是 32 字节 X25519 标量的标准 base64,因此keys.go只需crypto/ecdh+encoding/base64。ecdh.X25519().NewPrivateKey(raw)接受任意 32 字节并在标量乘时执行 RFC 7748 的 clamping, 与 wireguard-go 的推导等价;公钥 base64 长度恒为 44 字符,vpn_peers.public_key VARCHAR(64)足够。 routing.Policy可直接包装。internal/routing/policy.go:24的NewPolicy(cidrs []string, ports any)与:102的Validate(ip net.IP, port int), 加上:129的IsDangerousAddress,正是internal/proxyentry/target.go:25已经用过的手法。 注意routing.Policy.Validate只接受 IPv4(ip.To4() == nil即拒), 因此 VPN 策略天然继承「IPv4-only」这条既有约束。proxyentry.IsPrivateTarget已是可复用的私网判定(internal/proxyentry/target.go:113):IsPrivate || IsLoopback || IsLinkLocalUnicast || IsLinkLocalMulticast || IsUnspecified。internal/vpn不得重复实现,但也不能 importinternal/proxyentry(那是 tp-* 入口的包)。 决策见 D6。- 错误码风格:
internal/proxyentry/errors.go:58的Error{Status, Code, message, headers}+NewError(...)+ 包级var Err* = NewError(...)集中声明,且注释要求「codes 是公开契约, 改名需 deprecation 窗口」。credential_secret_unavailable已存在,规格 §12.1 要求复用它。 - API 挂载点:
internal/server/api.go:331的switch parts[0];响应封装是apiEnvelope{Code, Msg, Data}(api.go:285),错误走writeAPIError(w, status, msg)把msg放进data.error;decodeJSON强制DisallowUnknownFields且限 1 MiB;queryLimit默认 50、上限 500;splitPath已剥掉/api/v1前缀。 - reveal 范式:
internal/server/token_api.go:71起——要求Idempotency-Key、 确认头、acknowledgeRisk,响应Cache-Control: no-store,并写审计。credential_api.go:120的handleExtractSSHPublicKey是同一纪律的简化版。 - 配置默认值集中在一张 map:
internal/config/config.go:503起以"server.webssh.ticket_ttl": 30 * time.Second的形式声明;server.vpn.*必须同样登记, 否则mapstructure解码后是零值而不是文档承诺的默认值。 - 存储层能力已就绪(阶段 3):
db.VPNPeers()提供Create/Get/GetByPublicKey/GetByNodeAndIP/Update/SetStatus/List/ListByNode/CountByNode/CountByOwner,db.VPNIPLeases()提供AcquireSubnet/Renew/Release/Get/ListByHolder/AddAllocated; sentinel 为ErrVPNPeerConflict、ErrVPNPeerRevoked、ErrVPNIPLeaseHeld、ErrVPNIPLeaseStaleEpoch。vpn_peers.allowed_ips/allowed_ports是存储层不解析的不透明文本, 其规范编码的唯一所有者就是本阶段要写的internal/vpn。 storage.VPNPeer有 22 列,其中PrivateKeyCiphertext/Nonce/KeyID/Version四列 与credentials.secret_*同构,密封走internal/auth/secret_store.go;TUNNELMESH_TOKEN_ENCRYPTION_KEY缺失时既有身份/凭据特性返回 503credential_secret_unavailable。- 文档主张守卫仍然生效:
go test ./scripts/会扫描AGENTS.md、双语 README、docs/、deploy/五个活文档根。阶段 4 新增的用户文档不得宣称 VPN 数据面已可用——本阶段交付的是管理面, 用户还不能真正连通。措辞必须是「已批准、分阶段实施中、当前版本尚未提供数据面」。 docs/api/openapi.yaml存在且是接口契约的唯一权威,AGENTS.md要求每次接口行为变更同步更新。- 时点记录不可改写:阶段 3 的计划、PR 记录与 ADR 0002 只能新增不能修改; 本阶段引用它们时用链接,不改其正文。
4. 架构决策
D1:internal/vpn 是纯函数包,不 import 存储与 HTTP
internal/vpn 只依赖标准库与 internal/routing。它不认识 storage.VPNPeer、不认识
http.ResponseWriter,因此可以脱离数据库与网络做穷举单元测试,也不会把持久化细节渗进领域逻辑。
持久化模型与领域模型之间的映射只存在于 internal/server/vpn_peer_service.go 一处。
D2:领域侧只定义 PeerSpec,不复制 22 列的存储模型
storage.VPNPeer 是持久化记录(含 CreatedAt、PrivateKeyVersion 等只有存储关心的字段)。
vpn.PeerSpec 是校验并解析后的输入:AllowedIPs 是 []*net.IPNet、AllowedPorts 是 []int,
而不是字符串。二者不是同一层的东西,复制一份 22 字段的镜像结构只会制造漂移。
PeerSpec 同时是 allowed_ips/allowed_ports 规范编码的唯一所有者(D3)。
D3:规范编码由 internal/vpn 独占
阶段 3 承诺「存储层从不解析 allowed_ips/allowed_ports」,那个承诺要成立,
就必须有且只有一个地方定义它们的文本形式:
vpn.EncodeAllowedIPs([]*net.IPNet) string:按String()输出、排序去重、逗号分隔、无空格。vpn.EncodeAllowedPorts([]int) string:升序去重、逗号分隔;空集合编码为空串(= 不限端口)。vpn.ParseAllowedIPs(string)/vpn.ParseAllowedPorts(string):反向解析,非法输入返回ErrPeerInvalid。
排序是必需的:同一组 CIDR 的两种书写顺序必须编码成同一个字符串,否则 「改了一次顺序」会在审计里显示成一次策略变更,幂等键也会失效。
D4:IP 池切分是纯函数,占用判定靠回调,持久化仍归 Repository
vpn.Pool 只回答「这个池能切出哪些 /N」「这个 /N 里哪些 /32 可用」,
不查库。AllocateAddress(subnet, taken func(net.IP) bool) 用回调把「已占用」的判断交给调用方,
调用方(Service)用 VPNPeerRepository.GetByNodeAndIP 实现它。这样纯逻辑可测,
权威事实仍在 vpn_peers.UNIQUE(node_id, vpn_ip),与阶段 3 的设计一致。
约束:
ip_pool必须是 IPv4、前缀长度 ≤ 24,node_subnet_size必须满足poolBits < size ≤ 30(/30才有 2 个可用/32)。- 切出的子网总数上限 4096,超出即
ParsePool失败。没有上限时一个/8配/24会产生 65536 个*net.IPNet,一次配置错误就能把内存打满。 - 每个子网的第一个可用地址保留给节点自己的 VPN 接口(
Pool.NodeAddress),AllocateAddress从第二个可用地址开始,且跳过网络地址与广播地址。 不保留的话 Server 的 netstack 会和第一个 peer 抢同一个/32。
D5:逐包策略包装 routing.Policy,并叠加 VPN 专属规则
vpn.PacketPolicy 的判定顺序是安全语义的一部分,不可调换:
- 分片 →
fragment_dropped(非首片无法判定端口,放行即等于绕过端口白名单)。 - 超长 →
oversize_dropped(MTU来自配置)。 - 协议号白名单:TCP=6、UDP=17、ICMP=1;其余 →
protocol_unsupported。 ICMP 且 peer 未开icmp_enabled→icmp_unsupported。 routing.IsDangerousAddress(dst)→metadata_denied(云 metadata、组播、非路由地址恒拒, 与tp-*和 Agent policy 共用同一 deny list)。!allowPrivateTargets && IsPrivateTarget(dst)→target_denied。- peer
AllowedIPs不含 dst →target_denied。 - TCP/UDP 且端口不在
AllowedPorts→port_denied。
error_class 字符串必须与规格 §12.2 逐字一致,因为它们会直接成为 Prometheus 的低基数标签值。
peer_unknown/peer_revoked/peer_expired/capacity_exhausted/rate_limited/egress_*/icmp_timeout/
stack_error 属阶段 6 的数据面,本阶段只定义常量、不产生它们。
D6:私网判定下沉到 internal/vpn,proxyentry 改为委托
proxyentry.IsPrivateTarget(target.go:113)是导出的既有 API,internal/vpn 需要同一个判定,
但两者互不 import(proxyentry 是 tp-* 入口包,vpn 是 VPN 领域包)。三个选项:
- (a) 在
internal/vpn复制一份 → 违反 DRY,且两份实现迟早漂移,而这是一个安全判定。 - (b)
internal/vpnimportinternal/proxyentry→ 让 VPN 领域依赖 tp-* 入口,方向错误。 - (c) 把实现下沉到
internal/routing(两包都已依赖它),proxyentry.IsPrivateTarget保留为 一行委托,签名与行为完全不变。
选 (c)。internal/routing 已经是「目标地址是否危险」的权威包(IsDangerousAddress 就在那里),
私网判定与它同层。委托保持 proxyentry 的导出面不变,因此不破坏任何既有调用方与测试。
D7:server.vpn.enabled: false 时零 VPN 资源,且不注册路由以外的任何东西
配置默认关闭。关闭时:不生成密钥、不切 IP 池、不启动任何 goroutine、不注册 peer 热加载。
管理 API 的 7 个端点仍然注册(它们是既有 /api/v1 路由树的一部分),
但 Service 在 enabled: false 时对写操作返回 409 vpn_node_disabled,
读操作返回空列表——这样前端在阶段 5 可以先接上,而用户不会以为签发成功了。
server.vpn.enabled: true 而二进制没有 vpn tag 时的快速失败属阶段 6(数据面才需要 tag)。
D8:签发是「生成密钥 → 选节点子网 → 分配 /32 → 校验 Agent → 落库」的单一事务边界
顺序不可调换,理由:
- 密钥必须先生成,因为
vpn_peers.public_key是 UNIQUE,冲突要在落库前就知道。 - 子网租约必须先于 /32 分配拿到(
VPNIPLeases().AcquireSubnet), 否则两个节点可能同时认为自己拥有同一个/24。 - Agent 能力校验必须在落库前:规格 §12.1 的
409 vpn_agent_capability_missing是 「peer 要求 ICMP 但出口 Agent 未协商stream_icmp_echo.v1」。 阶段 7 才有该能力协商,因此本阶段:icmp_enabled: true的签发请求一律返回409 vpn_agent_capability_missing,并在错误信息里写明该能力属阶段 7。 这不是偷懒——返回成功会造出一个用户以为能 ping、实际不能 ping 的 peer。 - 分配成功后必须
AddAllocated(+1);落库失败必须回滚计数(AddAllocated(-1)), 否则计数器会永久漂移。计数器只是派生值(阶段 3 已明确),但漂移会让耗尽判定失效。
D9:吊销与轮换复用阶段 3 的终态语义
- 吊销 =
SetStatus(revoked),不删行(阶段 3 的 Repository 没有Delete)。404 vpn_peer_not_found用于「不存在或对当前主体不可见」;吊销后的 peer 仍然可读, 但ListByNode不再返回它,因此数据面装载不到。 - 轮换 = 生成新密钥对 →
Update覆盖四个密封列与public_key→ 返回新配置摘要。 旧公钥立即失效,因为 WireGuard 握手只认public_key。 轮换不换 IP:peer 的地址是用户配置的一部分,换掉它等于静默破坏用户的连通性。 - 对已吊销 peer 轮换 →
409 vpn_peer_conflict(ErrVPNPeerRevoked映射), 因为吊销是终态,复活一个退役密钥正是阶段 3 刻意禁止的。
D10:reveal 严格照 tokens/{tokenId}/reveal 范式
POST /api/v1/vpn-peers/{peerId}/config:reveal 要求 X-VPN-Config-Reveal-Confirm、
Idempotency-Key、body 里 acknowledgeRisk: true;响应 Cache-Control: no-store;写审计。
返回的是完整 ini(含 peer 私钥),因此:
- 私钥只在响应体里出现一次,不落日志、不进指标、不进审计详情。
- 审计记录的
resource_type/resource_id只写 peer ID,details不含任何密钥材料。 - 密文存储不可用 →
503 credential_secret_unavailable(复用既有码,不新造)。 - 普通用户只能 reveal 自己的 peer;管理员可 reveal 任意 peer。权限在服务端判定, 不信任 body 里的 owner。
D11:权限过滤在分页语义内完成
GET /api/v1/vpn-peers 对普通用户注入 VPNPeerFilter{OwnerUserID: principal.UserID},
管理员不注入。这条在阶段 3 已由 Repository 契约测试守住(owner 过滤是 WHERE 条件),
本阶段的 API 测试必须从 HTTP 层再验一次:用户 A 的列表里不能出现用户 B 的 peer,
且分页游标续页也不能出现。
D12:两个未实现端点返回 501 而不是 200 空数据
GET .../flows 与 GET /api/v1/vpn-nodes 返回
501 vpn_not_implemented,msg 指明所属阶段。理由:返回 200 {"items":[]} 会让调用方
(尤其是阶段 5 的前端)把「还没实现」误读成「没有活跃流」,
而这正是 AGENTS.md「优雅降级不得返回兜底假数据」要防的事。
5. 全局约束
- 每个任务先写失败测试,实测红灯原文,再写最小实现(
AGENTS.mdTDD 流程)。 go.mod/go.sum零改动。任何import新第三方包都视为违规。migrations/、internal/storage/、web/、deploy/零改动(阶段 3 已交付存储层)。 唯一例外:D6 会改internal/proxyentry/target.go与其测试,以及internal/routing/policy.go。- 一个任务一个提交,
<type>(<scope>): <subject>,subject ≤50 字、祈使句、无句号。 - 不执行
git commit/push之外的破坏性 git 操作;本阶段已获用户授权提交与推送。 - 日志、审计、指标、错误信息一律不含私钥、包载荷、握手字节、DSN、Token。
error_class与code字符串是公开契约,逐字来自规格 §12.1/§12.2,不得自创或改名。- 新增用户文档不得宣称数据面可用(前置核实结论 10)。
- 时点记录(阶段 3 的计划/PR 记录、ADR 0002、既有 spec)零改写。
- 阶段 4 不引入
//go:build vpn:internal/vpn与管理 API 在所有构建变体里都存在。
6. 文件清单
| 文件 | 任务 | 内容 |
|---|---|---|
docs/superpowers/plans/2026-09-21-vpn-phase4-pure-logic-and-api.md |
— | 本计划 |
internal/routing/policy.go |
T1 | 新增 IsPrivateTarget(ip net.IP) bool(从 proxyentry 下沉,D6) |
internal/proxyentry/target.go |
T1 | IsPrivateTarget 改为委托 routing.IsPrivateTarget,导出签名不变 |
internal/routing/routing_test.go |
T1 | IsPrivateTarget 的表驱动测试 |
internal/vpn/doc.go |
T1 | 包注释:职责、边界、为什么不 import 存储与 HTTP |
internal/vpn/errors.go |
T1 | Error{Status,Code,message}、NewError、规格 §12.1 的 9 个稳定码 |
internal/vpn/errors_test.go |
T1 | 码唯一性、状态码正确性、Error()/errors.Is 行为 |
internal/vpn/keys.go |
T2 | GenerateKeyPair、EncodeKey/DecodeKey、ValidatePublicKey |
internal/vpn/keys_test.go |
T2 | RFC 7748 向量、长度/字符集校验、往返、拒绝非法 base64 |
internal/vpn/ippool.go |
T3 | ParsePool、Pool.Subnets/NodeSubnet/NodeAddress/AllocateAddress/Contains |
internal/vpn/ippool_test.go |
T3 | 切分数量、边界、上限、耗尽、网络/广播/节点地址跳过 |
internal/vpn/peerspec.go |
T4 | PeerSpec、Validate、Encode/ParseAllowedIPs、Encode/ParseAllowedPorts |
internal/vpn/peerspec_test.go |
T4 | 规范编码的排序去重与往返、非法输入、空集合语义 |
internal/vpn/policy.go |
T5 | PacketPolicy、Packet、Allow、error_class 常量 |
internal/vpn/policy_test.go |
T5 | D5 的 7 步判定顺序逐步覆盖,含分片与协议号白名单 |
internal/vpn/config.go |
T6 | RenderPeerConfig(ini)、RenderInterface、RenderPeerSection |
internal/vpn/config_test.go + internal/vpn/testdata/*.ini |
T6 | 黄金文件测试 |
internal/config/config.go |
T7 | VPNConfig 结构、ServerConfig.VPN 字段、server.vpn.* 默认值与校验 |
internal/config/config_test.go |
T7 | 默认值、非法值快速失败、优先级(flag > env > file > default) |
docs/operations/configuration.md、docs/operations/config-examples.md |
T7 | 全部键、取值范围、默认值;三份示例同步 |
internal/server/vpn_peer_service.go |
T8 | VPNPeerService:签发/吊销/轮换/reveal/列表/详情 |
internal/server/vpn_peer_service_test.go |
T8 | D8 的顺序与回滚、D9 终态、权限、配额、密钥密封 |
internal/server/vpn_peer_api.go |
T9 | 7 个端点的 Handler + 2 个 501 |
internal/server/vpn_peer_api_test.go |
T9 | 未授权、越权、分页、幂等、错误响应、no-store |
internal/server/api.go |
T9 | switch 新增 case "vpn-peers" 与 case "vpn-nodes";Service 装配 |
docs/api/openapi.yaml |
T10 | 9 条路径、schema、稳定错误码、x-tunnelmesh-phase 标注 |
docs/pull-requests/2026-09-21-vpn-phase4-pure-logic-and-api.md |
T11 | PR 记录 |
docs/pull-requests/README.md 等四份索引 |
T11 | scripts/gen_doc_index.py 生成 |
明确不改
migrations/、internal/storage/**(阶段 3 交付,本阶段只消费其接口)。web/、deploy/、Dockerfile、scripts/build-release.sh。- 阶段 3 的计划、PR 记录、ADR 0002、既有 spec(时点记录零改写)。
internal/server/proxy_entry.go与tp-*相关行为(D6 只动IsPrivateTarget的实现位置)。
7. 任务分解
T1:私网判定下沉 + 错误码
Step 1(红灯):在 internal/routing/routing_test.go 新增 TestIsPrivateTarget,
覆盖 10.0.0.1、192.168.1.1、172.16.0.1、127.0.0.1、169.254.1.1、0.0.0.0、
::1、8.8.8.8、224.0.0.1、nil;在 internal/vpn/errors_test.go 新增
TestErrorCodesAreStableAndUnique(断言规格 §12.1 的 9 个码逐字存在、状态码正确、码不重复)。
Run go test ./internal/routing/ ./internal/vpn/ -count=1 → 预期编译失败 undefined: routing.IsPrivateTarget。
Step 2(实现):routing.IsPrivateTarget 用 proxyentry 的现有实现体;
proxyentry.IsPrivateTarget 改成 return routing.IsPrivateTarget(ip) 并把注释指向新位置;
internal/vpn/doc.go 与 errors.go。
Step 3(绿灯 + 提交):go test ./internal/routing/ ./internal/proxyentry/ ./internal/vpn/ -count=1
→ ok。提交 refactor(routing): move the private target check down a layer。
T2:密钥
Step 1(红灯):keys_test.go 断言
GenerateKeyPair 返回 44 字符 base64 私钥与公钥、DecodeKey(EncodeKey(x))==x、
RFC 7748 alice 向量(私钥 hex 7707…2c2a → 公钥 base64 hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=)、
ValidatePublicKey 拒绝 43/45 字符、非 base64、以及全零私钥导出的公钥可被接受但全零公钥被拒绝。
Run → 编译失败 undefined: GenerateKeyPair。
Step 2(实现):crypto/ecdh + encoding/base64;ValidatePublicKey 必须
DecodeKey 成功且长度为 32 字节,再用 ecdh.X25519().NewPublicKey 确认可导入。
Step 3(绿灯 + 提交):feat(vpn): generate wireguard keys with the stdlib。
T3:IP 池
Step 1(红灯):ippool_test.go 覆盖
10.64.0.0/16 + /24 → 256 个子网且首尾正确;node_subnet_size ≤ poolBits 或 > 30 → ErrIPPoolInvalid;
IPv6 池 → ErrIPPoolInvalid;/8 + /24(65536 > 4096)→ ErrIPPoolInvalid;
NodeAddress 是子网第一个可用地址;AllocateAddress 跳过网络地址、节点地址与广播地址;
taken 回调全部返回 true 时得到 ErrIPPoolExhausted;/30 子网只剩 1 个可分配地址。
Run → 编译失败 undefined: ParsePool。
Step 2(实现):纯函数 + 回调;子网上限常量 maxNodeSubnets = 4096。
Step 3(绿灯 + 提交):feat(vpn): carve node subnets out of the ip pool。
T4:PeerSpec 与规范编码
Step 1(红灯):peerspec_test.go 覆盖
EncodeAllowedIPs 排序去重(["10.1.0.0/16","10.0.0.0/16","10.1.0.0/16"] → "10.0.0.0/16,10.1.0.0/16")、
EncodeAllowedPorts 升序去重、空端口集合 → 空串、Parse∘Encode == 原集合、
非法 CIDR/端口/负数/超范围端口 → ErrPeerInvalid、PeerSpec.Validate 逐字段
(空公钥、非法公钥、空 VPNIP、MaxConcurrentFlows<0、PacketRateLimit<0、名称超长)。
Run → 编译失败 undefined: PeerSpec。
Step 2(实现)。
Step 3(绿灯 + 提交):feat(vpn): own the canonical peer policy encoding。
T5:逐包策略
Step 1(红灯):policy_test.go 按 D5 的 7 步逐步构造用例,
每步断言返回错误的 ErrorClass() 精确等于规格 §12.2 的字符串;
额外覆盖:判定顺序(一个既是分片又指向 metadata 的包必须报 fragment_dropped,
因为分片在前)、ICMP 关闭时 icmp_unsupported、IPv6 目标被 routing.Policy 拒绝、
AllowedIPs 为空时的语义(= 不允许任何目标,不是「不限」,与端口的空集合语义相反,
必须在注释与测试里都写明)。
Run → 编译失败 undefined: PacketPolicy。
Step 2(实现):包装 routing.Policy + routing.IsPrivateTarget。
Step 3(绿灯 + 提交):feat(vpn): evaluate per packet egress policy。
T6:ini 渲染与黄金文件
Step 1(红灯):config_test.go 用 testdata/peer.ini 做黄金文件比对;
先写测试与空的 golden 文件使其失败。
Run → FAIL(golden 不匹配)。
Step 2(实现):RenderPeerConfig(PeerConfigInput) (string, error),
输出 [Interface](PrivateKey、Address = <vpnIP>/32、MTU)与
[Peer](PublicKey = 节点公钥、AllowedIPs = 0.0.0.0/0 或 peer 策略、Endpoint、
PersistentKeepalive = 25)。渲染必须对值做 ini 转义校验(拒绝含 \n、;、# 的值),
否则一个恶意 peer 名称可以注入额外配置段。
Step 3(绿灯 + 提交):feat(vpn): render wireguard peer configuration。
T7:server.vpn 配置
Step 1(红灯):config_test.go 断言全部 15 个键的默认值(规格 §8 逐字)、
ip_pool 非法 → 加载失败并给出可操作的错误、node_subnet_size 越界 → 失败、
mtu 越界(< 576 或 > 1500)→ 失败、环境变量覆盖文件、flag 覆盖环境变量。
Run → 编译失败 undefined: VPNConfig。
Step 2(实现):VPNConfig 结构 + ServerConfig.VPN + 默认值 map + validate;
docs/operations/configuration.md 与 config-examples.md 同步(本地/集群 MySQL/集群 etcd 三份示例)。
Step 3(绿灯 + 提交):feat(config): add the server vpn section。
T8:Service
Step 1(红灯):vpn_peer_service_test.go 用真实 SQLite(照阶段 3 的 newVPNTestDB 思路)覆盖
签发成功路径(密钥生成、IP 分配、计数 +1、四个密封列非空且明文不入库)、
icmp_enabled: true → 409 vpn_agent_capability_missing、
池耗尽 → 409 vpn_ip_pool_exhausted、max_peers 触顶 → 503 vpn_capacity_exhausted、
落库失败时计数回滚、吊销后 ListByNode 不含它、对已吊销 peer 轮换 → 409、
普通用户读他人 peer → 404 vpn_peer_not_found(不是 403,避免枚举)、
enabled: false 时写操作 → 409 vpn_node_disabled。
Run → 编译失败 undefined: VPNPeerService。
Step 2(实现):internal/server/vpn_peer_service.go;密钥密封复用 internal/auth/secret_store.go。
Step 3(绿灯 + 提交):feat(server): orchestrate vpn peer lifecycle。
T9:API
Step 1(红灯):vpn_peer_api_test.go 从 HTTP 层覆盖 7 个端点的
未授权(401)、越权(404 而非 403)、分页(D11:A 的任何一页都不含 B 的行)、
幂等(同一 Idempotency-Key 重放 POST 返回同一结果、不产生第二个 peer)、
reveal 的三个前置条件各自缺失时的 400、reveal 响应的 Cache-Control: no-store、
两个 501 端点。
Run → 编译失败 a.vpnPeerService undefined。
Step 2(实现):vpn_peer_api.go + api.go 的 switch 与装配。
Step 3(绿灯 + 提交):feat(server): expose the vpn peer management api。
T10:OpenAPI
Step 1:docs/api/openapi.yaml 新增 9 条路径、VPNPeer* schema、稳定错误码枚举、
两条 x-tunnelmesh-phase: 6 标注。校验 YAML 可解析且与实现的路径逐字一致
(用一个测试断言 openapi 里出现的 /api/v1/vpn-* 路径集合与 api.go 实际注册的一致)。
Step 2(提交):docs(api): specify the vpn peer endpoints。
T11:门禁、索引、PR 记录
全量门禁 → 重新生成索引两次确认幂等 → 时点记录零改写核验 → 死链核验 →
写 docs/pull-requests/2026-09-21-vpn-phase4-pure-logic-and-api.md →
提交 docs(pull-requests): record vpn pure logic and api。
8. 整体验证
go build ./...
go vet ./...
go test ./... -count=1
go test -race ./... -timeout 30m -count=1
gofmt -l internal/vpn internal/routing internal/proxyentry internal/config internal/server
git diff --check
git status --porcelain go.mod go.sum # 必须为空
go test ./scripts/ -count=1 # 文档主张守卫
python3 scripts/gen_doc_index.py && python3 scripts/gen_doc_index.py && git status --porcelain
git diff --name-status codex/vpn-phase3-schema-v15..HEAD -- docs/superpowers docs/pull-requests docs/architecture/adr
前端未改动,npm test -- --run 与 npm run build 不是本阶段门禁。
无 Docker/Compose 改动,容器验证不适用。
无 Schema 改动,MySQL 侧迁移测试不适用;Service 与 API 测试跑在 SQLite 上,
MySQL 契约由阶段 3 的 Repository 测试覆盖(本机无实例时如实 SKIP)。
9. 回滚注意事项
- 本阶段全部是新增文件 + 两处受控修改(
routing.IsPrivateTarget下沉、api.goswitch 两个 case),git revert对应提交即可,无数据迁移、无配置迁移。 - D6 是唯一触及既有行为的改动:
proxyentry.IsPrivateTarget的导出签名与语义不变, 回滚它只需把实现体搬回去;internal/routing的新函数是纯增量。 server.vpn.enabled默认false,因此即使本阶段被合并而不回滚, 已部署实例的行为也完全不变——这本身就是止损开关。- 若阶段 5 的前端已经接上而阶段 4 被回滚,前端会收到 404; 因此阶段 4 与阶段 5 的合并顺序不可颠倒(规格 §15 的依赖链 4 → 5)。
- 稳定错误码一旦发布不得改名;若本阶段未合并即废弃,
vpn_*码可重新设计, 但已写进docs/api/openapi.yaml的码在合并后即成契约。