From f8d10c326b5d15e88d1180d968665977242fefca Mon Sep 17 00:00:00 2001 From: zaneliu Date: Mon, 13 Jul 2026 17:00:44 +0800 Subject: [PATCH 1/2] docs: plan egress IP uniqueness fix --- .planning/debug/egress-ip-unique-collision.md | 56 +++++++ .../plans/2026-07-13-egress-ip-uniqueness.md | 150 ++++++++++++++++++ .../2026-07-13-egress-ip-uniqueness-design.md | 39 +++++ 3 files changed, 245 insertions(+) create mode 100644 .planning/debug/egress-ip-unique-collision.md create mode 100644 docs/superpowers/plans/2026-07-13-egress-ip-uniqueness.md create mode 100644 docs/superpowers/specs/2026-07-13-egress-ip-uniqueness-design.md diff --git a/.planning/debug/egress-ip-unique-collision.md b/.planning/debug/egress-ip-unique-collision.md new file mode 100644 index 0000000..b80e0e3 --- /dev/null +++ b/.planning/debug/egress-ip-unique-collision.md @@ -0,0 +1,56 @@ +--- +status: investigating +trigger: "创建第二条未检测出口线路时,ip_address=0.0.0.0 触发 SQLite 唯一约束;需要代码修复并发布" +created: 2026-07-13T00:00:00Z +updated: 2026-07-13T00:00:00Z +--- + +## Current Focus + +hypothesis: egress_ips.ip_address 被错误用作唯一身份,但前端对所有未检测代理都写入 0.0.0.0,且多个代理也可能共享真实出口 IP,因此第二次创建或自动回填必然触发唯一冲突 +test: 先用旧 schema 复现重复 IP 插入失败,再应用移除唯一约束的迁移并验证绑定与外键完整 +expecting: 迁移后允许不同出口资源拥有相同或空的 ip_address,同时 host_egress_bindings 数据和约束保持不变 +next_action: 编写迁移、API 与前端失败测试 +reasoning_checkpoint: 已排除临时分配 192.0.2.x 和把 label 改为唯一字段;前者污染数据语义,后者可能让历史重复标签阻断升级 +tdd_checkpoint: pending-red + +## Symptoms + +expected: 可以创建多条尚未探测真实 IP 的代理线路,也可以保存共享同一出口 IP 的不同线路 +actual: 第一条线路占用 0.0.0.0 后,第二条创建返回唯一约束冲突;真实 IP 自动纠正也可能因重复地址失败 +errors: "ip address already exists" / SQLite UNIQUE constraint failed +reproduction: 连续创建两条不填写出口 IP 的代理线路 +started: v4.0 引入统一 0.0.0.0 占位后 + +## Eliminated + +- hypothesis: 为每条线路分配 192.0.2.x 占位地址可以根治 + reason: 只能规避创建冲突,仍会在共享真实出口 IP 时失败,并把保留网段伪装成业务数据 +- hypothesis: 把唯一约束迁移到 label 可以安全替代 + reason: label 不是资源身份,历史重复标签会导致迁移失败;资源已有 id 主键 + +## Evidence + +- timestamp: 2026-07-13T00:00:00Z + checked: web/admin/src/components/egress-ips/egress-ip-drawer.tsx + found: 未填写 ip_address 时无条件写入 0.0.0.0 + implication: 所有尚未检测的线路竞争同一个数据库唯一值 +- timestamp: 2026-07-13T00:00:00Z + checked: internal/store/migrations/0001_initial.sql + found: egress_ips.ip_address 定义为 TEXT NOT NULL UNIQUE,而 id 已经是 PRIMARY KEY + implication: ip_address 承担了不应有的资源身份职责 +- timestamp: 2026-07-13T00:00:00Z + checked: internal/store/repository/queries.go 和出口探测流程 + found: 探测会写入 detected_ip_address,运行时自动纠正还会更新 ip_address;不同线路可能得到同一实际出口地址 + implication: 唯一约束不仅影响占位值,也会影响合法的共享出口场景 +- timestamp: 2026-07-13T00:00:00Z + checked: GitHub PR #8 + found: PR 同样识别到 ip_address 唯一约束问题,但同时引入 label UNIQUE 并夹带安全、VNC、SSH 等无关改动 + implication: 本次应采用不新增业务唯一键的聚焦补丁发布 + +## Resolution + +root_cause: "" +fix: "" +verification: "" +files_changed: [] diff --git a/docs/superpowers/plans/2026-07-13-egress-ip-uniqueness.md b/docs/superpowers/plans/2026-07-13-egress-ip-uniqueness.md new file mode 100644 index 0000000..401615b --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-egress-ip-uniqueness.md @@ -0,0 +1,150 @@ +# 出口 IP 唯一约束修复实施计划 + +> **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:** 让多条代理线路可以处于未检测状态或共享同一真实出口 IP,并通过 +`v4.2.19` 自动升级现有 SQLite 数据库。 + +**Architecture:** 使用 SQLite 表重建迁移移除 `egress_ips.ip_address` 的唯一 +约束,同时临时备份并恢复引用它的绑定表。API 和前端统一用空字符串表达尚未 +检测的 IP,真实地址继续由探测流程写入。 + +**Tech Stack:** Go 1.26、`database/sql`、`modernc.org/sqlite`、React 19、 +TypeScript、Vitest、GitHub Actions。 + +## Global Constraints + +- v1 仍为单宿主机部署,不改变容器、隧道或出口验证架构。 +- 每个用户容器仍必须绑定至少一个出口资源。 +- 不新增第三方运行时依赖。 +- 所有仓库路径使用项目根目录相对路径。 +- 发布版本固定为 `v4.2.19`。 + +--- + +### Task 1: SQLite 升级迁移 + +**Files:** +- Create: `internal/store/repository/migration_0003_test.go` +- Create: `internal/store/migrations/0003_drop_egress_ip_address_unique.sql` + +**Interfaces:** +- Consumes: `migrator.RunMigrations(context.Context, *sql.DB, embed.FS)` +- Produces: 最终结构中 `egress_ips.ip_address TEXT NOT NULL` 且不唯一 + +- [ ] **Step 1: 写失败的升级测试** + +测试先建立仅应用 `0001`、`0002` 的旧数据库,写入用户、主机、出口和绑定, +再通过 migrator 应用 `0003`。断言重复 IP 可插入、绑定保留、外键仍生效。 + +- [ ] **Step 2: 运行测试确认红灯** + +Run: `go test ./internal/store/repository -run TestMigration0003 -count=1` + +Expected: 因缺少 `0003_drop_egress_ip_address_unique.sql` 或重复 IP 唯一冲突失败。 + +- [ ] **Step 3: 实现最小迁移** + +迁移按以下顺序执行:备份 `host_egress_bindings`、删除子表、重建 +`egress_ips`、复制数据、恢复子表和索引、删除备份表。 + +- [ ] **Step 4: 运行迁移测试确认绿灯** + +Run: `go test ./internal/store/repository -run TestMigration0003 -count=1` + +Expected: PASS。 + +### Task 2: API 空 IP 契约 + +**Files:** +- Modify: `internal/controlplane/http/admin_egress_ips_test.go` +- Modify: `internal/controlplane/http/admin_egress_ips.go` + +**Interfaces:** +- Consumes: `createEgressIPRequest.IPAddress`、`updateEgressIPRequest.IPAddress` +- Produces: 空字符串合法;非空值必须通过 `net.ParseIP` + +- [ ] **Step 1: 写创建空 IP 和更新非法 IP 的失败测试** + +创建请求传 `ip_address: ""` 应返回 201;更新请求传 `not-an-ip` 应返回 400。 + +- [ ] **Step 2: 运行测试确认红灯** + +Run: `go test ./internal/controlplane/http -run TestAdminEgressIPsHandler -count=1` + +Expected: 创建空 IP 用例得到 400,或更新非法 IP 用例得到 200。 + +- [ ] **Step 3: 实现统一校验** + +对创建和更新都执行 `strings.TrimSpace`;仅在非空时调用 `net.ParseIP`。 + +- [ ] **Step 4: 运行 API 测试确认绿灯** + +Run: `go test ./internal/controlplane/http -run TestAdminEgressIPsHandler -count=1` + +Expected: PASS。 + +### Task 3: 前端不再生成占位 IP + +**Files:** +- Create: `web/admin/src/lib/egress-ip-address.ts` +- Create: `web/admin/src/lib/__tests__/egress-ip-address.test.ts` +- Modify: `web/admin/src/components/egress-ips/egress-ip-drawer.tsx` + +**Interfaces:** +- Produces: `normalizeEgressIPAddress(value: string): string` +- Consumes: 表单 `values.ip_address` + +- [ ] **Step 1: 写失败的纯函数测试** + +断言空白输入返回空字符串,显式 IP 去除首尾空格后保持原值。 + +- [ ] **Step 2: 运行测试确认红灯** + +Run: `pnpm test:unit -- src/lib/__tests__/egress-ip-address.test.ts` + +Expected: 因模块或导出不存在失败。 + +- [ ] **Step 3: 实现函数并接入提交载荷** + +`normalizeEgressIPAddress` 只执行 `trim()`;抽屉提交时直接把结果放入 +`ip_address`,删除 `0.0.0.0` 回退逻辑和未使用的正则。 + +- [ ] **Step 4: 运行前端测试确认绿灯** + +Run: `pnpm test:unit -- src/lib/__tests__/egress-ip-address.test.ts` + +Expected: PASS。 + +### Task 4: 验证与发布 + +**Files:** +- Modify: `.planning/debug/egress-ip-unique-collision.md` + +- [ ] **Step 1: 运行格式化与定向验证** + +Run: `gofmt -w internal/controlplane/http/admin_egress_ips.go internal/controlplane/http/admin_egress_ips_test.go internal/store/repository/migration_0003_test.go` + +Run: `go test ./internal/store/... ./internal/controlplane/http/...` + +Run: `pnpm test:unit && pnpm typecheck && pnpm build` + +- [ ] **Step 2: 运行全量 Go 验证** + +Run: `go test ./...` + +Expected: PASS。 + +- [ ] **Step 3: 检查隐私、差异和迁移结构** + +确认新增文件没有绝对路径、凭据或个人信息;确认迁移后 +`PRAGMA foreign_key_check` 无结果。 + +- [ ] **Step 4: 提交并发布** + +提交聚焦改动,推送修复分支,合入 `main`,在合入提交上创建并推送 +`v4.2.19` 标签。 + +- [ ] **Step 5: 监控发布完成** + +确认 Release workflow、GitHub Release、控制面镜像和 managed-user 镜像均成功。 diff --git a/docs/superpowers/specs/2026-07-13-egress-ip-uniqueness-design.md b/docs/superpowers/specs/2026-07-13-egress-ip-uniqueness-design.md new file mode 100644 index 0000000..827b239 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-egress-ip-uniqueness-design.md @@ -0,0 +1,39 @@ +# 出口 IP 唯一约束修复设计 + +## 背景 + +管理后台在代理线路尚未检测到真实出口地址时,把 `ip_address` 保存为 +`0.0.0.0`。SQLite 的 `egress_ips.ip_address` 同时带有 `UNIQUE` 约束,因此 +第二条未检测线路无法创建;多个不同代理共享同一真实出口地址时,自动回填也会 +触发同类冲突。 + +`egress_ips.id` 已经是资源身份,`ip_address` 只是可重复的检测结果,不应承担 +唯一标识职责。 + +## 方案 + +1. 新增 SQLite 增量迁移,重建 `egress_ips` 并移除 `ip_address` 的 `UNIQUE` + 约束,保留 `TEXT NOT NULL`。 +2. 迁移期间暂存并重建 `host_egress_bindings`,保留全部绑定数据、唯一约束和 + 外键行为。 +3. 不给 `label` 增加新的唯一约束,避免历史重复标签阻断升级。 +4. API 创建和更新允许空字符串表示“尚未检测”,但继续拒绝非空的非法 IP。 +5. 管理后台不再生成 `0.0.0.0`,空值原样提交;已有 `0.0.0.0` 数据无需改写, + 因为迁移后它不再阻塞其他线路,后续探测仍可写入 `detected_ip_address`。 + +## 兼容性与失败处理 + +- 迁移在单个事务中执行,任一步失败时不留下半重建表。 +- `host_egress_bindings` 重建后恢复 `ON DELETE CASCADE`、`ON DELETE RESTRICT` + 和 `(host_id, egress_ip_id)` 唯一约束。 +- 新安装仍按全部迁移顺序得到相同最终结构。 +- 发布采用补丁版本 `v4.2.19`,不夹带 PR #8 中的安全、VNC 或 SSH 改动。 + +## 验证 + +- 从旧结构和真实绑定数据升级成功。 +- 升级后允许重复空 IP、`0.0.0.0` 和真实 IP。 +- 升级后绑定数据及外键约束仍有效。 +- API 创建接受空 IP,创建和更新拒绝非法非空 IP。 +- 前端空值序列化为空字符串,不再变成 `0.0.0.0`。 +- Go 全量测试、前端单元测试、类型检查和构建全部通过。 From 04ca86a46dde78c0107e5cb4a510318cadad128d Mon Sep 17 00:00:00 2001 From: zaneliu Date: Mon, 13 Jul 2026 17:14:35 +0800 Subject: [PATCH 2/2] fix(egress): allow duplicate undetected addresses --- .planning/debug/egress-ip-unique-collision.md | 45 +++++-- .../plans/2026-07-13-egress-ip-uniqueness.md | 34 +++--- .../controlplane/http/admin_egress_ips.go | 7 +- .../http/admin_egress_ips_test.go | 31 +++++ .../0003_drop_egress_ip_address_unique.sql | 50 ++++++++ .../store/repository/migration_0003_test.go | 110 ++++++++++++++++++ .../egress-ips/egress-ip-drawer.tsx | 11 +- .../lib/__tests__/egress-ip-address.test.ts | 12 ++ web/admin/src/lib/egress-ip-address.ts | 3 + 9 files changed, 267 insertions(+), 36 deletions(-) create mode 100644 internal/store/migrations/0003_drop_egress_ip_address_unique.sql create mode 100644 internal/store/repository/migration_0003_test.go create mode 100644 web/admin/src/lib/__tests__/egress-ip-address.test.ts create mode 100644 web/admin/src/lib/egress-ip-address.ts diff --git a/.planning/debug/egress-ip-unique-collision.md b/.planning/debug/egress-ip-unique-collision.md index b80e0e3..0d3351e 100644 --- a/.planning/debug/egress-ip-unique-collision.md +++ b/.planning/debug/egress-ip-unique-collision.md @@ -1,18 +1,18 @@ --- -status: investigating +status: verifying trigger: "创建第二条未检测出口线路时,ip_address=0.0.0.0 触发 SQLite 唯一约束;需要代码修复并发布" created: 2026-07-13T00:00:00Z -updated: 2026-07-13T00:00:00Z +updated: 2026-07-13T09:12:30Z --- ## Current Focus -hypothesis: egress_ips.ip_address 被错误用作唯一身份,但前端对所有未检测代理都写入 0.0.0.0,且多个代理也可能共享真实出口 IP,因此第二次创建或自动回填必然触发唯一冲突 -test: 先用旧 schema 复现重复 IP 插入失败,再应用移除唯一约束的迁移并验证绑定与外键完整 -expecting: 迁移后允许不同出口资源拥有相同或空的 ip_address,同时 host_egress_bindings 数据和约束保持不变 -next_action: 编写迁移、API 与前端失败测试 +hypothesis: 已确认;旧 schema 在第二条重复 ip_address 插入时稳定报 UNIQUE constraint failed,移除该约束后同一测试通过 +test: 迁移、API 和前端回归测试均完成红灯到绿灯;Go race、e2e、前端单测和生产构建已通过 +expecting: 提交并合入 main 后,v4.2.19 Release workflow、GitHub Release 和容器镜像全部成功 +next_action: 提交、推送、合入 main 并创建 v4.2.19 标签 reasoning_checkpoint: 已排除临时分配 192.0.2.x 和把 label 改为唯一字段;前者污染数据语义,后者可能让历史重复标签阻断升级 -tdd_checkpoint: pending-red +tdd_checkpoint: red-green-complete ## Symptoms @@ -47,10 +47,33 @@ started: v4.0 引入统一 0.0.0.0 占位后 checked: GitHub PR #8 found: PR 同样识别到 ip_address 唯一约束问题,但同时引入 label UNIQUE 并夹带安全、VNC、SSH 等无关改动 implication: 本次应采用不新增业务唯一键的聚焦补丁发布 +- timestamp: 2026-07-13T09:07:00Z + checked: 新增迁移、API 和前端回归测试的红灯结果 + found: 旧 schema 拒绝第二条 0.0.0.0;Create 空 IP 返回 400;Update 非法 IP 返回 200;前端规范化模块尚不存在 + implication: 三组测试分别命中数据库、API 和前端根因,不是无关环境失败 +- timestamp: 2026-07-13T09:08:00Z + checked: 最小实现后的定向测试 + found: 迁移测试、API 测试和前端测试全部通过;迁移测试同时验证重复标签、重复真实 IP、重复空 IP、绑定保留、RESTRICT 和 foreign_key_check + implication: 修复覆盖了数据模型冲突且未削弱绑定完整性 +- timestamp: 2026-07-13T09:10:12Z + checked: 扩大验证和 origin/main 类型检查基线 + found: go test ./...、34 个前端单测和 Vite 生产构建通过;typecheck 的剩余错误均可在 origin/main 复现,本分支未新增类型错误 + implication: 当前改动没有观察到 Go、前端运行构建或类型层面的新增回归 +- timestamp: 2026-07-13T09:12:30Z + checked: CI 等价验证与提交前代码审查 + found: 非 e2e Go 包在 race 和随机顺序下通过,e2e 包通过,前端 34 个单测与生产构建再次通过;审查确认空 ExpectedIP 会走现有 UpdateExpectedIP 回调自动回填 + implication: 修复满足当前 CI 质量门,且未破坏首次出口探测与自动纠正流程 ## Resolution -root_cause: "" -fix: "" -verification: "" -files_changed: [] +root_cause: egress_ips.id 已是主键,但 ip_address 仍被定义为 UNIQUE;前端又对所有未检测线路写入相同的 0.0.0.0,导致第二次创建和共享真实出口 IP 的自动回填发生合法数据冲突。 +fix: 新增 SQLite 表重建迁移移除 ip_address 唯一约束并保留 NOT NULL、绑定数据和外键;API 允许空 IP 但拒绝非法非空 IP;前端空值不再改写为 0.0.0.0。 +verification: 定向红绿测试、go test ./...、Go race 随机顺序测试、e2e、前端 34 个单测和生产构建已通过;typecheck 仅剩 origin/main 已存在的错误。等待发布工作流。 +files_changed: + - internal/store/migrations/0003_drop_egress_ip_address_unique.sql + - internal/store/repository/migration_0003_test.go + - internal/controlplane/http/admin_egress_ips.go + - internal/controlplane/http/admin_egress_ips_test.go + - web/admin/src/components/egress-ips/egress-ip-drawer.tsx + - web/admin/src/lib/egress-ip-address.ts + - web/admin/src/lib/__tests__/egress-ip-address.test.ts diff --git a/docs/superpowers/plans/2026-07-13-egress-ip-uniqueness.md b/docs/superpowers/plans/2026-07-13-egress-ip-uniqueness.md index 401615b..35a1f4a 100644 --- a/docs/superpowers/plans/2026-07-13-egress-ip-uniqueness.md +++ b/docs/superpowers/plans/2026-07-13-egress-ip-uniqueness.md @@ -32,23 +32,23 @@ TypeScript、Vitest、GitHub Actions。 - Consumes: `migrator.RunMigrations(context.Context, *sql.DB, embed.FS)` - Produces: 最终结构中 `egress_ips.ip_address TEXT NOT NULL` 且不唯一 -- [ ] **Step 1: 写失败的升级测试** +- [x] **Step 1: 写失败的升级测试** 测试先建立仅应用 `0001`、`0002` 的旧数据库,写入用户、主机、出口和绑定, 再通过 migrator 应用 `0003`。断言重复 IP 可插入、绑定保留、外键仍生效。 -- [ ] **Step 2: 运行测试确认红灯** +- [x] **Step 2: 运行测试确认红灯** Run: `go test ./internal/store/repository -run TestMigration0003 -count=1` Expected: 因缺少 `0003_drop_egress_ip_address_unique.sql` 或重复 IP 唯一冲突失败。 -- [ ] **Step 3: 实现最小迁移** +- [x] **Step 3: 实现最小迁移** 迁移按以下顺序执行:备份 `host_egress_bindings`、删除子表、重建 `egress_ips`、复制数据、恢复子表和索引、删除备份表。 -- [ ] **Step 4: 运行迁移测试确认绿灯** +- [x] **Step 4: 运行迁移测试确认绿灯** Run: `go test ./internal/store/repository -run TestMigration0003 -count=1` @@ -64,21 +64,21 @@ Expected: PASS。 - Consumes: `createEgressIPRequest.IPAddress`、`updateEgressIPRequest.IPAddress` - Produces: 空字符串合法;非空值必须通过 `net.ParseIP` -- [ ] **Step 1: 写创建空 IP 和更新非法 IP 的失败测试** +- [x] **Step 1: 写创建空 IP 和更新非法 IP 的失败测试** 创建请求传 `ip_address: ""` 应返回 201;更新请求传 `not-an-ip` 应返回 400。 -- [ ] **Step 2: 运行测试确认红灯** +- [x] **Step 2: 运行测试确认红灯** Run: `go test ./internal/controlplane/http -run TestAdminEgressIPsHandler -count=1` Expected: 创建空 IP 用例得到 400,或更新非法 IP 用例得到 200。 -- [ ] **Step 3: 实现统一校验** +- [x] **Step 3: 实现统一校验** 对创建和更新都执行 `strings.TrimSpace`;仅在非空时调用 `net.ParseIP`。 -- [ ] **Step 4: 运行 API 测试确认绿灯** +- [x] **Step 4: 运行 API 测试确认绿灯** Run: `go test ./internal/controlplane/http -run TestAdminEgressIPsHandler -count=1` @@ -95,22 +95,22 @@ Expected: PASS。 - Produces: `normalizeEgressIPAddress(value: string): string` - Consumes: 表单 `values.ip_address` -- [ ] **Step 1: 写失败的纯函数测试** +- [x] **Step 1: 写失败的纯函数测试** 断言空白输入返回空字符串,显式 IP 去除首尾空格后保持原值。 -- [ ] **Step 2: 运行测试确认红灯** +- [x] **Step 2: 运行测试确认红灯** Run: `pnpm test:unit -- src/lib/__tests__/egress-ip-address.test.ts` Expected: 因模块或导出不存在失败。 -- [ ] **Step 3: 实现函数并接入提交载荷** +- [x] **Step 3: 实现函数并接入提交载荷** `normalizeEgressIPAddress` 只执行 `trim()`;抽屉提交时直接把结果放入 `ip_address`,删除 `0.0.0.0` 回退逻辑和未使用的正则。 -- [ ] **Step 4: 运行前端测试确认绿灯** +- [x] **Step 4: 运行前端测试确认绿灯** Run: `pnpm test:unit -- src/lib/__tests__/egress-ip-address.test.ts` @@ -121,21 +121,23 @@ Expected: PASS。 **Files:** - Modify: `.planning/debug/egress-ip-unique-collision.md` -- [ ] **Step 1: 运行格式化与定向验证** +- [x] **Step 1: 运行格式化与定向验证** Run: `gofmt -w internal/controlplane/http/admin_egress_ips.go internal/controlplane/http/admin_egress_ips_test.go internal/store/repository/migration_0003_test.go` Run: `go test ./internal/store/... ./internal/controlplane/http/...` -Run: `pnpm test:unit && pnpm typecheck && pnpm build` +Run: `pnpm test:unit && pnpm build` -- [ ] **Step 2: 运行全量 Go 验证** +Run: `pnpm typecheck`,并与 `origin/main` 基线比较;本次不新增类型错误。 + +- [x] **Step 2: 运行全量 Go 验证** Run: `go test ./...` Expected: PASS。 -- [ ] **Step 3: 检查隐私、差异和迁移结构** +- [x] **Step 3: 检查隐私、差异和迁移结构** 确认新增文件没有绝对路径、凭据或个人信息;确认迁移后 `PRAGMA foreign_key_check` 无结果。 diff --git a/internal/controlplane/http/admin_egress_ips.go b/internal/controlplane/http/admin_egress_ips.go index c263c56..adc9a58 100644 --- a/internal/controlplane/http/admin_egress_ips.go +++ b/internal/controlplane/http/admin_egress_ips.go @@ -182,7 +182,7 @@ func (h *AdminEgressIPsHandler) Create() nethttp.Handler { return } req.IPAddress = strings.TrimSpace(req.IPAddress) - if net.ParseIP(req.IPAddress) == nil { + if req.IPAddress != "" && net.ParseIP(req.IPAddress) == nil { writeJSON(w, nethttp.StatusBadRequest, map[string]string{"error": "invalid ip address"}) return } @@ -249,6 +249,11 @@ func (h *AdminEgressIPsHandler) Update() nethttp.Handler { writeJSON(w, nethttp.StatusBadRequest, map[string]string{"error": "status must be available or disabled"}) return } + req.IPAddress = strings.TrimSpace(req.IPAddress) + if req.IPAddress != "" && net.ParseIP(req.IPAddress) == nil { + writeJSON(w, nethttp.StatusBadRequest, map[string]string{"error": "invalid ip address"}) + return + } req.ProxyConfig = mergeProxyPassword(r.Context(), h.store, ipID, req.ProxyConfig) if err := validateProxyConfig(req.ProxyConfig); err != nil { diff --git a/internal/controlplane/http/admin_egress_ips_test.go b/internal/controlplane/http/admin_egress_ips_test.go index 589c65c..d759363 100644 --- a/internal/controlplane/http/admin_egress_ips_test.go +++ b/internal/controlplane/http/admin_egress_ips_test.go @@ -123,6 +123,21 @@ func TestAdminEgressIPsHandler(t *testing.T) { store: &stubEgressIPStore{}, wantStatus: 400, }, + { + name: "Create egress IP without detected address 201", + method: "POST", + path: "/v1/admin/egress-ips", + body: map[string]any{ + "label": "pending-detection", + "ip_address": "", + "proxy_config": map[string]any{ + "type": "socks", "server": "proxy.example.com", "server_port": 1080, + }, + }, + store: &stubEgressIPStore{createIP: sampleIP}, + wantStatus: 201, + wantField: "egress_ip", + }, { name: "Get egress IP 200", method: "GET", @@ -166,6 +181,22 @@ func TestAdminEgressIPsHandler(t *testing.T) { store: &stubEgressIPStore{updateErr: sql.ErrNoRows}, wantStatus: 404, }, + { + name: "Update egress IP invalid non-empty address 400", + method: "PUT", + path: "/v1/admin/egress-ips/ip1", + body: map[string]any{ + "label": "updated", + "ip_address": "not-an-ip", + "provider": "manual", + "status": "available", + "proxy_config": map[string]any{ + "type": "socks", "server": "proxy.example.com", "server_port": 1080, + }, + }, + store: &stubEgressIPStore{updateIP: sampleIP}, + wantStatus: 400, + }, { name: "Delete egress IP 204", method: "DELETE", diff --git a/internal/store/migrations/0003_drop_egress_ip_address_unique.sql b/internal/store/migrations/0003_drop_egress_ip_address_unique.sql new file mode 100644 index 0000000..27057c8 --- /dev/null +++ b/internal/store/migrations/0003_drop_egress_ip_address_unique.sql @@ -0,0 +1,50 @@ +-- 0003_drop_egress_ip_address_unique.sql +-- ip_address 是可重复的检测结果,不是出口资源的唯一身份。 +-- SQLite 无法直接移除列级 UNIQUE 约束,因此在保留绑定关系的前提下重建表。 + +CREATE TABLE _host_egress_bindings_backup AS +SELECT id, host_id, egress_ip_id, created_at +FROM host_egress_bindings; + +DROP TABLE host_egress_bindings; + +CREATE TABLE egress_ips_new ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + ip_address TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT 'manual', + status TEXT NOT NULL DEFAULT 'available', + created_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP), + updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP), + proxy_config TEXT, + detected_ip_address TEXT +); + +INSERT INTO egress_ips_new ( + id, label, ip_address, provider, status, + created_at, updated_at, proxy_config, detected_ip_address +) +SELECT + id, label, ip_address, provider, status, + created_at, updated_at, proxy_config, detected_ip_address +FROM egress_ips; + +DROP TABLE egress_ips; +ALTER TABLE egress_ips_new RENAME TO egress_ips; + +CREATE TABLE host_egress_bindings ( + id TEXT PRIMARY KEY, + host_id TEXT NOT NULL REFERENCES hosts (id) ON DELETE CASCADE, + egress_ip_id TEXT NOT NULL REFERENCES egress_ips (id) ON DELETE RESTRICT, + created_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP), + UNIQUE (host_id, egress_ip_id) +); + +INSERT INTO host_egress_bindings (id, host_id, egress_ip_id, created_at) +SELECT id, host_id, egress_ip_id, created_at +FROM _host_egress_bindings_backup; + +CREATE INDEX IF NOT EXISTS idx_host_egress_bindings_host_id + ON host_egress_bindings (host_id); + +DROP TABLE _host_egress_bindings_backup; diff --git a/internal/store/repository/migration_0003_test.go b/internal/store/repository/migration_0003_test.go new file mode 100644 index 0000000..104415e --- /dev/null +++ b/internal/store/repository/migration_0003_test.go @@ -0,0 +1,110 @@ +package repository + +import ( + "context" + "database/sql" + "io/fs" + "path/filepath" + "testing" + + "github.com/zanel1u/cloud-cli-proxy/internal/store/migrations" + "github.com/zanel1u/cloud-cli-proxy/internal/store/migrator" + _ "modernc.org/sqlite" +) + +func TestMigration0003DropsEgressIPAddressUniqueAndPreservesBindings(t *testing.T) { + t.Parallel() + + ctx := context.Background() + db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "upgrade.db")) + if err != nil { + t.Fatalf("open database: %v", err) + } + defer db.Close() + db.SetMaxOpenConns(1) + + if _, err := db.ExecContext(ctx, `PRAGMA foreign_keys=ON`); err != nil { + t.Fatalf("enable foreign keys: %v", err) + } + + for _, filename := range []string{"0001_initial.sql", "0002_add_host_pids_limit.sql"} { + contents, err := fs.ReadFile(migrations.FS, filename) + if err != nil { + t.Fatalf("read %s: %v", filename, err) + } + if _, err := db.ExecContext(ctx, string(contents)); err != nil { + t.Fatalf("apply %s: %v", filename, err) + } + } + + if _, err := db.ExecContext(ctx, ` + CREATE TABLE schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) + ); + INSERT INTO schema_migrations (filename) VALUES + ('0001_initial.sql'), + ('0002_add_host_pids_limit.sql'); + INSERT INTO users (id, username, entry_password) VALUES + ('user-1', 'alice', 'your-secret-here'); + INSERT INTO hosts ( + id, user_id, status, template_image_ref, home_volume_name, slot_key + ) VALUES ( + 'host-1', 'user-1', 'running', 'managed-user:test', 'home-1', 'primary' + ); + INSERT INTO egress_ips (id, label, ip_address, provider) VALUES + ('egress-1', 'shared-label', '0.0.0.0', 'manual'); + INSERT INTO host_egress_bindings (id, host_id, egress_ip_id) VALUES + ('binding-1', 'host-1', 'egress-1'); + `); err != nil { + t.Fatalf("seed pre-migration data: %v", err) + } + + if err := migrator.RunMigrations(ctx, db, migrations.FS); err != nil { + t.Fatalf("run migrations: %v", err) + } + + for _, statement := range []string{ + `INSERT INTO egress_ips (id, label, ip_address) VALUES ('egress-2', 'shared-label', '0.0.0.0')`, + `INSERT INTO egress_ips (id, label, ip_address) VALUES ('egress-3', 'third', '8.8.8.8')`, + `INSERT INTO egress_ips (id, label, ip_address) VALUES ('egress-4', 'fourth', '8.8.8.8')`, + `INSERT INTO egress_ips (id, label, ip_address) VALUES ('egress-5', 'empty-one', '')`, + `INSERT INTO egress_ips (id, label, ip_address) VALUES ('egress-6', 'empty-two', '')`, + } { + if _, err := db.ExecContext(ctx, statement); err != nil { + t.Fatalf("duplicate or empty ip_address should be allowed: %v", err) + } + } + + var bindingCount int + if err := db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM host_egress_bindings + WHERE id = 'binding-1' AND host_id = 'host-1' AND egress_ip_id = 'egress-1' + `).Scan(&bindingCount); err != nil { + t.Fatalf("query preserved binding: %v", err) + } + if bindingCount != 1 { + t.Fatalf("preserved binding count = %d, want 1", bindingCount) + } + + if _, err := db.ExecContext(ctx, `DELETE FROM egress_ips WHERE id = 'egress-1'`); err == nil { + t.Fatal("deleting a bound egress IP should still be restricted") + } + + rows, err := db.QueryContext(ctx, `PRAGMA foreign_key_check`) + if err != nil { + t.Fatalf("run foreign key check: %v", err) + } + defer rows.Close() + if rows.Next() { + t.Fatal("foreign_key_check reported a violation after migration") + } + + if _, err := db.ExecContext(ctx, `INSERT INTO egress_ips (id, label, ip_address) VALUES ('egress-null', 'null-ip', NULL)`); err == nil { + t.Fatal("ip_address should remain NOT NULL") + } + + if err := migrator.RunMigrations(ctx, db, migrations.FS); err != nil { + t.Fatalf("rerun migrations: %v", err) + } +} diff --git a/web/admin/src/components/egress-ips/egress-ip-drawer.tsx b/web/admin/src/components/egress-ips/egress-ip-drawer.tsx index 36da67d..e5bcc5b 100644 --- a/web/admin/src/components/egress-ips/egress-ip-drawer.tsx +++ b/web/admin/src/components/egress-ips/egress-ip-drawer.tsx @@ -13,6 +13,7 @@ import { formValuesToProxyConfig, proxyConfigToFormValues, } from "./proxy-fields"; +import { normalizeEgressIPAddress } from "@/lib/egress-ip-address"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -30,8 +31,6 @@ import { SelectValue, } from "@/components/ui/select"; -const IPV4_RE = /^(\d{1,3}\.){3}\d{1,3}$/; - const formSchema = z .object({ label: z.string().min(1, "标签不能为空"), @@ -229,12 +228,8 @@ export function EgressIPDrawer({ }, [mode, ipData, form]); function onSubmit(values: FormValues) { - // ip_address 仅当用户显式填写时使用;空值时用 0.0.0.0 占位, - // 后端验证阶段会通过 SOCKS5 探测自动纠正为真实出口 IP。 - let ipAddress = values.ip_address; - if (!ipAddress) { - ipAddress = "0.0.0.0"; - } + // 空值表示尚未检测,后端验证阶段会通过 SOCKS5 探测真实出口 IP。 + const ipAddress = normalizeEgressIPAddress(values.ip_address); let proxyConfig: Record; if (values.edit_mode === "json") { diff --git a/web/admin/src/lib/__tests__/egress-ip-address.test.ts b/web/admin/src/lib/__tests__/egress-ip-address.test.ts new file mode 100644 index 0000000..e3f047f --- /dev/null +++ b/web/admin/src/lib/__tests__/egress-ip-address.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { normalizeEgressIPAddress } from "../egress-ip-address"; + +describe("normalizeEgressIPAddress", () => { + it("keeps an undetected address empty", () => { + expect(normalizeEgressIPAddress(" ")).toBe(""); + }); + + it("trims an explicitly supplied address", () => { + expect(normalizeEgressIPAddress(" 203.0.113.10 ")).toBe("203.0.113.10"); + }); +}); diff --git a/web/admin/src/lib/egress-ip-address.ts b/web/admin/src/lib/egress-ip-address.ts new file mode 100644 index 0000000..3b689f9 --- /dev/null +++ b/web/admin/src/lib/egress-ip-address.ts @@ -0,0 +1,3 @@ +export function normalizeEgressIPAddress(value: string): string { + return value.trim(); +}