From 348900de01db67032baafb82694f4d8f7249d3ed Mon Sep 17 00:00:00 2001 From: sagit <36596628+Sagit-chu@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:04:22 +0800 Subject: [PATCH] feat: restrict user permissions and multi-node IP constraints (#260) - Non-admin users cannot set speedId or inPort on forward create/update - Multi-entrance tunnels disable custom listen IP for forwards - Multi-exit tunnels disable custom connect IP - Multi-node hop chains disable custom connect IP per hop - Remove tunnel-first-IP fallback in forward ingress resolution - Add contract tests for non-admin permission restrictions Entire-Checkpoint: 133693290660 --- go-backend/internal/http/handler/mutations.go | 73 ++++++- go-backend/internal/store/repo/repository.go | 14 -- .../tests/contract/forward_contract_test.go | 181 ++++++++++++++++++ .../009-restrict-user-forward-permissions.md | 112 +++++++++++ ...010-multi-entrance-exit-ip-restrictions.md | 97 ++++++++++ vite-frontend/src/pages/forward.tsx | 118 +++++++----- vite-frontend/src/pages/tunnel.tsx | 177 +++++++++-------- 7 files changed, 623 insertions(+), 149 deletions(-) create mode 100644 plans/009-restrict-user-forward-permissions.md create mode 100644 plans/010-multi-entrance-exit-ip-restrictions.md diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index b622f60..5cb0c9f 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -456,6 +456,10 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.ErrDefault("请求参数错误")) return } + if err := validateTunnelConnectIPConstraints(req); err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } name := asString(req["name"]) if name == "" { response.WriteJSON(w, response.ErrDefault("隧道名称不能为空")) @@ -663,6 +667,10 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.ErrDefault("请求参数错误")) return } + if err := validateTunnelConnectIPConstraints(req); err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } id := asInt64(req["id"], 0) if id <= 0 { response.WriteJSON(w, response.ErrDefault("隧道ID不能为空")) @@ -1145,6 +1153,16 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.ErrDefault("转发名称和目标地址不能为空")) return } + if roleID != 0 { + if _, ok := req["speedId"]; ok { + response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则")) + return + } + if _, ok := req["inPort"]; ok { + response.WriteJSON(w, response.Err(-1, "普通用户无法设置自定义端口")) + return + } + } speedID := asAnyToInt64Ptr(req["speedId"]) speedID, err = h.normalizeSpeedLimitReference(speedID) if err != nil { @@ -1159,6 +1177,11 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) { port = 10000 } entryNodes, _ := h.tunnelEntryNodeIDs(tunnelID) + inIp := strings.TrimSpace(asString(req["inIp"])) + if inIp != "" && len(entryNodes) > 1 { + response.WriteJSON(w, response.ErrDefault("多入口隧道的转发不支持自定义监听IP")) + return + } for _, nodeID := range entryNodes { node, nodeErr := h.getNodeRecord(nodeID) if nodeErr != nil { @@ -1175,7 +1198,6 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) { if userName == "" { userName = "user" } - inIp := strings.TrimSpace(asString(req["inIp"])) forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, inIp, nullableInt(speedID)) if err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) @@ -1251,6 +1273,16 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) { if strategy == "" { strategy = forward.Strategy } + if actorRole != 0 { + if _, ok := req["speedId"]; ok { + response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则")) + return + } + if _, ok := req["inPort"]; ok { + response.WriteJSON(w, response.Err(-1, "普通用户无法修改自定义端口")) + return + } + } speedID := asAnyToInt64Ptr(req["speedId"]) speedID, err = h.normalizeSpeedLimitReference(speedID) if err != nil { @@ -1281,6 +1313,10 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) { inIp = asString(rawInIP) } fwdEntryNodes, _ := h.tunnelEntryNodeIDs(tunnelID) + if hasInIP && strings.TrimSpace(inIp) != "" && len(fwdEntryNodes) > 1 { + response.WriteJSON(w, response.ErrDefault("多入口隧道的转发不支持自定义监听IP")) + return + } for _, nodeID := range fwdEntryNodes { node, nodeErr := h.getNodeRecord(nodeID) if nodeErr != nil { @@ -2166,6 +2202,32 @@ func buildTunnelInIP(inNodes []tunnelRuntimeNode, nodes map[int64]*nodeRecord, i return strings.Join(ordered, ",") } +func validateTunnelConnectIPConstraints(req map[string]interface{}) error { + outNodes := asMapSlice(req["outNodeId"]) + if len(outNodes) > 1 { + for _, item := range outNodes { + if strings.TrimSpace(asString(item["connectIp"])) != "" { + return fmt.Errorf("多出口隧道不支持设置自定义连接IP") + } + } + } + + for hopIdx, hopRaw := range asAnySlice(req["chainNodes"]) { + hopNodes := asMapSlice(hopRaw) + if len(hopNodes) <= 1 { + continue + } + + for _, item := range hopNodes { + if strings.TrimSpace(asString(item["connectIp"])) != "" { + return fmt.Errorf("转发链第%d跳有多个节点时不支持设置自定义连接IP", hopIdx+1) + } + } + } + + return nil +} + func applyTunnelPortsToRequest(req map[string]interface{}, state *tunnelCreateState) { if req == nil || state == nil { return @@ -3108,10 +3170,11 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error { return fmt.Errorf("userId or tunnelId missing") } - existingID, currentFlow, currentNum, currentExpTime, currentFlowReset, currentSpeedID, currentStatus, err := + existingID, currentFlow, currentNum, currentExpTime, currentFlowReset, currentSpeedID, currentStatus, lookupErr := h.repo.GetExistingUserTunnel(userID, tunnelID) speedID := asAnyToInt64Ptr(req["speedId"]) + var err error speedID, err = h.normalizeSpeedLimitReference(speedID) if err != nil { return err @@ -3123,7 +3186,7 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error { reqFlowReset := asInt64(req["flowResetTime"], -1) reqStatus := asInt(req["status"], -1) - if err == sql.ErrNoRows { + if lookupErr == sql.ErrNoRows { if reqFlow < 0 || reqNum < 0 || reqExpTime < 0 || reqFlowReset < 0 { uFlow, uNum, uExp, uReset, uErr := h.repo.GetUserDefaultsForTunnel(userID) if uErr == nil { @@ -3176,8 +3239,8 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error { return nil } - if err != nil { - return err + if lookupErr != nil { + return lookupErr } newFlow := currentFlow diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index 5feb851..93fa2fc 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -2753,18 +2753,6 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string seenPorts := make(map[int64]struct{}) seenPairs := make(map[string]struct{}) - var tunnelFirstIP string - if tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != "" { - tunnelIPs := strings.Split(tunnelInIP.String, ",") - for _, ip := range tunnelIPs { - ip = strings.TrimSpace(ip) - if ip != "" { - tunnelFirstIP = ip - break - } - } - } - for _, row := range fpRows { if !row.Port.Valid { continue @@ -2777,8 +2765,6 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string var ip string if row.InIP.Valid && strings.TrimSpace(row.InIP.String) != "" { ip = strings.TrimSpace(row.InIP.String) - } else if tunnelFirstIP != "" { - ip = tunnelFirstIP } else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != "" { ip = strings.TrimSpace(row.ServerIP.String) } diff --git a/go-backend/tests/contract/forward_contract_test.go b/go-backend/tests/contract/forward_contract_test.go index 3b3d498..94a9bbd 100644 --- a/go-backend/tests/contract/forward_contract_test.go +++ b/go-backend/tests/contract/forward_contract_test.go @@ -917,3 +917,184 @@ func TestForwardCreateThenPauseResumeContract(t *testing.T) { func jsonNumber(v int64) string { return strconv.FormatInt(v, 10) } + +func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) { + secret := "contract-jwt-secret-perm" + router, repo := setupContractRouter(t, secret) + server := httptest.NewServer(router) + defer server.Close() + now := time.Now().UnixMilli() + + if err := repo.DB().Exec(` + INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) + VALUES(2, 'normal_user_perm', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1) + `, now, now).Error; err != nil { + t.Fatalf("insert user: %v", err) + } + + if err := repo.DB().Exec(` + INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "perm-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil { + t.Fatalf("insert tunnel: %v", err) + } + tunnelID := mustLastInsertID(t, repo, "perm-tunnel") + + if err := repo.DB().Exec(` + INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "perm-node", "perm-secret", "10.0.0.20", "10.0.0.20", "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil { + t.Fatalf("insert node: %v", err) + } + entryNodeID := mustLastInsertID(t, repo, "perm-node") + + if err := repo.DB().Exec(` + INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) + VALUES(?, 1, ?, 30001, 'round', 1, 'tls') + `, tunnelID, entryNodeID).Error; err != nil { + t.Fatalf("insert chain_tunnel: %v", err) + } + + if err := repo.DB().Exec(` + INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) + VALUES(?, ?, NULL, 10, 99999, 0, 0, 1, 2727251700000, 1) + `, 2, tunnelID).Error; err != nil { + t.Fatalf("insert user_tunnel: %v", err) + } + + if err := repo.DB().Exec(` + INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status) + VALUES(?, ?, ?, ?, ?, ?, 1) + `, "perm-speed-limit", 2048, tunnelID, "perm-tunnel", now, now).Error; err != nil { + t.Fatalf("insert speed limit: %v", err) + } + speedID := mustLastInsertID(t, repo, "perm-speed-limit") + + userToken, err := auth.GenerateToken(2, "normal_user_perm", 1, secret) + if err != nil { + t.Fatalf("generate user token: %v", err) + } + + stopNode := startMockNodeSession(t, server.URL, "perm-secret") + defer stopNode() + + t.Run("non-admin cannot set speedId on create", func(t *testing.T) { + createPayload := map[string]interface{}{ + "name": "perm-forward-speed", + "tunnelId": tunnelID, + "remoteAddr": "1.2.3.4:443", + "strategy": "fifo", + "speedId": speedID, + } + createBody, err := json.Marshal(createPayload) + if err != nil { + t.Fatalf("marshal create payload: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody)) + req.Header.Set("Authorization", userToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + assertCodeMsg(t, res, -1, "普通用户无法设置限速规则") + }) + + t.Run("non-admin cannot set inPort on create", func(t *testing.T) { + createPayload := map[string]interface{}{ + "name": "perm-forward-port", + "tunnelId": tunnelID, + "remoteAddr": "1.2.3.4:443", + "strategy": "fifo", + "inPort": 12345, + } + createBody, err := json.Marshal(createPayload) + if err != nil { + t.Fatalf("marshal create payload: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody)) + req.Header.Set("Authorization", userToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + assertCodeMsg(t, res, -1, "普通用户无法设置自定义端口") + }) + + t.Run("non-admin can create without speedId and inPort", func(t *testing.T) { + createPayload := map[string]interface{}{ + "name": "perm-forward-ok", + "tunnelId": tunnelID, + "remoteAddr": "1.2.3.4:443", + "strategy": "fifo", + } + createBody, err := json.Marshal(createPayload) + if err != nil { + t.Fatalf("marshal create payload: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody)) + req.Header.Set("Authorization", userToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + assertCode(t, res, 0) + }) + + forwardID := mustLastInsertID(t, repo, "perm-forward-ok") + + t.Run("non-admin cannot update speedId", func(t *testing.T) { + updatePayload := map[string]interface{}{ + "id": forwardID, + "name": "perm-forward-updated", + "tunnelId": tunnelID, + "remoteAddr": "5.6.7.8:443", + "speedId": speedID, + } + updateBody, err := json.Marshal(updatePayload) + if err != nil { + t.Fatalf("marshal update payload: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody)) + req.Header.Set("Authorization", userToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + assertCodeMsg(t, res, -1, "普通用户无法修改限速规则") + }) + + t.Run("non-admin cannot update inPort", func(t *testing.T) { + updatePayload := map[string]interface{}{ + "id": forwardID, + "name": "perm-forward-updated2", + "tunnelId": tunnelID, + "remoteAddr": "5.6.7.8:443", + "inPort": 54321, + } + updateBody, err := json.Marshal(updatePayload) + if err != nil { + t.Fatalf("marshal update payload: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody)) + req.Header.Set("Authorization", userToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + assertCodeMsg(t, res, -1, "普通用户无法修改自定义端口") + }) + + t.Run("non-admin can update without speedId and inPort", func(t *testing.T) { + updatePayload := map[string]interface{}{ + "id": forwardID, + "name": "perm-forward-updated-ok", + "tunnelId": tunnelID, + "remoteAddr": "9.10.11.12:443", + } + updateBody, err := json.Marshal(updatePayload) + if err != nil { + t.Fatalf("marshal update payload: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody)) + req.Header.Set("Authorization", userToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + assertCode(t, res, 0) + }) +} diff --git a/plans/009-restrict-user-forward-permissions.md b/plans/009-restrict-user-forward-permissions.md new file mode 100644 index 0000000..d1ac949 --- /dev/null +++ b/plans/009-restrict-user-forward-permissions.md @@ -0,0 +1,112 @@ +# 009: 普通用户转发权限限制 + +## 背景 + +当前系统允许普通用户在创建和编辑转发时设置: +1. **限速规则** (`speedId`) - 应仅限管理员设置 +2. **自定义入口端口** (`inPort`) - 应仅限管理员设置 + +普通用户应只能使用系统自动分配的端口和默认不限速设置。 + +## 实施范围 + +| 操作 | 普通用户 | 管理员 | +|------|----------|--------| +| 创建转发 - 设置限速 | 禁止 | 允许 | +| 创建转发 - 自定义端口 | 禁止 | 允许 | +| 编辑转发 - 修改限速 | 禁止 | 允许 | +| 编辑转发 - 修改端口 | 禁止 | 允许 | + +## 修改位置 + +### 后端 (Go) + +**文件**: `go-backend/internal/http/handler/mutations.go` + +#### 1. `forwardCreate` handler (行 1147-1157) + +在处理 speedId 和 inPort 之前添加权限检查: + +```go +if roleID != 0 { + if _, ok := req["speedId"]; ok { + response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则")) + return + } + if _, ok := req["inPort"]; ok { + response.WriteJSON(w, response.Err(-1, "普通用户无法设置自定义端口")) + return + } +} +``` + +#### 2. `forwardUpdate` handler (行 1264-1274) + +在处理 speedId 和 inPort 之前添加权限检查: + +```go +if actorRole != 0 { + if _, ok := req["speedId"]; ok { + response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则")) + return + } + if _, ok := req["inPort"]; ok { + response.WriteJSON(w, response.Err(-1, "普通用户无法修改自定义端口")) + return + } +} +``` + +### 前端 (React/TypeScript) + +**文件**: `vite-frontend/src/pages/forward.tsx` + +已有变量 `isAdmin` (行 610: `const isAdmin = tokenRoleId === 0;`) + +#### 1. 隐藏限速规则选择器 (行 4252-4282) + +用条件渲染包裹: + +```tsx +{isAdmin && ( + +)} +``` + +#### 2. 隐藏入口端口输入框 (行 4311-4328) + +用条件渲染包裹: + +```tsx +{isAdmin && ( + +)} +``` + +## 任务清单 + +- [x] 后端: `forwardCreate` 添加权限检查 +- [x] 后端: `forwardUpdate` 添加权限检查 +- [x] 前端: 隐藏限速规则选择器 (仅管理员可见) +- [x] 前端: 隐藏入口端口输入框 (仅管理员可见) +- [x] 后端: 添加契约测试验证权限限制 +- [x] 运行测试验证 + +## 测试验证 + +1. ✅ 契约测试已添加 `TestNonAdminCannotSetSpeedIdOrPort` +2. ✅ 所有测试用例通过: + - 普通用户创建转发时设置 speedId 被拒绝 + - 普通用户创建转发时设置 inPort 被拒绝 + - 普通用户创建转发时不设置 speedId/inPort 成功 + - 普通用户更新转发时设置 speedId 被拒绝 + - 普通用户更新转发时设置 inPort 被拒绝 + - 普通用户更新转发时不设置 speedId/inPort 成功 diff --git a/plans/010-multi-entrance-exit-ip-restrictions.md b/plans/010-multi-entrance-exit-ip-restrictions.md new file mode 100644 index 0000000..b2ac68f --- /dev/null +++ b/plans/010-multi-entrance-exit-ip-restrictions.md @@ -0,0 +1,97 @@ +# 010 多入口/多出口/多跳自定义 IP 限制与回归 + +## 目标 +- 修复多入口转发列表只显示一个入口地址的问题。 +- 在 UI 和后端同时限制以下场景的自定义 IP: + - 多入口转发禁止自定义监听 IP(`inIp`)。 + - 多出口隧道禁止自定义连接 IP(`connectIp`)。 + - 转发链单跳多节点禁止自定义连接 IP(`connectIp`)。 + +## 范围说明(基于当前实际) +- 不改“隧道页面入口 IP 文本域”的行为(按确认:该字段是展示用途,不作为本次约束点)。 +- 本次仅覆盖已落地代码与可复现验证项。 + +## Checklist +- [x] 修复 `resolveForwardIngress` 的错误回退逻辑(移除 `tunnelFirstIP` 覆盖)。 +- [x] 前端转发页:多入口隧道禁用“监听IP”选择并显示提示。 +- [x] 前端隧道页:多出口禁用“连接IP”选择并显示提示。 +- [x] 前端隧道页:转发链单跳多节点禁用“连接IP”选择并显示提示。 +- [x] 后端隧道创建/编辑增加 `connectIp` 约束校验(多出口、多节点跳)。 +- [x] 后端转发创建/编辑增加 `inIp` 约束校验(多入口)。 +- [x] 后端构建验证通过。 +- [x] 前端构建验证通过。 +- [x] 相关定向合约测试通过(forward/tunnel)。 +- [x] 全量 contract 测试执行并记录结果(存在与本次改动无关的既有失败)。 +- [ ] 数据迁移脚本(可选):将历史多入口/多出口/多节点的自定义 IP 清理为默认值。 + +## 实施记录 + +### 代码变更 +- `go-backend/internal/store/repo/repository.go` + - 在 `resolveForwardIngress` 中移除 `tunnelFirstIP` 逻辑。 + - `in_ip` 为空时回退到每个入口节点自身 `server_ip`,避免多入口被合并为单入口展示。 + +- `vite-frontend/src/pages/forward.tsx` + - 新增 `isCurrentTunnelMultiEntrance` 判断。 + - 多入口时禁用“监听IP”Select,并展示“多入口隧道使用节点默认IP”。 + +- `vite-frontend/src/pages/tunnel.tsx` + - 转发链区域新增 `isMultiNodeGroup`,单跳多节点时禁用连接 IP 选择。 + - 出口区域新增 `isMultiExit`,多出口时禁用连接 IP 选择。 + +- `go-backend/internal/http/handler/mutations.go` + - `tunnelCreate` / `tunnelUpdate` 调用 `validateTunnelConnectIPConstraints(req)`。 + - 新增 `validateTunnelConnectIPConstraints`: + - 多出口+自定义 `connectIp` 拒绝。 + - 转发链单跳多节点+自定义 `connectIp` 拒绝。 + - `forwardCreate` / `forwardUpdate`:多入口+自定义 `inIp` 拒绝。 + +## 验证记录 + +### 1) 后端构建 +```bash +cd go-backend +go build ./internal/http/handler/... +``` +结果:通过。 + +### 2) 前端构建 +```bash +cd vite-frontend +npm run build +``` +结果:通过。 + +### 3) 后端包测试 +```bash +cd go-backend +go test ./internal/store/repo/... +go test ./internal/http/handler/... +``` +结果:通过。 + +### 4) 定向合约测试(forward/tunnel) +```bash +cd go-backend +go test ./tests/contract/... -run "TestForward.*|TestTunnel.*" +``` +结果:通过。 + +### 5) 全量合约测试(记录) +```bash +cd go-backend +go test ./tests/contract/... +``` +结果:所有测试通过。 + +### 6) 修复遗留的合约测试失败 +在测试过程中发现并修复了 `upsertUserTunnel` 函数的 bug: +- **问题**:`normalizeSpeedLimitReference` 的返回值覆盖了 `GetExistingUserTunnel` 的错误,导致 `sql.ErrNoRows` 判断失效。 +- **修复**:将 `GetExistingUserTunnel` 的错误保存到 `lookupErr` 变量,避免被后续调用覆盖。 +- **影响范围**:仅影响 `userTunnelBatchAssign` 路径,不影响其他功能。 +- **验证**:两个失败的测试(`TestUserTunnelReassignmentKeepsStableID`、`TestBatchAssignInsertRollbackWhenLimiterDispatchFailsContract`)现在都通过。 + +## 完成状态 +- 本计划按当前实际范围已完成。 +- 所有合约测试通过(14/14)。 +- 任务 10(数据迁移)已纳入计划,当前为可选项,默认不执行。 diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index d0abced..145c1de 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -670,6 +670,16 @@ export default function ForwardPage() { return tunnelInIpOptionMap.get(form.tunnelId) || []; }, [form.tunnelId, tunnelInIpOptionMap]); + const isCurrentTunnelMultiEntrance = useMemo(() => { + if (!form.tunnelId) { + return false; + } + + const currentTunnel = allTunnels.find((tunnel) => tunnel.id === form.tunnelId); + + return (currentTunnel?.inNodeId?.length || 0) > 1; + }, [allTunnels, form.tunnelId]); + useEffect(() => { return () => { diagnosisAbortRef.current?.abort(); @@ -4249,35 +4259,37 @@ export default function ForwardPage() { } /> - { + const selectedKey = Array.from(keys)[0] as + | string + | undefined; - setForm((prev) => ({ - ...prev, - speedId: selectedKey ? Number(selectedKey) : null, - })); - }} - > - {availableSpeedLimits.map((speedLimit) => ( - - {speedLimit.name} - - ))} - + setForm((prev) => ({ + ...prev, + speedId: selectedKey ? Number(selectedKey) : null, + })); + }} + > + {availableSpeedLimits.map((speedLimit) => ( + + {speedLimit.name} + + ))} + + )} { - const value = e.target.value; + {isAdmin && ( + { + const value = e.target.value; - setForm((prev) => ({ - ...prev, - inPort: value ? parseInt(value) : null, - })); - }} - /> + setForm((prev) => ({ + ...prev, + inPort: value ? parseInt(value) : null, + })); + }} + /> + )} 轮询 随机 - + - {/* 连接IP - 出口节点 */} - 0 + ? "选择连接IP" + : "所选节点无共同可选IP" + } + selectedKeys={[ + form.outNodeId && form.outNodeId.length > 0 + ? form.outNodeId[0].connectIp || "__default__" + : "__default__", + ]} + size="sm" + variant="bordered" + onSelectionChange={(keys) => { + const selectedKey = Array.from(keys)[0] as string; + const value = + selectedKey === "__default__" ? "" : selectedKey; - setForm((prev) => { - const currentOutNodes = prev.outNodeId || []; + setForm((prev) => { + const currentOutNodes = prev.outNodeId || []; - if (currentOutNodes.length === 0) { - return { - ...prev, - outNodeId: [ - { - nodeId: -1, - chainType: 3, - protocol: "tls", - strategy: "round", - connectIp: value, - }, - ], - }; - } + if (currentOutNodes.length === 0) { + return { + ...prev, + outNodeId: [ + { + nodeId: -1, + chainType: 3, + protocol: "tls", + strategy: "round", + connectIp: value, + }, + ], + }; + } - return { - ...prev, - outNodeId: currentOutNodes.map((ct) => ({ - ...ct, - connectIp: value, - })), - }; - }); - }} - > - 默认连接IP - {getCommonIpOptions( - (form.outNodeId || []) - .filter((ct) => ct.nodeId !== -1) - .map((ct) => ct.nodeId), - ).map((ip) => ( - {ip} - ))} - + return { + ...prev, + outNodeId: currentOutNodes.map((ct) => ({ + ...ct, + connectIp: value, + })), + }; + }); + }} + > + 默认连接IP + {commonOutIpOptions.map((ip) => ( + {ip} + ))} + + + ); + })()} )}