VPN 网关 阶段 5:管理后台 Implementation Plan
VPN 网关 阶段 5:管理后台 Implementation Plan
- 日期:2026-09-22
- 分支:
codex/vpn-phase5-admin-console(stacked 在codex/vpn-phase4-pure-logic-and-api之上) - 规格:内嵌 VPN 网关(WireGuard)设计 §4.3、§10.1–§10.8、§12.1、§15 阶段 5
- 决策载体:ADR 0002
- 前置阶段:阶段 4 PR 记录(9 个操作、10 个稳定码、
server.vpn配置)
1. 目标
交付规格 §10 的全部条目:
web/src/api/vpn.ts:9 个操作的 API 客户端与类型,范式照web/src/api/credentials.ts。web/src/views/VpnPeers.vue:列表 + 创建/编辑对话框 + 使用说明抽屉(含一次性 reveal)。web/src/router.ts与web/src/layouts/AppShell.vue:/vpn路由与导航项(不按 admin 门禁)。web/src/i18n/messages/{zh-CN,en-US}.ts:navigation.vpn与完整vpn.*文案,两侧键集合一致。web/src/views/Servers.vue:节点 VPN 状态区块,数据来自GET /api/v1/vpn-nodes。- 前端测试与构建产物同步(
npm test -- --run、npm run build、scripts/verify-web-embed.sh)。
2. 非目标
- 不写数据面(阶段 6)、不改 Agent(阶段 7)、不加指标/Grafana/告警(阶段 8)。
- 不新增任何前端依赖:规格 §10.5 已按 YAGNI 排除二维码,
package.json零改动。 - 不改 Go 代码、不改 OpenAPI、不改 Schema:阶段 4 的契约就是本阶段的输入。
web/dist与internal/server/web_dist都在.gitignore里,因此构建产物不入库。 - 不做活跃流列表页:
GET /api/v1/vpn-peers/{peerId}/flows属阶段 6,本版按规格 §10.3 把活跃流排除在列表之外,只在详情抽屉里给出「看 Grafana」的指引。
3. 前置核实结论(实测,非推断)
web/node_modules已安装;package.json的脚本是test: vitest run、build: vite build && node scripts/sync-web-dist.mjs,构建会把web/dist同步进internal/server/web_dist(//go:embed all:web_dist,两者均被.gitignore忽略)。web/src/api/client.ts:412抛出new APIError(payload.msg, response.status, payload.code, data?.error, data), 因此阶段 4 的稳定码在error.domain上可直接分支,error.details是整个data。- i18n 键对齐有双向守卫:
src/tests/i18n.spec.ts:44(结构相同)与:79-85(分别报告缺失与多余,并断言长度相等)。新增键必须两侧同时加。 - 菜单在
src/layouts/AppShell.vue:38的menuItems();非 admin 段落以['/credentials','navigation.credentials']结尾,规格 §10.1 要求在其后插入['/vpn','navigation.vpn']。 - 路由集中在
src/router.ts的一行式数组里,meta:{auth:true}表示需登录、meta:{admin:true}表示需管理员;/routes与/credentials都只有auth。 - 本版服务端事实(阶段 4 PR 记录已实测):
GET /api/v1/vpn-nodes与GET /api/v1/vpn-peers/{id}/flows返回501 vpn_not_implemented;POST .../config:reveal返回409 vpn_node_disabled;icmpEnabled: true的签发返回409 vpn_agent_capability_missing;enabled: false时读操作返回空列表、写操作返回 409。 前端必须把这四种情况渲染成明确的、指向服务端配置或后续版本的说明, 而不是通用「操作失败,请重试」。 - 视图测试有两种既有范式:源码标记断言(
credentials-view.spec.ts)与挂载 +stubApi(sso-providers.spec.ts,harness 在src/tests/api-stub.ts)。stubApi对未声明的请求抛错,因此漏接端点会在测试里立刻暴露。 - Agent 下拉复用
src/views/token-form.ts的loadAgentsForSelection(getAgents)与filterAgents(Routes.vue:227已这么用);CIDR 多值输入复用Routes.vue:468的parseCIDRList范式(逗号分隔文本 + 客户端校验,非法即阻止提交)。 - 一次性密钥展示的既有范式是
src/components/TokenSecretDialog.vue(23 行)与Credentials.vue的clearSensitiveKeyInput:离开即清空,不落localStorage/sessionStorage。
4. 架构决策
D1:api/vpn.ts 只做传输与类型,不做文案
稳定码到文案的映射放在视图层(vpnErrorMessage(error, t),范式照
src/views/remote-server-errors.ts),因为同一个码在列表、表单和 reveal 三处需要不同措辞。
api/vpn.ts 只负责:查询串拼装、幂等键透传、reveal 的确认头与 acknowledgeRisk、
以及把服务端投影类型逐字声明出来(不声明四个密封列,与 VPNPeer projection 的
additionalProperties: false 一致)。
D2:reveal 的私钥只活在组件内存里,且关抽屉即清空
config:reveal 返回完整 ini(含 peer 私钥)。前端纪律:
- 私钥不进
localStorage/sessionStorage/pinia持久层,只存ref。 - 抽屉关闭、路由离开、复制完成后都不保留明文;
revealVisible置 false 时同步清空。 - 每次显示都要求用户显式点「显示」并确认风险,不自动 reveal。
- 复制用
navigator.clipboard,失败时降级为选中文本提示,不写临时文件。
D3:数据面缺席是状态,不是错误
501 vpn_not_implemented、409 vpn_node_disabled、409 vpn_agent_capability_missing
三者都渲染成 el-alert type="info"(而非 error)加专属文案,因为用户没有做错任何事:
能力属后续版本或需要服务端开关。Servers.vue 的 VPN 区块在 501 时显示
「本版未提供网关运行时状态」,不弹全局错误提示、不重试风暴。
D4:表单校验与服务端逐字对齐,校验失败不提交
名称:1–128 字符(服务端 PeerSpec.ValidateFields);CIDR:IPv4,parseCIDRList 同构;
端口:1–65535 整数,逗号分隔,空 = 不限;expiresAt:必须晚于当前时间;
备注 ≤256。字段级错误用 el-form-item 的 rules 呈现,任何一项非法就不发请求
(服务端仍会再校验一次,前端校验只是省一次往返,不是信任边界)。
D5:列表不显示活跃流,IP 池概览来自 vpn-nodes
规格 §10.3 要求列表顶部展示 IP 池使用概览,但该数据只由 GET /api/v1/vpn-nodes 提供,
而它在本版是 501。因此概览区做成条件渲染:拿到数据就显示总量/已分配/剩余,
拿不到(501)就显示一行说明。不把 501 缓存成「0 已分配」这种假数据。
D6:/vpn 不加 admin 门禁,可见范围由服务端决定
与 /routes、/credentials 一致:普通用户只看自己的 peer(阶段 4 已在分页语义内过滤),
管理员看全部。前端不做角色分支,避免与服务端过滤产生两套真相。
5. 全局约束
- 每个任务先写失败测试,实测红灯原文,再写最小实现(
AGENTS.mdTDD 流程)。 web/package.json与package-lock.json零改动(不新增依赖)。- Go 代码、
docs/api/openapi.yaml、migrations/、internal/**零改动。 - i18n 两侧键集合必须完全一致;不得留
TODO或英文占位。 - 一个任务一个提交,
<type>(<scope>): <subject>,subject ≤50 字、祈使句、无句号。 - 私钥、幂等键、确认头一律不落日志;测试里不得断言明文私钥被写入任何持久层。
- 文案不得宣称数据面可用(阶段 4 的文档纪律同样适用于前端)。
- 时点记录零改写:阶段 4 的计划与 PR 记录只链接不修改。
6. 文件清单
| 文件 | 任务 | 内容 |
|---|---|---|
docs/superpowers/plans/2026-09-22-vpn-phase5-admin-console.md |
— | 本计划 |
web/src/api/vpn.ts |
T1 | 类型 + 9 个操作的客户端 |
web/src/tests/vpn-api.spec.ts |
T1 | 查询串、幂等键、reveal 三前置、类型不含密封列 |
web/src/views/vpn-errors.ts |
T2 | 稳定码 → i18n 文案映射(照 remote-server-errors.ts) |
web/src/views/VpnPeers.vue |
T2 | 列表 + 创建/编辑 + 使用说明抽屉 |
web/src/tests/vpn.spec.ts |
T2 | 挂载测(stubApi)+ 源码标记断言 |
web/src/router.ts |
T3 | {path:'/vpn', component:VpnPeers, meta:{auth:true}} |
web/src/layouts/AppShell.vue |
T3 | menuItems() 插入 ['/vpn','navigation.vpn'] |
web/src/i18n/messages/zh-CN.ts、en-US.ts |
T3 | navigation.vpn 与 vpn.* 全量文案 |
web/src/tests/vpn-i18n.spec.ts |
T3 | vpn.* 键在两侧都存在且非空 |
web/src/views/Servers.vue |
T4 | 节点 VPN 状态区块(含 501 状态) |
web/src/tests/servers-vpn.spec.ts |
T4 | 200 渲染与 501 降级各一例 |
docs/pull-requests/2026-09-22-vpn-phase5-admin-console.md |
T5 | PR 记录 |
docs/pull-requests/README.md、docs/superpowers/plans/README.md |
T5 | scripts/gen_doc_index.py 生成 |
明确不改
web/package.json、web/package-lock.json(零新依赖)。- 任何 Go 文件、
docs/api/openapi.yaml、migrations/、deploy/。 - 阶段 4 的计划与 PR 记录、ADR 0002、既有 spec(时点记录零改写)。
web/src/api/client.ts(APIError已能承载domain,不需要扩展)。
7. 任务分解
T1:api/vpn.ts
Step 1(红灯):web/src/tests/vpn-api.spec.ts 用 stubApi 断言
listVpnPeers({status,keyword,cursor,limit}) 拼出的查询串、createVpnPeer 带
Idempotency-Key、revokeVpnPeer 用 DELETE、rotateVpnPeer 用 POST .../rotate、
revealVpnPeerConfig 同时带 X-VPN-Config-Reveal-Confirm 与 Idempotency-Key
且 body 为 {acknowledgeRisk:true}、listVpnNodes 与 listVpnPeerFlows 存在。
Run npm test -- --run → 失败 Cannot find module '../api/vpn'。
Step 2(实现):web/src/api/vpn.ts;类型逐字对齐 VPNPeer projection。
Step 3(绿灯 + 提交):feat(web): add the vpn api client。
T2:VpnPeers.vue + 错误映射
Step 1(红灯):web/src/tests/vpn.spec.ts 挂载视图,断言:列表渲染名称与 VPN IP;
创建表单校验非法 CIDR 时不发请求;reveal 返回 409 时渲染 info 级说明且不抛全局错误;
抽屉关闭后私钥 ref 被清空(用 container.textContent 不再包含明文断言)。
Run → 失败 Cannot find module '../views/VpnPeers.vue'。
Step 2(实现):vpn-errors.ts + VpnPeers.vue(复用 PageHeader/DataState/StatusTag、
token-form.ts 的 agent 选择、Routes.vue 的 CIDR 解析范式)。
Step 3(绿灯 + 提交):feat(web): manage vpn peers from the console。
T3:路由、导航与 i18n
Step 1(红灯):vpn-i18n.spec.ts 断言 navigation.vpn 与 vpn.* 关键键在 zh/en 两侧都存在且非空;
routes.spec.ts 风格断言 router.getRoutes() 含 /vpn 且 meta.auth === true、meta.admin 未设置;
断言 AppShell.vue 的 menuItems() 在 /credentials 之后含 ['/vpn','navigation.vpn']。
Run → 失败(键与路由都不存在)。
Step 2(实现):三处最小改动 + 两份文案。
Step 3(绿灯 + 提交):feat(web): route and translate the vpn console。
T4:Servers.vue 节点 VPN 状态
Step 1(红灯):servers-vpn.spec.ts 用 stubApi 分别给出 200(节点启用、子网、已分配、peer 数、
ICMP 能力)与 501 vpn_not_implemented 两种响应,断言前者渲染数值、后者渲染说明文案且不渲染 0。
Run → 失败(listVpnNodes 未被 Servers.vue 调用 / 断言的文案键不存在)。
Step 2(实现):Servers.vue 增加区块,条件渲染,501 走 D3 的状态而非错误。
Step 3(绿灯 + 提交):feat(web): show vpn status on the node page。
T5:构建、嵌入产物、门禁与 PR 记录
npm test -- --run 全绿 → npm run build → scripts/verify-web-embed.sh →
go build ./...(确认 embed 目录仍可编译)→ go test ./scripts/ -count=1(文档主张守卫)→
python3 scripts/gen_doc_index.py 两次确认幂等 → 死链核验 →
写 docs/pull-requests/2026-09-22-vpn-phase5-admin-console.md →
提交 docs(pull-requests): record vpn admin console。
8. 整体验证
cd web && npm test -- --run && npm run build && cd ..
bash scripts/verify-web-embed.sh
go build ./... && go vet ./... && go test ./scripts/ -count=1
git status --porcelain go.mod go.sum web/package.json web/package-lock.json # 必须为空
git diff --check
python3 scripts/gen_doc_index.py && python3 scripts/gen_doc_index.py && git status --porcelain
git diff --name-status codex/vpn-phase4-pure-logic-and-api..HEAD -- docs/superpowers docs/pull-requests docs/architecture/adr
Go 侧只跑 go build/go vet/scripts 守卫:本阶段不改任何 Go 源文件,
完整 go test ./... 与 -race 在阶段 4 已对同一份 Go 代码验证过,重跑不产生新信息。
无 Docker/Compose 与 Schema 改动,容器与迁移验证不适用。
9. 回滚注意事项
- 全部是新增文件 + 四处受控修改(
router.ts一行、AppShell.vue一个数组项、 两份 i18n 文案、Servers.vue一个区块),git revert对应提交即可,无数据与配置迁移。 - 构建产物未入库,回滚后需要重跑
npm run build让internal/server/web_dist与源码一致;scripts/verify-web-embed.sh会在这两者漂移时失败,因此漏跑会被门禁发现。 - 本阶段不改变任何服务端行为:即使前端已合并而阶段 4 被回滚,
/vpn页面会收到 404 并渲染通用错误——因此合并顺序必须是阶段 4 在前(规格 §15 的依赖链 4 → 5)。 - i18n 键一旦发布即被两侧守卫锁定,删除键必须同时删两侧,否则
i18n.spec.ts失败。