Skip to content

Commit bb6828e

Browse files
authored
fix(egress): allow duplicate undetected addresses
* docs: plan egress IP uniqueness fix * fix(egress): allow duplicate undetected addresses
1 parent 1ada9c4 commit bb6828e

10 files changed

Lines changed: 485 additions & 9 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
---
2+
status: verifying
3+
trigger: "创建第二条未检测出口线路时,ip_address=0.0.0.0 触发 SQLite 唯一约束;需要代码修复并发布"
4+
created: 2026-07-13T00:00:00Z
5+
updated: 2026-07-13T09:12:30Z
6+
---
7+
8+
## Current Focus
9+
10+
hypothesis: 已确认;旧 schema 在第二条重复 ip_address 插入时稳定报 UNIQUE constraint failed,移除该约束后同一测试通过
11+
test: 迁移、API 和前端回归测试均完成红灯到绿灯;Go race、e2e、前端单测和生产构建已通过
12+
expecting: 提交并合入 main 后,v4.2.19 Release workflow、GitHub Release 和容器镜像全部成功
13+
next_action: 提交、推送、合入 main 并创建 v4.2.19 标签
14+
reasoning_checkpoint: 已排除临时分配 192.0.2.x 和把 label 改为唯一字段;前者污染数据语义,后者可能让历史重复标签阻断升级
15+
tdd_checkpoint: red-green-complete
16+
17+
## Symptoms
18+
19+
expected: 可以创建多条尚未探测真实 IP 的代理线路,也可以保存共享同一出口 IP 的不同线路
20+
actual: 第一条线路占用 0.0.0.0 后,第二条创建返回唯一约束冲突;真实 IP 自动纠正也可能因重复地址失败
21+
errors: "ip address already exists" / SQLite UNIQUE constraint failed
22+
reproduction: 连续创建两条不填写出口 IP 的代理线路
23+
started: v4.0 引入统一 0.0.0.0 占位后
24+
25+
## Eliminated
26+
27+
- hypothesis: 为每条线路分配 192.0.2.x 占位地址可以根治
28+
reason: 只能规避创建冲突,仍会在共享真实出口 IP 时失败,并把保留网段伪装成业务数据
29+
- hypothesis: 把唯一约束迁移到 label 可以安全替代
30+
reason: label 不是资源身份,历史重复标签会导致迁移失败;资源已有 id 主键
31+
32+
## Evidence
33+
34+
- timestamp: 2026-07-13T00:00:00Z
35+
checked: web/admin/src/components/egress-ips/egress-ip-drawer.tsx
36+
found: 未填写 ip_address 时无条件写入 0.0.0.0
37+
implication: 所有尚未检测的线路竞争同一个数据库唯一值
38+
- timestamp: 2026-07-13T00:00:00Z
39+
checked: internal/store/migrations/0001_initial.sql
40+
found: egress_ips.ip_address 定义为 TEXT NOT NULL UNIQUE,而 id 已经是 PRIMARY KEY
41+
implication: ip_address 承担了不应有的资源身份职责
42+
- timestamp: 2026-07-13T00:00:00Z
43+
checked: internal/store/repository/queries.go 和出口探测流程
44+
found: 探测会写入 detected_ip_address,运行时自动纠正还会更新 ip_address;不同线路可能得到同一实际出口地址
45+
implication: 唯一约束不仅影响占位值,也会影响合法的共享出口场景
46+
- timestamp: 2026-07-13T00:00:00Z
47+
checked: GitHub PR #8
48+
found: PR 同样识别到 ip_address 唯一约束问题,但同时引入 label UNIQUE 并夹带安全、VNC、SSH 等无关改动
49+
implication: 本次应采用不新增业务唯一键的聚焦补丁发布
50+
- timestamp: 2026-07-13T09:07:00Z
51+
checked: 新增迁移、API 和前端回归测试的红灯结果
52+
found: 旧 schema 拒绝第二条 0.0.0.0;Create 空 IP 返回 400;Update 非法 IP 返回 200;前端规范化模块尚不存在
53+
implication: 三组测试分别命中数据库、API 和前端根因,不是无关环境失败
54+
- timestamp: 2026-07-13T09:08:00Z
55+
checked: 最小实现后的定向测试
56+
found: 迁移测试、API 测试和前端测试全部通过;迁移测试同时验证重复标签、重复真实 IP、重复空 IP、绑定保留、RESTRICT 和 foreign_key_check
57+
implication: 修复覆盖了数据模型冲突且未削弱绑定完整性
58+
- timestamp: 2026-07-13T09:10:12Z
59+
checked: 扩大验证和 origin/main 类型检查基线
60+
found: go test ./...、34 个前端单测和 Vite 生产构建通过;typecheck 的剩余错误均可在 origin/main 复现,本分支未新增类型错误
61+
implication: 当前改动没有观察到 Go、前端运行构建或类型层面的新增回归
62+
- timestamp: 2026-07-13T09:12:30Z
63+
checked: CI 等价验证与提交前代码审查
64+
found: 非 e2e Go 包在 race 和随机顺序下通过,e2e 包通过,前端 34 个单测与生产构建再次通过;审查确认空 ExpectedIP 会走现有 UpdateExpectedIP 回调自动回填
65+
implication: 修复满足当前 CI 质量门,且未破坏首次出口探测与自动纠正流程
66+
67+
## Resolution
68+
69+
root_cause: egress_ips.id 已是主键,但 ip_address 仍被定义为 UNIQUE;前端又对所有未检测线路写入相同的 0.0.0.0,导致第二次创建和共享真实出口 IP 的自动回填发生合法数据冲突。
70+
fix: 新增 SQLite 表重建迁移移除 ip_address 唯一约束并保留 NOT NULL、绑定数据和外键;API 允许空 IP 但拒绝非法非空 IP;前端空值不再改写为 0.0.0.0。
71+
verification: 定向红绿测试、go test ./...、Go race 随机顺序测试、e2e、前端 34 个单测和生产构建已通过;typecheck 仅剩 origin/main 已存在的错误。等待发布工作流。
72+
files_changed:
73+
- internal/store/migrations/0003_drop_egress_ip_address_unique.sql
74+
- internal/store/repository/migration_0003_test.go
75+
- internal/controlplane/http/admin_egress_ips.go
76+
- internal/controlplane/http/admin_egress_ips_test.go
77+
- web/admin/src/components/egress-ips/egress-ip-drawer.tsx
78+
- web/admin/src/lib/egress-ip-address.ts
79+
- web/admin/src/lib/__tests__/egress-ip-address.test.ts
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# 出口 IP 唯一约束修复实施计划
2+
3+
> **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.
4+
5+
**Goal:** 让多条代理线路可以处于未检测状态或共享同一真实出口 IP,并通过
6+
`v4.2.19` 自动升级现有 SQLite 数据库。
7+
8+
**Architecture:** 使用 SQLite 表重建迁移移除 `egress_ips.ip_address` 的唯一
9+
约束,同时临时备份并恢复引用它的绑定表。API 和前端统一用空字符串表达尚未
10+
检测的 IP,真实地址继续由探测流程写入。
11+
12+
**Tech Stack:** Go 1.26、`database/sql``modernc.org/sqlite`、React 19、
13+
TypeScript、Vitest、GitHub Actions。
14+
15+
## Global Constraints
16+
17+
- v1 仍为单宿主机部署,不改变容器、隧道或出口验证架构。
18+
- 每个用户容器仍必须绑定至少一个出口资源。
19+
- 不新增第三方运行时依赖。
20+
- 所有仓库路径使用项目根目录相对路径。
21+
- 发布版本固定为 `v4.2.19`
22+
23+
---
24+
25+
### Task 1: SQLite 升级迁移
26+
27+
**Files:**
28+
- Create: `internal/store/repository/migration_0003_test.go`
29+
- Create: `internal/store/migrations/0003_drop_egress_ip_address_unique.sql`
30+
31+
**Interfaces:**
32+
- Consumes: `migrator.RunMigrations(context.Context, *sql.DB, embed.FS)`
33+
- Produces: 最终结构中 `egress_ips.ip_address TEXT NOT NULL` 且不唯一
34+
35+
- [x] **Step 1: 写失败的升级测试**
36+
37+
测试先建立仅应用 `0001``0002` 的旧数据库,写入用户、主机、出口和绑定,
38+
再通过 migrator 应用 `0003`。断言重复 IP 可插入、绑定保留、外键仍生效。
39+
40+
- [x] **Step 2: 运行测试确认红灯**
41+
42+
Run: `go test ./internal/store/repository -run TestMigration0003 -count=1`
43+
44+
Expected: 因缺少 `0003_drop_egress_ip_address_unique.sql` 或重复 IP 唯一冲突失败。
45+
46+
- [x] **Step 3: 实现最小迁移**
47+
48+
迁移按以下顺序执行:备份 `host_egress_bindings`、删除子表、重建
49+
`egress_ips`、复制数据、恢复子表和索引、删除备份表。
50+
51+
- [x] **Step 4: 运行迁移测试确认绿灯**
52+
53+
Run: `go test ./internal/store/repository -run TestMigration0003 -count=1`
54+
55+
Expected: PASS。
56+
57+
### Task 2: API 空 IP 契约
58+
59+
**Files:**
60+
- Modify: `internal/controlplane/http/admin_egress_ips_test.go`
61+
- Modify: `internal/controlplane/http/admin_egress_ips.go`
62+
63+
**Interfaces:**
64+
- Consumes: `createEgressIPRequest.IPAddress``updateEgressIPRequest.IPAddress`
65+
- Produces: 空字符串合法;非空值必须通过 `net.ParseIP`
66+
67+
- [x] **Step 1: 写创建空 IP 和更新非法 IP 的失败测试**
68+
69+
创建请求传 `ip_address: ""` 应返回 201;更新请求传 `not-an-ip` 应返回 400。
70+
71+
- [x] **Step 2: 运行测试确认红灯**
72+
73+
Run: `go test ./internal/controlplane/http -run TestAdminEgressIPsHandler -count=1`
74+
75+
Expected: 创建空 IP 用例得到 400,或更新非法 IP 用例得到 200。
76+
77+
- [x] **Step 3: 实现统一校验**
78+
79+
对创建和更新都执行 `strings.TrimSpace`;仅在非空时调用 `net.ParseIP`
80+
81+
- [x] **Step 4: 运行 API 测试确认绿灯**
82+
83+
Run: `go test ./internal/controlplane/http -run TestAdminEgressIPsHandler -count=1`
84+
85+
Expected: PASS。
86+
87+
### Task 3: 前端不再生成占位 IP
88+
89+
**Files:**
90+
- Create: `web/admin/src/lib/egress-ip-address.ts`
91+
- Create: `web/admin/src/lib/__tests__/egress-ip-address.test.ts`
92+
- Modify: `web/admin/src/components/egress-ips/egress-ip-drawer.tsx`
93+
94+
**Interfaces:**
95+
- Produces: `normalizeEgressIPAddress(value: string): string`
96+
- Consumes: 表单 `values.ip_address`
97+
98+
- [x] **Step 1: 写失败的纯函数测试**
99+
100+
断言空白输入返回空字符串,显式 IP 去除首尾空格后保持原值。
101+
102+
- [x] **Step 2: 运行测试确认红灯**
103+
104+
Run: `pnpm test:unit -- src/lib/__tests__/egress-ip-address.test.ts`
105+
106+
Expected: 因模块或导出不存在失败。
107+
108+
- [x] **Step 3: 实现函数并接入提交载荷**
109+
110+
`normalizeEgressIPAddress` 只执行 `trim()`;抽屉提交时直接把结果放入
111+
`ip_address`,删除 `0.0.0.0` 回退逻辑和未使用的正则。
112+
113+
- [x] **Step 4: 运行前端测试确认绿灯**
114+
115+
Run: `pnpm test:unit -- src/lib/__tests__/egress-ip-address.test.ts`
116+
117+
Expected: PASS。
118+
119+
### Task 4: 验证与发布
120+
121+
**Files:**
122+
- Modify: `.planning/debug/egress-ip-unique-collision.md`
123+
124+
- [x] **Step 1: 运行格式化与定向验证**
125+
126+
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`
127+
128+
Run: `go test ./internal/store/... ./internal/controlplane/http/...`
129+
130+
Run: `pnpm test:unit && pnpm build`
131+
132+
Run: `pnpm typecheck`,并与 `origin/main` 基线比较;本次不新增类型错误。
133+
134+
- [x] **Step 2: 运行全量 Go 验证**
135+
136+
Run: `go test ./...`
137+
138+
Expected: PASS。
139+
140+
- [x] **Step 3: 检查隐私、差异和迁移结构**
141+
142+
确认新增文件没有绝对路径、凭据或个人信息;确认迁移后
143+
`PRAGMA foreign_key_check` 无结果。
144+
145+
- [ ] **Step 4: 提交并发布**
146+
147+
提交聚焦改动,推送修复分支,合入 `main`,在合入提交上创建并推送
148+
`v4.2.19` 标签。
149+
150+
- [ ] **Step 5: 监控发布完成**
151+
152+
确认 Release workflow、GitHub Release、控制面镜像和 managed-user 镜像均成功。
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# 出口 IP 唯一约束修复设计
2+
3+
## 背景
4+
5+
管理后台在代理线路尚未检测到真实出口地址时,把 `ip_address` 保存为
6+
`0.0.0.0`。SQLite 的 `egress_ips.ip_address` 同时带有 `UNIQUE` 约束,因此
7+
第二条未检测线路无法创建;多个不同代理共享同一真实出口地址时,自动回填也会
8+
触发同类冲突。
9+
10+
`egress_ips.id` 已经是资源身份,`ip_address` 只是可重复的检测结果,不应承担
11+
唯一标识职责。
12+
13+
## 方案
14+
15+
1. 新增 SQLite 增量迁移,重建 `egress_ips` 并移除 `ip_address``UNIQUE`
16+
约束,保留 `TEXT NOT NULL`
17+
2. 迁移期间暂存并重建 `host_egress_bindings`,保留全部绑定数据、唯一约束和
18+
外键行为。
19+
3. 不给 `label` 增加新的唯一约束,避免历史重复标签阻断升级。
20+
4. API 创建和更新允许空字符串表示“尚未检测”,但继续拒绝非空的非法 IP。
21+
5. 管理后台不再生成 `0.0.0.0`,空值原样提交;已有 `0.0.0.0` 数据无需改写,
22+
因为迁移后它不再阻塞其他线路,后续探测仍可写入 `detected_ip_address`
23+
24+
## 兼容性与失败处理
25+
26+
- 迁移在单个事务中执行,任一步失败时不留下半重建表。
27+
- `host_egress_bindings` 重建后恢复 `ON DELETE CASCADE``ON DELETE RESTRICT`
28+
`(host_id, egress_ip_id)` 唯一约束。
29+
- 新安装仍按全部迁移顺序得到相同最终结构。
30+
- 发布采用补丁版本 `v4.2.19`,不夹带 PR #8 中的安全、VNC 或 SSH 改动。
31+
32+
## 验证
33+
34+
- 从旧结构和真实绑定数据升级成功。
35+
- 升级后允许重复空 IP、`0.0.0.0` 和真实 IP。
36+
- 升级后绑定数据及外键约束仍有效。
37+
- API 创建接受空 IP,创建和更新拒绝非法非空 IP。
38+
- 前端空值序列化为空字符串,不再变成 `0.0.0.0`
39+
- Go 全量测试、前端单元测试、类型检查和构建全部通过。

internal/controlplane/http/admin_egress_ips.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ func (h *AdminEgressIPsHandler) Create() nethttp.Handler {
182182
return
183183
}
184184
req.IPAddress = strings.TrimSpace(req.IPAddress)
185-
if net.ParseIP(req.IPAddress) == nil {
185+
if req.IPAddress != "" && net.ParseIP(req.IPAddress) == nil {
186186
writeJSON(w, nethttp.StatusBadRequest, map[string]string{"error": "invalid ip address"})
187187
return
188188
}
@@ -249,6 +249,11 @@ func (h *AdminEgressIPsHandler) Update() nethttp.Handler {
249249
writeJSON(w, nethttp.StatusBadRequest, map[string]string{"error": "status must be available or disabled"})
250250
return
251251
}
252+
req.IPAddress = strings.TrimSpace(req.IPAddress)
253+
if req.IPAddress != "" && net.ParseIP(req.IPAddress) == nil {
254+
writeJSON(w, nethttp.StatusBadRequest, map[string]string{"error": "invalid ip address"})
255+
return
256+
}
252257

253258
req.ProxyConfig = mergeProxyPassword(r.Context(), h.store, ipID, req.ProxyConfig)
254259
if err := validateProxyConfig(req.ProxyConfig); err != nil {

internal/controlplane/http/admin_egress_ips_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,21 @@ func TestAdminEgressIPsHandler(t *testing.T) {
123123
store: &stubEgressIPStore{},
124124
wantStatus: 400,
125125
},
126+
{
127+
name: "Create egress IP without detected address 201",
128+
method: "POST",
129+
path: "/v1/admin/egress-ips",
130+
body: map[string]any{
131+
"label": "pending-detection",
132+
"ip_address": "",
133+
"proxy_config": map[string]any{
134+
"type": "socks", "server": "proxy.example.com", "server_port": 1080,
135+
},
136+
},
137+
store: &stubEgressIPStore{createIP: sampleIP},
138+
wantStatus: 201,
139+
wantField: "egress_ip",
140+
},
126141
{
127142
name: "Get egress IP 200",
128143
method: "GET",
@@ -166,6 +181,22 @@ func TestAdminEgressIPsHandler(t *testing.T) {
166181
store: &stubEgressIPStore{updateErr: sql.ErrNoRows},
167182
wantStatus: 404,
168183
},
184+
{
185+
name: "Update egress IP invalid non-empty address 400",
186+
method: "PUT",
187+
path: "/v1/admin/egress-ips/ip1",
188+
body: map[string]any{
189+
"label": "updated",
190+
"ip_address": "not-an-ip",
191+
"provider": "manual",
192+
"status": "available",
193+
"proxy_config": map[string]any{
194+
"type": "socks", "server": "proxy.example.com", "server_port": 1080,
195+
},
196+
},
197+
store: &stubEgressIPStore{updateIP: sampleIP},
198+
wantStatus: 400,
199+
},
169200
{
170201
name: "Delete egress IP 204",
171202
method: "DELETE",
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
-- 0003_drop_egress_ip_address_unique.sql
2+
-- ip_address 是可重复的检测结果,不是出口资源的唯一身份。
3+
-- SQLite 无法直接移除列级 UNIQUE 约束,因此在保留绑定关系的前提下重建表。
4+
5+
CREATE TABLE _host_egress_bindings_backup AS
6+
SELECT id, host_id, egress_ip_id, created_at
7+
FROM host_egress_bindings;
8+
9+
DROP TABLE host_egress_bindings;
10+
11+
CREATE TABLE egress_ips_new (
12+
id TEXT PRIMARY KEY,
13+
label TEXT NOT NULL,
14+
ip_address TEXT NOT NULL,
15+
provider TEXT NOT NULL DEFAULT 'manual',
16+
status TEXT NOT NULL DEFAULT 'available',
17+
created_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP),
18+
updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP),
19+
proxy_config TEXT,
20+
detected_ip_address TEXT
21+
);
22+
23+
INSERT INTO egress_ips_new (
24+
id, label, ip_address, provider, status,
25+
created_at, updated_at, proxy_config, detected_ip_address
26+
)
27+
SELECT
28+
id, label, ip_address, provider, status,
29+
created_at, updated_at, proxy_config, detected_ip_address
30+
FROM egress_ips;
31+
32+
DROP TABLE egress_ips;
33+
ALTER TABLE egress_ips_new RENAME TO egress_ips;
34+
35+
CREATE TABLE host_egress_bindings (
36+
id TEXT PRIMARY KEY,
37+
host_id TEXT NOT NULL REFERENCES hosts (id) ON DELETE CASCADE,
38+
egress_ip_id TEXT NOT NULL REFERENCES egress_ips (id) ON DELETE RESTRICT,
39+
created_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP),
40+
UNIQUE (host_id, egress_ip_id)
41+
);
42+
43+
INSERT INTO host_egress_bindings (id, host_id, egress_ip_id, created_at)
44+
SELECT id, host_id, egress_ip_id, created_at
45+
FROM _host_egress_bindings_backup;
46+
47+
CREATE INDEX IF NOT EXISTS idx_host_egress_bindings_host_id
48+
ON host_egress_bindings (host_id);
49+
50+
DROP TABLE _host_egress_bindings_backup;

0 commit comments

Comments
 (0)