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
This commit is contained in:
sagit
2026-03-04 14:04:22 +08:00
committed by GitHub
parent b93c259fac
commit 348900de01
7 changed files with 623 additions and 149 deletions
+68 -5
View File
@@ -456,6 +456,10 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("请求参数错误")) response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return return
} }
if err := validateTunnelConnectIPConstraints(req); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
name := asString(req["name"]) name := asString(req["name"])
if name == "" { if name == "" {
response.WriteJSON(w, response.ErrDefault("隧道名称不能为空")) response.WriteJSON(w, response.ErrDefault("隧道名称不能为空"))
@@ -663,6 +667,10 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("请求参数错误")) response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return return
} }
if err := validateTunnelConnectIPConstraints(req); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
id := asInt64(req["id"], 0) id := asInt64(req["id"], 0)
if id <= 0 { if id <= 0 {
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空")) 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("转发名称和目标地址不能为空")) response.WriteJSON(w, response.ErrDefault("转发名称和目标地址不能为空"))
return 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 := asAnyToInt64Ptr(req["speedId"])
speedID, err = h.normalizeSpeedLimitReference(speedID) speedID, err = h.normalizeSpeedLimitReference(speedID)
if err != nil { if err != nil {
@@ -1159,6 +1177,11 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
port = 10000 port = 10000
} }
entryNodes, _ := h.tunnelEntryNodeIDs(tunnelID) 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 { for _, nodeID := range entryNodes {
node, nodeErr := h.getNodeRecord(nodeID) node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr != nil { if nodeErr != nil {
@@ -1175,7 +1198,6 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
if userName == "" { if userName == "" {
userName = "user" 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)) 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 { if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
@@ -1251,6 +1273,16 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
if strategy == "" { if strategy == "" {
strategy = forward.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 := asAnyToInt64Ptr(req["speedId"])
speedID, err = h.normalizeSpeedLimitReference(speedID) speedID, err = h.normalizeSpeedLimitReference(speedID)
if err != nil { if err != nil {
@@ -1281,6 +1313,10 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
inIp = asString(rawInIP) inIp = asString(rawInIP)
} }
fwdEntryNodes, _ := h.tunnelEntryNodeIDs(tunnelID) fwdEntryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
if hasInIP && strings.TrimSpace(inIp) != "" && len(fwdEntryNodes) > 1 {
response.WriteJSON(w, response.ErrDefault("多入口隧道的转发不支持自定义监听IP"))
return
}
for _, nodeID := range fwdEntryNodes { for _, nodeID := range fwdEntryNodes {
node, nodeErr := h.getNodeRecord(nodeID) node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr != nil { if nodeErr != nil {
@@ -2166,6 +2202,32 @@ func buildTunnelInIP(inNodes []tunnelRuntimeNode, nodes map[int64]*nodeRecord, i
return strings.Join(ordered, ",") 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) { func applyTunnelPortsToRequest(req map[string]interface{}, state *tunnelCreateState) {
if req == nil || state == nil { if req == nil || state == nil {
return return
@@ -3108,10 +3170,11 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
return fmt.Errorf("userId or tunnelId missing") 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) h.repo.GetExistingUserTunnel(userID, tunnelID)
speedID := asAnyToInt64Ptr(req["speedId"]) speedID := asAnyToInt64Ptr(req["speedId"])
var err error
speedID, err = h.normalizeSpeedLimitReference(speedID) speedID, err = h.normalizeSpeedLimitReference(speedID)
if err != nil { if err != nil {
return err return err
@@ -3123,7 +3186,7 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
reqFlowReset := asInt64(req["flowResetTime"], -1) reqFlowReset := asInt64(req["flowResetTime"], -1)
reqStatus := asInt(req["status"], -1) reqStatus := asInt(req["status"], -1)
if err == sql.ErrNoRows { if lookupErr == sql.ErrNoRows {
if reqFlow < 0 || reqNum < 0 || reqExpTime < 0 || reqFlowReset < 0 { if reqFlow < 0 || reqNum < 0 || reqExpTime < 0 || reqFlowReset < 0 {
uFlow, uNum, uExp, uReset, uErr := h.repo.GetUserDefaultsForTunnel(userID) uFlow, uNum, uExp, uReset, uErr := h.repo.GetUserDefaultsForTunnel(userID)
if uErr == nil { if uErr == nil {
@@ -3176,8 +3239,8 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
return nil return nil
} }
if err != nil { if lookupErr != nil {
return err return lookupErr
} }
newFlow := currentFlow newFlow := currentFlow
@@ -2753,18 +2753,6 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
seenPorts := make(map[int64]struct{}) seenPorts := make(map[int64]struct{})
seenPairs := make(map[string]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 { for _, row := range fpRows {
if !row.Port.Valid { if !row.Port.Valid {
continue continue
@@ -2777,8 +2765,6 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
var ip string var ip string
if row.InIP.Valid && strings.TrimSpace(row.InIP.String) != "" { if row.InIP.Valid && strings.TrimSpace(row.InIP.String) != "" {
ip = 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) != "" { } else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != "" {
ip = strings.TrimSpace(row.ServerIP.String) ip = strings.TrimSpace(row.ServerIP.String)
} }
@@ -917,3 +917,184 @@ func TestForwardCreateThenPauseResumeContract(t *testing.T) {
func jsonNumber(v int64) string { func jsonNumber(v int64) string {
return strconv.FormatInt(v, 10) 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)
})
}
@@ -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 && (
<Select
label="限速规则"
// ... 现有属性
>
{/* ... */}
</Select>
)}
```
#### 2. 隐藏入口端口输入框 (行 4311-4328)
用条件渲染包裹:
```tsx
{isAdmin && (
<Input
description="指定入口端口,留空则从节点可用端口中自动分配"
// ... 现有属性
/>
)}
```
## 任务清单
- [x] 后端: `forwardCreate` 添加权限检查
- [x] 后端: `forwardUpdate` 添加权限检查
- [x] 前端: 隐藏限速规则选择器 (仅管理员可见)
- [x] 前端: 隐藏入口端口输入框 (仅管理员可见)
- [x] 后端: 添加契约测试验证权限限制
- [x] 运行测试验证
## 测试验证
1. ✅ 契约测试已添加 `TestNonAdminCannotSetSpeedIdOrPort`
2. ✅ 所有测试用例通过:
- 普通用户创建转发时设置 speedId 被拒绝
- 普通用户创建转发时设置 inPort 被拒绝
- 普通用户创建转发时不设置 speedId/inPort 成功
- 普通用户更新转发时设置 speedId 被拒绝
- 普通用户更新转发时设置 inPort 被拒绝
- 普通用户更新转发时不设置 speedId/inPort 成功
@@ -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(数据迁移)已纳入计划,当前为可选项,默认不执行。
+70 -48
View File
@@ -670,6 +670,16 @@ export default function ForwardPage() {
return tunnelInIpOptionMap.get(form.tunnelId) || []; return tunnelInIpOptionMap.get(form.tunnelId) || [];
}, [form.tunnelId, tunnelInIpOptionMap]); }, [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(() => { useEffect(() => {
return () => { return () => {
diagnosisAbortRef.current?.abort(); diagnosisAbortRef.current?.abort();
@@ -4249,35 +4259,37 @@ export default function ForwardPage() {
} }
/> />
<Select {isAdmin && (
label="限速规则" <Select
placeholder="不限速" label="限速规则"
selectedKeys={ placeholder="不限速"
selectedSpeedId !== null selectedKeys={
? [selectedSpeedId.toString()] selectedSpeedId !== null
: [] ? [selectedSpeedId.toString()]
} : []
variant="bordered" }
onSelectionChange={(keys) => { variant="bordered"
const selectedKey = Array.from(keys)[0] as onSelectionChange={(keys) => {
| string const selectedKey = Array.from(keys)[0] as
| undefined; | string
| undefined;
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
speedId: selectedKey ? Number(selectedKey) : null, speedId: selectedKey ? Number(selectedKey) : null,
})); }));
}} }}
> >
{availableSpeedLimits.map((speedLimit) => ( {availableSpeedLimits.map((speedLimit) => (
<SelectItem <SelectItem
key={speedLimit.id.toString()} key={speedLimit.id.toString()}
textValue={speedLimit.name} textValue={speedLimit.name}
> >
{speedLimit.name} {speedLimit.name}
</SelectItem> </SelectItem>
))} ))}
</Select> </Select>
)}
<Select <Select
description={ description={
@@ -4306,33 +4318,43 @@ export default function ForwardPage() {
))} ))}
</Select> </Select>
<Input {isAdmin && (
description="指定入口端口,留空则从节点可用端口中自动分配" <Input
errorMessage={errors.inPort} description="指定入口端口,留空则从节点可用端口中自动分配"
isInvalid={!!errors.inPort} errorMessage={errors.inPort}
label="入口端口" isInvalid={!!errors.inPort}
placeholder="留空则自动分配可用端口" label="入口端口"
type="number" placeholder="留空则自动分配可用端口"
value={form.inPort !== null ? form.inPort.toString() : ""} type="number"
variant="bordered" value={form.inPort !== null ? form.inPort.toString() : ""}
onChange={(e) => { variant="bordered"
const value = e.target.value; onChange={(e) => {
const value = e.target.value;
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
inPort: value ? parseInt(value) : null, inPort: value ? parseInt(value) : null,
})); }));
}} }}
/> />
)}
<Select <Select
description="从入口节点IP中选择,留空使用默认" description={
isCurrentTunnelMultiEntrance
? "多入口隧道不支持自定义监听IP,使用各节点默认IP"
: "从入口节点IP中选择,留空使用默认"
}
isDisabled={ isDisabled={
!form.tunnelId || currentTunnelIpOptions.length === 0 !form.tunnelId ||
currentTunnelIpOptions.length === 0 ||
isCurrentTunnelMultiEntrance
} }
label="监听IP" label="监听IP"
placeholder={ placeholder={
form.tunnelId isCurrentTunnelMultiEntrance
? "多入口隧道使用节点默认IP"
: form.tunnelId
? currentTunnelIpOptions.length > 0 ? currentTunnelIpOptions.length > 0
? "选择入口监听IP" ? "选择入口监听IP"
: "当前隧道入口节点暂无可选IP" : "当前隧道入口节点暂无可选IP"
+95 -82
View File
@@ -1604,6 +1604,8 @@ export default function TunnelPage() {
.map((ct) => ct.nodeId); .map((ct) => ct.nodeId);
const groupIpOptions = const groupIpOptions =
getCommonIpOptions(groupSelectedNodeIds); getCommonIpOptions(groupSelectedNodeIds);
const isMultiNodeGroup =
groupSelectedNodeIds.length > 1;
const selectedGroupConnectIp = const selectedGroupConnectIp =
groupNodes.length > 0 groupNodes.length > 0
? groupNodes[0].connectIp || "" ? groupNodes[0].connectIp || ""
@@ -1826,14 +1828,21 @@ export default function TunnelPage() {
label: "text-xs", label: "text-xs",
value: "text-sm", value: "text-sm",
}} }}
description="按当前跳所选节点的共有IP进行选择,留空使用默认" description={
isMultiNodeGroup
? "多节点跳不支持设置自定义连接IP,使用各节点默认IP"
: "按当前跳所选节点的共有IP进行选择,留空使用默认"
}
isDisabled={ isDisabled={
groupSelectedNodeIds.length === 0 || groupSelectedNodeIds.length === 0 ||
groupIpOptions.length === 0 groupIpOptions.length === 0 ||
isMultiNodeGroup
} }
label="连接IP" label="连接IP"
placeholder={ placeholder={
groupSelectedNodeIds.length === 0 isMultiNodeGroup
? "多节点跳使用节点默认IP"
: groupSelectedNodeIds.length === 0
? "请先选择节点" ? "请先选择节点"
: groupIpOptions.length > 0 : groupIpOptions.length > 0
? "选择连接IP" ? "选择连接IP"
@@ -1886,7 +1895,17 @@ export default function TunnelPage() {
<Divider /> <Divider />
<h3 className="text-lg font-semibold">出口配置</h3> <h3 className="text-lg font-semibold">出口配置</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-2"> {(() => {
const selectedOutNodeIds = (form.outNodeId || [])
.filter((ct) => ct.nodeId !== -1)
.map((ct) => ct.nodeId);
const isMultiExit = selectedOutNodeIds.length > 1;
const commonOutIpOptions =
getCommonIpOptions(selectedOutNodeIds);
return (
<>
<div className="grid grid-cols-1 md:grid-cols-4 gap-2">
{/* 节点选择 - 移动端100%,桌面端50% */} {/* 节点选择 - 移动端100%,桌面端50% */}
<div className="col-span-1 md:col-span-2"> <div className="col-span-1 md:col-span-2">
<Select <Select
@@ -2124,88 +2143,82 @@ export default function TunnelPage() {
<SelectItem key="round">轮询</SelectItem> <SelectItem key="round">轮询</SelectItem>
<SelectItem key="rand">随机</SelectItem> <SelectItem key="rand">随机</SelectItem>
</Select> </Select>
</div> </div>
{/* 连接IP - 出口节点 */} {/* 连接IP - 出口节点 */}
<Select <Select
classNames={{ classNames={{
label: "text-xs", label: "text-xs",
value: "text-sm", value: "text-sm",
}} }}
description="按出口节点共同可用IP选择,留空使用默认" description={
isDisabled={ isMultiExit
(form.outNodeId || []).filter( ? "多出口隧道不支持设置自定义连接IP,使用各节点默认IP"
(ct) => ct.nodeId !== -1, : "按出口节点共同可用IP选择,留空使用默认"
).length === 0 || }
getCommonIpOptions( isDisabled={
(form.outNodeId || []) selectedOutNodeIds.length === 0 ||
.filter((ct) => ct.nodeId !== -1) commonOutIpOptions.length === 0 ||
.map((ct) => ct.nodeId), isMultiExit
).length === 0 }
} label="连接IP"
label="连接IP" placeholder={
placeholder={ isMultiExit
(form.outNodeId || []).filter( ? "多出口隧道使用节点默认IP"
(ct) => ct.nodeId !== -1, : selectedOutNodeIds.length === 0
).length === 0 ? "请先选择出口节点"
? "请先选择出口节点" : commonOutIpOptions.length > 0
: getCommonIpOptions( ? "选择连接IP"
(form.outNodeId || []) : "所选节点无共同可选IP"
.filter((ct) => ct.nodeId !== -1) }
.map((ct) => ct.nodeId), selectedKeys={[
).length > 0 form.outNodeId && form.outNodeId.length > 0
? "选择连接IP" ? form.outNodeId[0].connectIp || "__default__"
: "所选节点无共同可选IP" : "__default__",
} ]}
selectedKeys={[ size="sm"
form.outNodeId && form.outNodeId.length > 0 variant="bordered"
? form.outNodeId[0].connectIp || "__default__" onSelectionChange={(keys) => {
: "__default__", const selectedKey = Array.from(keys)[0] as string;
]} const value =
size="sm" selectedKey === "__default__" ? "" : selectedKey;
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
const value =
selectedKey === "__default__" ? "" : selectedKey;
setForm((prev) => { setForm((prev) => {
const currentOutNodes = prev.outNodeId || []; const currentOutNodes = prev.outNodeId || [];
if (currentOutNodes.length === 0) { if (currentOutNodes.length === 0) {
return { return {
...prev, ...prev,
outNodeId: [ outNodeId: [
{ {
nodeId: -1, nodeId: -1,
chainType: 3, chainType: 3,
protocol: "tls", protocol: "tls",
strategy: "round", strategy: "round",
connectIp: value, connectIp: value,
}, },
], ],
}; };
} }
return { return {
...prev, ...prev,
outNodeId: currentOutNodes.map((ct) => ({ outNodeId: currentOutNodes.map((ct) => ({
...ct, ...ct,
connectIp: value, connectIp: value,
})), })),
}; };
}); });
}} }}
> >
<SelectItem key="__default__">默认连接IP</SelectItem> <SelectItem key="__default__">默认连接IP</SelectItem>
{getCommonIpOptions( {commonOutIpOptions.map((ip) => (
(form.outNodeId || []) <SelectItem key={ip}>{ip}</SelectItem>
.filter((ct) => ct.nodeId !== -1) ))}
.map((ct) => ct.nodeId), </Select>
).map((ip) => ( </>
<SelectItem key={ip}>{ip}</SelectItem> );
))} })()}
</Select>
</> </>
)} )}
</div> </div>