mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
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:
@@ -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(数据迁移)已纳入计划,当前为可选项,默认不执行。
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user