Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .planning/debug/egress-ip-unique-collision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
---
status: verifying
trigger: "创建第二条未检测出口线路时,ip_address=0.0.0.0 触发 SQLite 唯一约束;需要代码修复并发布"
created: 2026-07-13T00:00:00Z
updated: 2026-07-13T09:12:30Z
---

## Current Focus

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: red-green-complete

## 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: 本次应采用不新增业务唯一键的聚焦补丁发布
- 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: 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
152 changes: 152 additions & 0 deletions docs/superpowers/plans/2026-07-13-egress-ip-uniqueness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# 出口 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` 且不唯一

- [x] **Step 1: 写失败的升级测试**

测试先建立仅应用 `0001`、`0002` 的旧数据库,写入用户、主机、出口和绑定,
再通过 migrator 应用 `0003`。断言重复 IP 可插入、绑定保留、外键仍生效。

- [x] **Step 2: 运行测试确认红灯**

Run: `go test ./internal/store/repository -run TestMigration0003 -count=1`

Expected: 因缺少 `0003_drop_egress_ip_address_unique.sql` 或重复 IP 唯一冲突失败。

- [x] **Step 3: 实现最小迁移**

迁移按以下顺序执行:备份 `host_egress_bindings`、删除子表、重建
`egress_ips`、复制数据、恢复子表和索引、删除备份表。

- [x] **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`

- [x] **Step 1: 写创建空 IP 和更新非法 IP 的失败测试**

创建请求传 `ip_address: ""` 应返回 201;更新请求传 `not-an-ip` 应返回 400。

- [x] **Step 2: 运行测试确认红灯**

Run: `go test ./internal/controlplane/http -run TestAdminEgressIPsHandler -count=1`

Expected: 创建空 IP 用例得到 400,或更新非法 IP 用例得到 200。

- [x] **Step 3: 实现统一校验**

对创建和更新都执行 `strings.TrimSpace`;仅在非空时调用 `net.ParseIP`。

- [x] **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`

- [x] **Step 1: 写失败的纯函数测试**

断言空白输入返回空字符串,显式 IP 去除首尾空格后保持原值。

- [x] **Step 2: 运行测试确认红灯**

Run: `pnpm test:unit -- src/lib/__tests__/egress-ip-address.test.ts`

Expected: 因模块或导出不存在失败。

- [x] **Step 3: 实现函数并接入提交载荷**

`normalizeEgressIPAddress` 只执行 `trim()`;抽屉提交时直接把结果放入
`ip_address`,删除 `0.0.0.0` 回退逻辑和未使用的正则。

- [x] **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`

- [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 build`

Run: `pnpm typecheck`,并与 `origin/main` 基线比较;本次不新增类型错误。

- [x] **Step 2: 运行全量 Go 验证**

Run: `go test ./...`

Expected: PASS。

- [x] **Step 3: 检查隐私、差异和迁移结构**

确认新增文件没有绝对路径、凭据或个人信息;确认迁移后
`PRAGMA foreign_key_check` 无结果。

- [ ] **Step 4: 提交并发布**

提交聚焦改动,推送修复分支,合入 `main`,在合入提交上创建并推送
`v4.2.19` 标签。

- [ ] **Step 5: 监控发布完成**

确认 Release workflow、GitHub Release、控制面镜像和 managed-user 镜像均成功。
39 changes: 39 additions & 0 deletions docs/superpowers/specs/2026-07-13-egress-ip-uniqueness-design.md
Original file line number Diff line number Diff line change
@@ -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 全量测试、前端单元测试、类型检查和构建全部通过。
7 changes: 6 additions & 1 deletion internal/controlplane/http/admin_egress_ips.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
31 changes: 31 additions & 0 deletions internal/controlplane/http/admin_egress_ips_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
50 changes: 50 additions & 0 deletions internal/store/migrations/0003_drop_egress_ip_address_unique.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading