{ "info": { "id": "ses_34eec2495ffeJvDq7SlEs8mRoH", "slug": "sunny-squid", "projectID": "0d4685396cc353e0f47ffbe712b4e8d0545f5bad", "directory": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula", "title": "提交全部变更并推送及创建 Pull Request", "version": "1.2.15", "summary": { "additions": 0, "deletions": 0, "files": 0 }, "time": { "created": 1772497394538, "updated": 1772497481489 } }, "messages": [ { "info": { "role": "user", "time": { "created": 1772497394628 }, "summary": { "diffs": [] }, "agent": "build", "model": { "providerID": "bailian-coding-plan-test", "modelID": "glm-5" }, "id": "msg_cb113db8300118s1pZWx7icaGT", "sessionID": "ses_34eec2495ffeJvDq7SlEs8mRoH" }, "parts": [ { "type": "text", "text": "提交全部变更并且push,创建pr", "id": "prt_cb113db83002ZlwS3gq4rXNYuf", "sessionID": "ses_34eec2495ffeJvDq7SlEs8mRoH", "messageID": "msg_cb113db8300118s1pZWx7icaGT" } ] }, { "info": { "role": "assistant", "time": { "created": 1772497394637, "completed": 1772497401477 }, "parentID": "msg_cb113db8300118s1pZWx7icaGT", "modelID": "glm-5", "providerID": "bailian-coding-plan-test", "mode": "build", "agent": "build", "path": { "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula", "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula" }, "cost": 0, "tokens": { "total": 17118, "input": 17016, "output": 100, "reasoning": 0, "cache": { "read": 2, "write": 0 } }, "finish": "tool-calls", "id": "msg_cb113dbcd001FkDm4ReRvn3267", "sessionID": "ses_34eec2495ffeJvDq7SlEs8mRoH" }, "parts": [ { "type": "step-start", "snapshot": "9e5d740492cbcd137a59606c49c90a2d21814793", "id": "prt_cb113ed3f001UwP5oWXYVIr4Ou", "sessionID": "ses_34eec2495ffeJvDq7SlEs8mRoH", "messageID": "msg_cb113dbcd001FkDm4ReRvn3267" }, { "type": "reasoning", "text": "用户要求:\n1. 提交全部变更\n2. push\n3. 创建 PR\n\n让我先检查当前的 git 状态、diff 和最近的提交记录。", "metadata": { "anthropic": { "signature": "" } }, "time": { "start": 1772497399104, "end": 1772497400448 }, "id": "prt_cb113ed40001KofvpfrfnNsnwi", "sessionID": "ses_34eec2495ffeJvDq7SlEs8mRoH", "messageID": "msg_cb113dbcd001FkDm4ReRvn3267" }, { "type": "tool", "callID": "toolu_tool-ac8869b5afa748a588751d42685efe71", "tool": "bash", "state": { "status": "completed", "input": { "command": "git status", "description": "查看 git 状态" }, "output": "位于分支 opencode/mighty-nebula\n尚未暂存以备提交的变更:\n (使用 \"git add <文件>...\" 更新要提交的内容)\n (使用 \"git restore <文件>...\" 丢弃工作区的改动)\n\t修改: go-backend/internal/http/handler/control_plane.go\n\t修改: go-backend/internal/http/handler/dual_stack_test.go\n\t修改: go-backend/internal/http/handler/mutations.go\n\t修改: go-backend/internal/store/model/model.go\n\t修改: go-backend/internal/store/repo/repository.go\n\t修改: go-backend/internal/store/repo/repository_control.go\n\t修改: go-backend/internal/store/repo/repository_mutations.go\n\t修改: vite-frontend/src/pages/forward.tsx\n\t修改: vite-frontend/src/pages/node.tsx\n\t修改: vite-frontend/src/pages/tunnel.tsx\n\n未跟踪的文件:\n (使用 \"git add <文件>...\" 以包含要提交的内容)\n\t.entire/metadata/\n\t211-custom-ip-selection.md\n\n修改尚未加入提交(使用 \"git add\" 和/或 \"git commit -a\")\n", "title": "查看 git 状态", "metadata": { "output": "位于分支 opencode/mighty-nebula\n尚未暂存以备提交的变更:\n (使用 \"git add <文件>...\" 更新要提交的内容)\n (使用 \"git restore <文件>...\" 丢弃工作区的改动)\n\t修改: go-backend/internal/http/handler/control_plane.go\n\t修改: go-backend/internal/http/handler/dual_stack_test.go\n\t修改: go-backend/internal/http/handler/mutations.go\n\t修改: go-backend/internal/store/model/model.go\n\t修改: go-backend/internal/store/repo/repository.go\n\t修改: go-backend/internal/store/repo/repository_control.go\n\t修改: go-backend/internal/store/repo/repository_mutations.go\n\t修改: vite-frontend/src/pages/forward.tsx\n\t修改: vite-frontend/src/pages/node.tsx\n\t修改: vite-frontend/src/pages/tunnel.tsx\n\n未跟踪的文件:\n (使用 \"git add <文件>...\" 以包含要提交的内容)\n\t.entire/metadata/\n\t211-custom-ip-selection.md\n\n修改尚未加入提交(使用 \"git add\" 和/或 \"git commit -a\")\n", "exit": 0, "description": "查看 git 状态", "truncated": false }, "time": { "start": 1772497401371, "end": 1772497401422 } }, "id": "prt_cb113f282001C7dYMjTzBATWUt", "sessionID": "ses_34eec2495ffeJvDq7SlEs8mRoH", "messageID": "msg_cb113dbcd001FkDm4ReRvn3267" }, { "type": "tool", "callID": "toolu_tool-94c8183807834a99a52b18b6647bf159", "tool": "bash", "state": { "status": "completed", "input": { "command": "git diff", "description": "查看未暂存的更改" }, "output": "diff --git a/go-backend/internal/http/handler/control_plane.go b/go-backend/internal/http/handler/control_plane.go\nindex 7ec8976..5dc4b33 100644\n--- a/go-backend/internal/http/handler/control_plane.go\n+++ b/go-backend/internal/http/handler/control_plane.go\n@@ -72,7 +72,7 @@ func (h *Handler) buildDiagnosisStreamStartItems(workItems []diagnosisWorkItem)\n \t\t\tfromNode, _ := h.cachedNode(nodeCache, workItem.fromNodeID)\n \t\t\ttargetNode, err := h.cachedNode(nodeCache, workItem.toNode.NodeID)\n \t\t\tif err == nil {\n-\t\t\t\tresolvedIP, resolvedPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, workItem.toNode.Port, workItem.ipPreference)\n+\t\t\t\tresolvedIP, resolvedPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, workItem.toNode.Port, workItem.ipPreference, \"\")\n \t\t\t\tif resolveErr == nil {\n \t\t\t\t\ttargetIP = resolvedIP\n \t\t\t\t\ttargetPort = resolvedPort\n@@ -1099,7 +1099,7 @@ func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nod\n \t\th.appendFailedDiagnosis(results, nodeCache, fromNodeID, \"\", 0, description, metadata, err.Error())\n \t\treturn\n \t}\n-\ttargetIP, targetPort, err := resolveChainProbeTarget(fromNode, targetNode, toNode.Port, ipPreference)\n+\ttargetIP, targetPort, err := resolveChainProbeTarget(fromNode, targetNode, toNode.Port, ipPreference, \"\")\n \tif err != nil {\n \t\th.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), \"[]\"), toNode.Port, description, metadata, err.Error())\n \t\treturn\n@@ -1107,11 +1107,11 @@ func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nod\n \th.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata, options)\n }\n \n-func resolveChainProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string) (string, int, error) {\n+func resolveChainProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string, connectIp string) (string, int, error) {\n \tif targetNode == nil {\n \t\treturn \"\", 0, errors.New(\"目标节点不存在\")\n \t}\n-\thost, err := selectTunnelDialHost(fromNode, targetNode, ipPreference)\n+\thost, err := selectTunnelDialHost(fromNode, targetNode, ipPreference, connectIp)\n \tif err != nil {\n \t\thost = strings.Trim(strings.TrimSpace(targetNode.ServerIP), \"[]\")\n \t}\ndiff --git a/go-backend/internal/http/handler/dual_stack_test.go b/go-backend/internal/http/handler/dual_stack_test.go\nindex ee50353..3c423a4 100644\n--- a/go-backend/internal/http/handler/dual_stack_test.go\n+++ b/go-backend/internal/http/handler/dual_stack_test.go\n@@ -8,9 +8,25 @@ import (\n // nodeSupportsV4 / nodeSupportsV6\n // ---------------------------------------------------------------------------\n \n-func TestNodeSupportsV4_Nil(t *testing.T) {\n-\tif nodeSupportsV4(nil) {\n-\t\tt.Fatal(\"nil node must not support v4\")\n+func TestSelectTunnelDialHost_ConnectIpPriority(t *testing.T) {\n+\tfrom := dualStackNode(\"from\", \"10.0.0.1\", \"2001:db8::1\")\n+\tto := dualStackNode(\"to\", \"10.0.0.2\", \"2001:db8::2\")\n+\n+\t// Empty connectIp should be ignored, IP preference takes effect\n+\thost, err := selectTunnelDialHost(from, to, \"\", \"\")\n+\tif err != nil {\n+\t\tt.Fatalf(\"unexpected error: %v\", err)\n+\t}\n+\tif host != \"10.0.0.2\" {\n+\t\tt.Fatalf(\"empty connectIp should be ignored (v4 preference applies), got %q\", host)\n+\t}\n+\t// Non-empty connectIp should override IP preference\n+\thost, err = selectTunnelDialHost(from, to, \"v6\", \"192.168.0.3\")\n+\tif err != nil {\n+\t\tt.Fatalf(\"unexpected error: %v\", err)\n+\t}\n+\tif host != \"192.168.0.3\" {\n+\t\tt.Fatalf(\"connectIp should override v6 preference, got %q\", host)\n \t}\n }\n \n@@ -23,14 +39,14 @@ func TestNodeSupportsV6_Nil(t *testing.T) {\n func TestNodeSupportsV4_ExplicitV4(t *testing.T) {\n \tn := &nodeRecord{ServerIPv4: \"10.0.0.1\"}\n \tif !nodeSupportsV4(n) {\n-\t\tt.Fatal(\"explicit server_ip_v4 must support v4\")\n+\t\tt.Fatal(\"explicit server_ip_v4 needs support v4\")\n \t}\n }\n \n func TestNodeSupportsV6_ExplicitV6(t *testing.T) {\n \tn := &nodeRecord{ServerIPv6: \"2001:db8::1\"}\n \tif !nodeSupportsV6(n) {\n-\t\tt.Fatal(\"explicit server_ip_v6 must support v6\")\n+\t\tt.Fatal(\"explicit server_ip_v6 needs support v6\")\n \t}\n }\n \n@@ -68,7 +84,7 @@ func TestNodeSupportsV4_LegacyV4Only(t *testing.T) {\n \t\tt.Fatal(\"legacy v4 ip in server_ip must support v4\")\n \t}\n \tif nodeSupportsV6(n) {\n-\t\tt.Fatal(\"legacy v4 ip in server_ip must not support v6\")\n+\t\tt.Fatal(\"legacy v4 ip in server_ip should not support v6\")\n \t}\n }\n \n@@ -78,7 +94,7 @@ func TestNodeSupportsV6_LegacyV6Only(t *testing.T) {\n \t\tt.Fatal(\"legacy v6 ip in server_ip must support v6\")\n \t}\n \tif nodeSupportsV4(n) {\n-\t\tt.Fatal(\"legacy v6 ip in server_ip must not support v4\")\n+\t\tt.Fatal(\"legacy v6 ip in server_ip should not support v4\")\n \t}\n }\n \n@@ -177,15 +193,15 @@ func v6OnlyNode(name, v6 string) *nodeRecord {\n }\n \n func TestSelectTunnelDialHost_NilNodes(t *testing.T) {\n-\t_, err := selectTunnelDialHost(nil, nil, \"\")\n+\t_, err := selectTunnelDialHost(nil, nil, \"\", \"\")\n \tif err == nil {\n \t\tt.Fatal(\"expected error for nil nodes\")\n \t}\n-\t_, err = selectTunnelDialHost(dualStackNode(\"a\", \"1.1.1.1\", \"::1\"), nil, \"\")\n+\t_, err = selectTunnelDialHost(dualStackNode(\"a\", \"1.1.1.1\", \"::1\"), nil, \"\", \"\")\n \tif err == nil {\n \t\tt.Fatal(\"expected error for nil toNode\")\n \t}\n-\t_, err = selectTunnelDialHost(nil, dualStackNode(\"b\", \"1.1.1.1\", \"::1\"), \"\")\n+\t_, err = selectTunnelDialHost(nil, dualStackNode(\"b\", \"1.1.1.1\", \"::1\"), \"\", \"\")\n \tif err == nil {\n \t\tt.Fatal(\"expected error for nil fromNode\")\n \t}\n@@ -194,8 +210,7 @@ func TestSelectTunnelDialHost_NilNodes(t *testing.T) {\n func TestSelectTunnelDialHost_DualStack_DefaultPreference(t *testing.T) {\n \tfrom := dualStackNode(\"from\", \"10.0.0.1\", \"2001:db8::1\")\n \tto := dualStackNode(\"to\", \"10.0.0.2\", \"2001:db8::2\")\n-\n-\thost, err := selectTunnelDialHost(from, to, \"\")\n+\thost, err := selectTunnelDialHost(from, to, \"\", \"\")\n \tif err != nil {\n \t\tt.Fatalf(\"unexpected error: %v\", err)\n \t}\n@@ -208,8 +223,7 @@ func TestSelectTunnelDialHost_DualStack_DefaultPreference(t *testing.T) {\n func TestSelectTunnelDialHost_DualStack_PreferV4(t *testing.T) {\n \tfrom := dualStackNode(\"from\", \"10.0.0.1\", \"2001:db8::1\")\n \tto := dualStackNode(\"to\", \"10.0.0.2\", \"2001:db8::2\")\n-\n-\thost, err := selectTunnelDialHost(from, to, \"v4\")\n+\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\n \tif err != nil {\n \t\tt.Fatalf(\"unexpected error: %v\", err)\n \t}\n@@ -221,8 +235,7 @@ func TestSelectTunnelDialHost_DualStack_PreferV4(t *testing.T) {\n func TestSelectTunnelDialHost_DualStack_PreferV6(t *testing.T) {\n \tfrom := dualStackNode(\"from\", \"10.0.0.1\", \"2001:db8::1\")\n \tto := dualStackNode(\"to\", \"10.0.0.2\", \"2001:db8::2\")\n-\n-\thost, err := selectTunnelDialHost(from, to, \"v6\")\n+\thost, err := selectTunnelDialHost(from, to, \"v6\", \"\")\n \tif err != nil {\n \t\tt.Fatalf(\"unexpected error: %v\", err)\n \t}\n@@ -234,9 +247,8 @@ func TestSelectTunnelDialHost_DualStack_PreferV6(t *testing.T) {\n func TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {\n \tfrom := v4OnlyNode(\"from\", \"10.0.0.1\")\n \tto := v4OnlyNode(\"to\", \"10.0.0.2\")\n-\n \t// User prefers v6, but both nodes are v4-only — should fallback to v4\n-\thost, err := selectTunnelDialHost(from, to, \"v6\")\n+\thost, err := selectTunnelDialHost(from, to, \"v6\", \"\")\n \tif err != nil {\n \t\tt.Fatalf(\"unexpected error: %v\", err)\n \t}\n@@ -248,9 +260,8 @@ func TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {\n func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {\n \tfrom := v6OnlyNode(\"from\", \"2001:db8::1\")\n \tto := v6OnlyNode(\"to\", \"2001:db8::2\")\n-\n \t// User prefers v4, but both nodes are v6-only — should fallback to v6\n-\thost, err := selectTunnelDialHost(from, to, \"v4\")\n+\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\n \tif err != nil {\n \t\tt.Fatalf(\"unexpected error: %v\", err)\n \t}\n@@ -262,8 +273,7 @@ func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {\n func TestSelectTunnelDialHost_Incompatible(t *testing.T) {\n \tfrom := v4OnlyNode(\"from\", \"10.0.0.1\")\n \tto := v6OnlyNode(\"to\", \"2001:db8::2\")\n-\n-\t_, err := selectTunnelDialHost(from, to, \"\")\n+\t_, err := selectTunnelDialHost(from, to, \"\", \"\")\n \tif err == nil {\n \t\tt.Fatal(\"expected error for incompatible nodes (v4-only -> v6-only)\")\n \t}\n@@ -272,8 +282,7 @@ func TestSelectTunnelDialHost_Incompatible(t *testing.T) {\n func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {\n \tfrom := v6OnlyNode(\"from\", \"2001:db8::1\")\n \tto := v4OnlyNode(\"to\", \"10.0.0.2\")\n-\n-\t_, err := selectTunnelDialHost(from, to, \"\")\n+\t_, err := selectTunnelDialHost(from, to, \"\", \"\")\n \tif err == nil {\n \t\tt.Fatal(\"expected error for incompatible nodes (v6-only -> v4-only)\")\n \t}\n@@ -282,9 +291,8 @@ func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {\n func TestSelectTunnelDialHost_WhitespacePreference(t *testing.T) {\n \tfrom := dualStackNode(\"from\", \"10.0.0.1\", \"2001:db8::1\")\n \tto := dualStackNode(\"to\", \"10.0.0.2\", \"2001:db8::2\")\n-\n \t// Whitespace should be trimmed, treated as \"v6\"\n-\thost, err := selectTunnelDialHost(from, to, \" v6 \")\n+\thost, err := selectTunnelDialHost(from, to, \" v6 \", \"\")\n \tif err != nil {\n \t\tt.Fatalf(\"unexpected error: %v\", err)\n \t}\n@@ -296,9 +304,8 @@ func TestSelectTunnelDialHost_WhitespacePreference(t *testing.T) {\n func TestSelectTunnelDialHost_MixedStack_FromDualToV4(t *testing.T) {\n \tfrom := dualStackNode(\"from\", \"10.0.0.1\", \"2001:db8::1\")\n \tto := v4OnlyNode(\"to\", \"10.0.0.2\")\n-\n \t// v6 preferred, but target only has v4 — should succeed with v4\n-\thost, err := selectTunnelDialHost(from, to, \"v6\")\n+\thost, err := selectTunnelDialHost(from, to, \"v6\", \"\")\n \tif err != nil {\n \t\tt.Fatalf(\"unexpected error: %v\", err)\n \t}\n@@ -310,9 +317,8 @@ func TestSelectTunnelDialHost_MixedStack_FromDualToV4(t *testing.T) {\n func TestSelectTunnelDialHost_MixedStack_FromDualToV6(t *testing.T) {\n \tfrom := dualStackNode(\"from\", \"10.0.0.1\", \"2001:db8::1\")\n \tto := v6OnlyNode(\"to\", \"2001:db8::2\")\n-\n \t// v4 preferred, but target only has v6 — should succeed with v6\n-\thost, err := selectTunnelDialHost(from, to, \"v4\")\n+\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\n \tif err != nil {\n \t\tt.Fatalf(\"unexpected error: %v\", err)\n \t}\n@@ -324,9 +330,8 @@ func TestSelectTunnelDialHost_MixedStack_FromDualToV6(t *testing.T) {\n func TestSelectTunnelDialHost_MixedStack_FromV4ToDual(t *testing.T) {\n \tfrom := v4OnlyNode(\"from\", \"10.0.0.1\")\n \tto := dualStackNode(\"to\", \"10.0.0.2\", \"2001:db8::2\")\n-\n \t// v6 preferred, but from only has v4 — should use v4 (from can only reach v4 of target)\n-\thost, err := selectTunnelDialHost(from, to, \"v6\")\n+\thost, err := selectTunnelDialHost(from, to, \"v6\", \"\")\n \tif err != nil {\n \t\tt.Fatalf(\"unexpected error: %v\", err)\n \t}\n@@ -338,9 +343,8 @@ func TestSelectTunnelDialHost_MixedStack_FromV4ToDual(t *testing.T) {\n func TestSelectTunnelDialHost_MixedStack_FromV6ToDual(t *testing.T) {\n \tfrom := v6OnlyNode(\"from\", \"2001:db8::1\")\n \tto := dualStackNode(\"to\", \"10.0.0.2\", \"2001:db8::2\")\n-\n \t// v4 preferred, but from only has v6 — should use v6\n-\thost, err := selectTunnelDialHost(from, to, \"v4\")\n+\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\n \tif err != nil {\n \t\tt.Fatalf(\"unexpected error: %v\", err)\n \t}\n@@ -367,7 +371,6 @@ func TestNodeDisplayName_Named(t *testing.T) {\n \t\tt.Fatalf(\"expected 'hk-node', got %q\", got)\n \t}\n }\n-\n func TestNodeDisplayName_Unnamed(t *testing.T) {\n \tn := &nodeRecord{ID: 42}\n \tgot := nodeDisplayName(n)\ndiff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go\nindex aa44704..d314e87 100644\n--- a/go-backend/internal/http/handler/mutations.go\n+++ b/go-backend/internal/http/handler/mutations.go\n@@ -271,6 +271,7 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {\n \t\tnullableText(asString(req[\"remoteUrl\"])),\n \t\tnullableText(asString(req[\"remoteToken\"])),\n \t\tnullableText(asString(req[\"remoteConfig\"])),\n+\t\tnullableText(asString(req[\"extraIPs\"])),\n \t); err != nil {\n \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n \t\treturn\n@@ -322,6 +323,7 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {\n \t\tnullableText(asString(req[\"serverIpV6\"])),\n \t\tdefaultString(asString(req[\"port\"]), \"1000-65535\"),\n \t\tnullableText(asString(req[\"interfaceName\"])),\n+\t\tnullableText(asString(req[\"extraIPs\"])),\n \t\tnewHTTP,\n \t\tnewTLS,\n \t\tnewSocks,\n@@ -1176,7 +1178,8 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {\n \tif userName == \"\" {\n \t\tuserName = \"user\"\n \t}\n-\tforwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req[\"strategy\"]), \"fifo\"), now, inx, entryNodes, port, nullableInt(speedID))\n+\tinIp := strings.TrimSpace(asString(req[\"inIp\"]))\n+\tforwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req[\"strategy\"]), \"fifo\"), now, inx, entryNodes, port, inIp, nullableInt(speedID))\n \tif err != nil {\n \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n \t\treturn\n@@ -1280,6 +1283,7 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {\n \t\t\tport = h.pickTunnelPort(tunnelID)\n \t\t}\n \t}\n+\tinIp := asString(req[\"inIp\"])\n \tfwdEntryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)\n \tfor _, nodeID := range fwdEntryNodes {\n \t\tnode, nodeErr := h.getNodeRecord(nodeID)\n@@ -1296,7 +1300,7 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {\n \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n \t\treturn\n \t}\n-\tif err := h.replaceForwardPorts(id, tunnelID, port); err != nil {\n+\tif err := h.replaceForwardPorts(id, tunnelID, port, inIp); err != nil {\n \t\th.rollbackForwardMutation(forward, oldPorts)\n \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n \t\treturn\n@@ -1628,7 +1632,7 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques\n \t\t\tfail++\n \t\t\tcontinue\n \t\t}\n-\t\tif err := h.replaceForwardPorts(id, req.TargetTunnelID, p); err != nil {\n+\t\tif err := h.replaceForwardPorts(id, req.TargetTunnelID, p, \"\"); err != nil {\n \t\t\th.rollbackForwardMutation(forward, oldPorts)\n \t\t\tfail++\n \t\t\tcontinue\n@@ -1954,6 +1958,7 @@ type tunnelRuntimeNode struct {\n \tInx int\n \tChainType int\n \tPort int\n+\tConnectIP string\n }\n \n type tunnelCreateState struct {\n@@ -2027,6 +2032,7 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface\n \t\t\t\tStrategy: defaultString(asString(item[\"strategy\"]), \"round\"),\n \t\t\t\tChainType: 3,\n \t\t\t\tPort: port,\n+\t\t\t\tConnectIP: asString(item[\"connectIp\"]),\n \t\t\t})\n \t\t}\n \t\tif len(state.OutNodes) == 0 {\n@@ -2062,6 +2068,7 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface\n \t\t\t\t\tInx: hopIdx + 1,\n \t\t\t\t\tChainType: 2,\n \t\t\t\t\tPort: port,\n+\t\t\t\t\tConnectIP: asString(item[\"connectIp\"]),\n \t\t\t\t})\n \t\t\t}\n \t\t\tif len(hop) > 0 {\n@@ -2338,7 +2345,7 @@ func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain s\n \t\t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n \t\t\t\t\treturn nil, nil, errors.New(\"节点不存在\")\n \t\t\t\t}\n-\t\t\t\thost, hostErr := selectTunnelDialHost(node, targetNode, state.IPPreference)\n+\t\t\t\thost, hostErr := selectTunnelDialHost(node, targetNode, state.IPPreference, target.ConnectIP)\n \t\t\t\tif hostErr != nil {\n \t\t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n \t\t\t\t\treturn nil, nil, hostErr\n@@ -2578,7 +2585,7 @@ func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRu\n \t\tif targetNode == nil {\n \t\t\treturn nil, errors.New(\"节点不存在\")\n \t\t}\n-\t\thost, err := selectTunnelDialHost(fromNode, targetNode, ipPreference)\n+\t\thost, err := selectTunnelDialHost(fromNode, targetNode, ipPreference, target.ConnectIP)\n \t\tif err != nil {\n \t\t\treturn nil, err\n \t\t}\n@@ -2651,10 +2658,13 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,\n \treturn []map[string]interface{}{service}\n }\n \n-func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string) (string, error) {\n+func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, connectIp string) (string, error) {\n \tif fromNode == nil || toNode == nil {\n \t\treturn \"\", errors.New(\"节点不存在\")\n \t}\n+\tif strings.TrimSpace(connectIp) != \"\" {\n+\t\treturn strings.TrimSpace(connectIp), nil\n+\t}\n \tfromV4 := nodeSupportsV4(fromNode)\n \tfromV6 := nodeSupportsV6(fromNode)\n \ttoV4 := nodeSupportsV4(toNode)\n@@ -2789,6 +2799,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str\n \t\t\tdefaultString(asString(n[\"strategy\"]), \"round\"),\n \t\t\ti+1,\n \t\t\tdefaultString(asString(n[\"protocol\"]), \"tls\"),\n+\t\t\t\"\",\n \t\t); err != nil {\n \t\t\treturn err\n \t\t}\n@@ -2806,6 +2817,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str\n \t\t\t\treturn pickErr\n \t\t\t}\n \t\t}\n+\t\tconnectIp := asString(n[\"connectIp\"])\n \t\tif err := h.repo.CreateChainTunnelTx(\n \t\t\ttx,\n \t\t\ttunnelID,\n@@ -2815,6 +2827,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str\n \t\t\tdefaultString(asString(n[\"strategy\"]), \"round\"),\n \t\t\ti+1,\n \t\t\tdefaultString(asString(n[\"protocol\"]), \"tls\"),\n+\t\t\tconnectIp,\n \t\t); err != nil {\n \t\t\treturn err\n \t\t}\n@@ -2834,6 +2847,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str\n \t\t\t\t\treturn pickErr\n \t\t\t\t}\n \t\t\t}\n+\t\t\tconnectIp := asString(n[\"connectIp\"])\n \t\t\tif err := h.repo.CreateChainTunnelTx(\n \t\t\t\ttx,\n \t\t\t\ttunnelID,\n@@ -2843,6 +2857,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str\n \t\t\t\tdefaultString(asString(n[\"strategy\"]), \"round\"),\n \t\t\t\ti+1,\n \t\t\t\tdefaultString(asString(n[\"protocol\"]), \"tls\"),\n+\t\t\t\tconnectIp,\n \t\t\t); err != nil {\n \t\t\t\treturn err\n \t\t\t}\n@@ -2999,7 +3014,7 @@ func parsePorts(portRange string) ([]int, error) {\n \treturn ports, nil\n }\n \n-func (h *Handler) replaceForwardPorts(forwardID, tunnelID int64, port int) error {\n+func (h *Handler) replaceForwardPorts(forwardID, tunnelID int64, port int, inIp string) error {\n \tentryNodes, err := h.tunnelEntryNodeIDs(tunnelID)\n \tif err != nil {\n \t\treturn err\n@@ -3007,12 +3022,14 @@ func (h *Handler) replaceForwardPorts(forwardID, tunnelID int64, port int) error\n \tentries := make([]struct {\n \t\tNodeID int64\n \t\tPort int\n+\t\tInIP string\n \t}, len(entryNodes))\n \tfor i, nid := range entryNodes {\n \t\tentries[i] = struct {\n \t\t\tNodeID int64\n \t\t\tPort int\n-\t\t}{NodeID: nid, Port: port}\n+\t\t\tInIP string\n+\t\t}{NodeID: nid, Port: port, InIP: inIp}\n \t}\n \treturn h.repo.ReplaceForwardPorts(forwardID, entries)\n }\n@@ -3021,12 +3038,14 @@ func (h *Handler) replaceForwardPortsWithRecords(forwardID int64, ports []forwar\n \tentries := make([]struct {\n \t\tNodeID int64\n \t\tPort int\n+\t\tInIP string\n \t}, len(ports))\n \tfor i, fp := range ports {\n \t\tentries[i] = struct {\n \t\t\tNodeID int64\n \t\t\tPort int\n-\t\t}{NodeID: fp.NodeID, Port: fp.Port}\n+\t\t\tInIP string\n+\t\t}{NodeID: fp.NodeID, Port: fp.Port, InIP: fp.InIP}\n \t}\n \treturn h.repo.ReplaceForwardPorts(forwardID, entries)\n }\ndiff --git a/go-backend/internal/store/model/model.go b/go-backend/internal/store/model/model.go\nindex fcdc8ca..e485683 100644\n--- a/go-backend/internal/store/model/model.go\n+++ b/go-backend/internal/store/model/model.go\n@@ -48,10 +48,11 @@ type Forward struct {\n func (Forward) TableName() string { return \"forward\" }\n \n type ForwardPort struct {\n-\tID int64 `gorm:\"primaryKey;autoIncrement\"`\n-\tForwardID int64 `gorm:\"column:forward_id;not null\"`\n-\tNodeID int64 `gorm:\"column:node_id;not null\"`\n-\tPort int `gorm:\"not null\"`\n+\tID int64 `gorm:\"primaryKey;autoIncrement\"`\n+\tForwardID int64 `gorm:\"column:forward_id;not null\"`\n+\tNodeID int64 `gorm:\"column:node_id;not null\"`\n+\tPort int `gorm:\"not null\"`\n+\tInIP sql.NullString `gorm:\"column:in_ip;type:text\"`\n }\n \n func (ForwardPort) TableName() string { return \"forward_port\" }\n@@ -63,6 +64,7 @@ type Node struct {\n \tServerIP string `gorm:\"column:server_ip;type:varchar(100);not null\"`\n \tServerIPV4 sql.NullString `gorm:\"column:server_ip_v4;type:varchar(100)\"`\n \tServerIPV6 sql.NullString `gorm:\"column:server_ip_v6;type:varchar(100)\"`\n+\tExtraIPs sql.NullString `gorm:\"column:extra_ips;type:text\"`\n \tPort string `gorm:\"type:text;not null\"`\n \tInterfaceName sql.NullString `gorm:\"column:interface_name;type:varchar(200)\"`\n \tVersion sql.NullString `gorm:\"type:varchar(100)\"`\n@@ -133,6 +135,7 @@ type ChainTunnel struct {\n \tStrategy sql.NullString `gorm:\"type:varchar(10)\"`\n \tInx sql.NullInt64 `gorm:\"column:inx\"`\n \tProtocol sql.NullString `gorm:\"type:varchar(10)\"`\n+\tConnectIP sql.NullString `gorm:\"column:connect_ip;type:varchar(45)\"`\n }\n \n func (ChainTunnel) TableName() string { return \"chain_tunnel\" }\n@@ -337,6 +340,7 @@ type NodeBackup struct {\n \tServerIP string `json:\"serverIp\"`\n \tServerIPv4 string `json:\"serverIpV4,omitempty\"`\n \tServerIPv6 string `json:\"serverIpV6,omitempty\"`\n+\tExtraIPs string `json:\"extraIPs,omitempty\"`\n \tPort string `json:\"port\"`\n \tInterfaceName string `json:\"interfaceName,omitempty\"`\n \tVersion string `json:\"version,omitempty\"`\n@@ -510,6 +514,7 @@ type TunnelRecord struct {\n type ForwardPortRecord struct {\n \tNodeID int64\n \tPort int\n+\tInIP string\n }\n \n // NodeRecord is a node view used by control plane.\n@@ -519,6 +524,7 @@ type NodeRecord struct {\n \tServerIP string\n \tServerIPv4 string\n \tServerIPv6 string\n+\tExtraIPs string\n \tStatus int\n \tPortRange string\n \tTCPListenAddr string\n@@ -538,6 +544,7 @@ type ChainNodeRecord struct {\n \tNodeName string\n \tProtocol string\n \tStrategy string\n+\tConnectIP string\n }\n \n type UserTunnelLimiterInfo struct {\ndiff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go\nindex 6f42cc1..b2c4b80 100644\n--- a/go-backend/internal/store/repo/repository.go\n+++ b/go-backend/internal/store/repo/repository.go\n@@ -637,6 +637,7 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {\n \t\t\t\"ip\": n.ServerIP, \"serverIp\": n.ServerIP,\n \t\t\t\"serverIpV4\": nullableString(n.ServerIPV4),\n \t\t\t\"serverIpV6\": nullableString(n.ServerIPV6),\n+\t\t\t\"extraIPs\": nullableString(n.ExtraIPs),\n \t\t\t\"port\": n.Port,\n \t\t\t\"tcpListenAddr\": n.TCPListenAddr,\n \t\t\t\"udpListenAddr\": n.UDPListenAddr,\n@@ -2731,10 +2732,11 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string\n \ttype fpRow struct {\n \t\tPort sql.NullInt64\n \t\tServerIP sql.NullString\n+\t\tInIP sql.NullString\n \t}\n \tvar fpRows []fpRow\n \terr := db.Model(&model.ForwardPort{}).\n-\t\tSelect(\"forward_port.port, node.server_ip\").\n+\t\tSelect(\"forward_port.port, node.server_ip, forward_port.in_ip\").\n \t\tJoins(\"LEFT JOIN node ON node.id = forward_port.node_id\").\n \t\tWhere(\"forward_port.forward_id = ?\", forwardID).\n \t\tOrder(\"forward_port.id ASC\").\n@@ -2744,10 +2746,22 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string\n \t}\n \n \tports := make([]int64, 0)\n-\tnodePairs := make([]string, 0)\n+\tentries := make([]string, 0)\n \tseenPorts := make(map[int64]struct{})\n \tseenPairs := make(map[string]struct{})\n \n+\tvar tunnelFirstIP string\n+\tif tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != \"\" {\n+\t\ttunnelIPs := strings.Split(tunnelInIP.String, \",\")\n+\t\tfor _, ip := range tunnelIPs {\n+\t\t\tip = strings.TrimSpace(ip)\n+\t\t\tif ip != \"\" {\n+\t\t\t\ttunnelFirstIP = ip\n+\t\t\t\tbreak\n+\t\t\t}\n+\t\t}\n+\t}\n+\n \tfor _, row := range fpRows {\n \t\tif !row.Port.Valid {\n \t\t\tcontinue\n@@ -2756,11 +2770,21 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string\n \t\t\tseenPorts[row.Port.Int64] = struct{}{}\n \t\t\tports = append(ports, row.Port.Int64)\n \t\t}\n-\t\tif row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != \"\" {\n-\t\t\tpair := fmt.Sprintf(\"%s:%d\", strings.TrimSpace(row.ServerIP.String), row.Port.Int64)\n+\n+\t\tvar ip string\n+\t\tif row.InIP.Valid && strings.TrimSpace(row.InIP.String) != \"\" {\n+\t\t\tip = strings.TrimSpace(row.InIP.String)\n+\t\t} else if tunnelFirstIP != \"\" {\n+\t\t\tip = tunnelFirstIP\n+\t\t} else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != \"\" {\n+\t\t\tip = strings.TrimSpace(row.ServerIP.String)\n+\t\t}\n+\n+\t\tif ip != \"\" {\n+\t\t\tpair := fmt.Sprintf(\"%s:%d\", ip, row.Port.Int64)\n \t\t\tif _, ok := seenPairs[pair]; !ok {\n \t\t\t\tseenPairs[pair] = struct{}{}\n-\t\t\t\tnodePairs = append(nodePairs, pair)\n+\t\t\t\tentries = append(entries, pair)\n \t\t\t}\n \t\t}\n \t}\n@@ -2771,27 +2795,6 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string\n \n \tinPort := sql.NullInt64{Int64: ports[0], Valid: true}\n \n-\tentries := make([]string, 0)\n-\tif tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != \"\" {\n-\t\ttunnelIPs := strings.Split(tunnelInIP.String, \",\")\n-\t\tseen := make(map[string]struct{})\n-\t\tfor _, ip := range tunnelIPs {\n-\t\t\tip = strings.TrimSpace(ip)\n-\t\t\tif ip == \"\" {\n-\t\t\t\tcontinue\n-\t\t\t}\n-\t\t\tif _, ok := seen[ip]; ok {\n-\t\t\t\tcontinue\n-\t\t\t}\n-\t\t\tseen[ip] = struct{}{}\n-\t\t\tfor _, port := range ports {\n-\t\t\t\tentries = append(entries, fmt.Sprintf(\"%s:%d\", ip, port))\n-\t\t\t}\n-\t\t}\n-\t} else {\n-\t\tentries = append(entries, nodePairs...)\n-\t}\n-\n \treturn strings.Join(entries, \",\"), inPort, nil\n }\n \ndiff --git a/go-backend/internal/store/repo/repository_control.go b/go-backend/internal/store/repo/repository_control.go\nindex 680fae8..2799486 100644\n--- a/go-backend/internal/store/repo/repository_control.go\n+++ b/go-backend/internal/store/repo/repository_control.go\n@@ -102,7 +102,11 @@ func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecor\n \t}\n \trows := make([]model.ForwardPortRecord, 0, len(ports))\n \tfor _, p := range ports {\n-\t\trows = append(rows, model.ForwardPortRecord{NodeID: p.NodeID, Port: p.Port})\n+\t\tinIP := \"\"\n+\t\tif p.InIP.Valid {\n+\t\t\tinIP = p.InIP.String\n+\t\t}\n+\t\trows = append(rows, model.ForwardPortRecord{NodeID: p.NodeID, Port: p.Port, InIP: inIP})\n \t}\n \treturn rows, nil\n }\n@@ -177,6 +181,9 @@ func nodeRecordFromModel(n *model.Node) *model.NodeRecord {\n \tif n.ServerIPV6.Valid {\n \t\trec.ServerIPv6 = strings.TrimSpace(n.ServerIPV6.String)\n \t}\n+\tif n.ExtraIPs.Valid {\n+\t\trec.ExtraIPs = strings.TrimSpace(n.ExtraIPs.String)\n+\t}\n \tif n.InterfaceName.Valid {\n \t\trec.InterfaceName = strings.TrimSpace(n.InterfaceName.String)\n \t}\n@@ -289,10 +296,11 @@ func (r *Repository) ListChainNodesForTunnel(tunnelID int64) ([]model.ChainNodeR\n \t\tName sql.NullString\n \t\tProtocol sql.NullString\n \t\tStrategy sql.NullString\n+\t\tConnectIP sql.NullString\n \t}\n \tvar rows []row\n \terr := r.db.Model(&model.ChainTunnel{}).\n-\t\tSelect(\"chain_tunnel.chain_type, chain_tunnel.inx, chain_tunnel.node_id, chain_tunnel.port, node.name, chain_tunnel.protocol, chain_tunnel.strategy\").\n+\t\tSelect(\"chain_tunnel.chain_type, chain_tunnel.inx, chain_tunnel.node_id, chain_tunnel.port, node.name, chain_tunnel.protocol, chain_tunnel.strategy, chain_tunnel.connect_ip\").\n \t\tJoins(\"LEFT JOIN node ON node.id = chain_tunnel.node_id\").\n \t\tWhere(\"chain_tunnel.tunnel_id = ?\", tunnelID).\n \t\tOrder(\"chain_tunnel.chain_type ASC, chain_tunnel.inx ASC, chain_tunnel.id ASC\").\n@@ -337,6 +345,9 @@ func (r *Repository) ListChainNodesForTunnel(tunnelID int64) ([]model.ChainNodeR\n \t\t} else {\n \t\t\titem.Strategy = row.Strategy.String\n \t\t}\n+\t\tif row.ConnectIP.Valid {\n+\t\t\titem.ConnectIP = row.ConnectIP.String\n+\t\t}\n \t\tresult = append(result, item)\n \t}\n \treturn result, nil\ndiff --git a/go-backend/internal/store/repo/repository_mutations.go b/go-backend/internal/store/repo/repository_mutations.go\nindex c7fb53b..76cc6bc 100644\n--- a/go-backend/internal/store/repo/repository_mutations.go\n+++ b/go-backend/internal/store/repo/repository_mutations.go\n@@ -196,7 +196,7 @@ func (r *Repository) GetUserDefaultsForTunnel(userID int64) (flow int64, num int\n \treturn user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil\n }\n \n-func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig interface{}) error {\n+func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {\n \tif r == nil || r.db == nil {\n \t\treturn errors.New(\"repository not initialized\")\n \t}\n@@ -206,6 +206,7 @@ func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serve\n \t\tServerIP: serverIP,\n \t\tServerIPV4: nullStringFromInterface(serverIPV4),\n \t\tServerIPV6: nullStringFromInterface(serverIPV6),\n+\t\tExtraIPs: nullStringFromInterface(extraIPs),\n \t\tPort: stringFromInterface(port),\n \t\tInterfaceName: nullStringFromInterface(interfaceName),\n \t\tVersion: nullStringFromInterface(version),\n@@ -238,7 +239,7 @@ func (r *Repository) GetNodeStatusFields(nodeID int64) (status, httpFlag, tlsFla\n \treturn node.Status, node.HTTP, node.TLS, node.Socks, nil\n }\n \n-func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {\n+func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {\n \tif r == nil || r.db == nil {\n \t\treturn errors.New(\"repository not initialized\")\n \t}\n@@ -249,6 +250,7 @@ func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, ser\n \t\t\t\"server_ip\": serverIP,\n \t\t\t\"server_ip_v4\": nullStringFromInterface(serverIPV4),\n \t\t\t\"server_ip_v6\": nullStringFromInterface(serverIPV6),\n+\t\t\t\"extra_ips\": nullStringFromInterface(extraIPs),\n \t\t\t\"port\": stringFromInterface(port),\n \t\t\t\"interface_name\": nullStringFromInterface(interfaceName),\n \t\t\t\"http\": httpFlag,\n@@ -395,7 +397,7 @@ func (r *Repository) DeleteChainTunnelsByTunnelTx(tx *gorm.DB, tunnelID int64) e\n \treturn tx.Where(\"tunnel_id = ?\", tunnelID).Delete(&model.ChainTunnel{}).Error\n }\n \n-func (r *Repository) CreateChainTunnelTx(tx *gorm.DB, tunnelID int64, chainType string, nodeID int64, port sql.NullInt64, strategy string, inx int, protocol string) error {\n+func (r *Repository) CreateChainTunnelTx(tx *gorm.DB, tunnelID int64, chainType string, nodeID int64, port sql.NullInt64, strategy string, inx int, protocol string, connectIp string) error {\n \tif tx == nil {\n \t\treturn errors.New(\"database unavailable\")\n \t}\n@@ -407,6 +409,7 @@ func (r *Repository) CreateChainTunnelTx(tx *gorm.DB, tunnelID int64, chainType\n \t\tStrategy: nullStringFromInterface(strategy),\n \t\tInx: nullInt64FromInterface(inx),\n \t\tProtocol: nullStringFromInterface(protocol),\n+\t\tConnectIP: sql.NullString{String: connectIp, Valid: connectIp != \"\"},\n \t}\n \treturn tx.Create(&ct).Error\n }\n@@ -692,6 +695,7 @@ func (r *Repository) DeleteForwardCascade(forwardID int64) error {\n func (r *Repository) ReplaceForwardPorts(forwardID int64, entries []struct {\n \tNodeID int64\n \tPort int\n+\tInIP string\n }) error {\n \tif r == nil || r.db == nil {\n \t\treturn errors.New(\"repository not initialized\")\n@@ -705,7 +709,12 @@ func (r *Repository) ReplaceForwardPorts(forwardID int64, entries []struct {\n \t\t}\n \t\trows := make([]model.ForwardPort, 0, len(entries))\n \t\tfor _, e := range entries {\n-\t\t\trows = append(rows, model.ForwardPort{ForwardID: forwardID, NodeID: e.NodeID, Port: e.Port})\n+\t\t\trows = append(rows, model.ForwardPort{\n+\t\t\t\tForwardID: forwardID,\n+\t\t\t\tNodeID: e.NodeID,\n+\t\t\t\tPort: e.Port,\n+\t\t\t\tInIP: sql.NullString{String: e.InIP, Valid: e.InIP != \"\"},\n+\t\t\t})\n \t\t}\n \t\treturn tx.Create(&rows).Error\n \t})\n@@ -1168,7 +1177,7 @@ func (r *Repository) EnsureUserTunnelGrant(userID, tunnelID int64) (int64, bool,\n \treturn ut.ID, true, nil\n }\n \n-func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int, speedID interface{}) (int64, error) {\n+func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int, inIp string, speedID interface{}) (int64, error) {\n \tif r == nil || r.db == nil {\n \t\treturn 0, errors.New(\"repository not initialized\")\n \t}\n@@ -1198,6 +1207,7 @@ func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnel\n \t\t\t\tForwardID: forwardID,\n \t\t\t\tNodeID: nodeID,\n \t\t\t\tPort: port,\n+\t\t\t\tInIP: sql.NullString{String: inIp, Valid: inIp != \"\"},\n \t\t\t}\n \t\t\tif err := tx.Create(&fp).Error; err != nil {\n \t\t\t\treturn err\ndiff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx\nindex b9c94fd..c2816ef 100644\n--- a/vite-frontend/src/pages/forward.tsx\n+++ b/vite-frontend/src/pages/forward.tsx\n@@ -127,6 +127,7 @@ interface ForwardForm {\n name: string;\n tunnelId: number | null;\n inPort: number | null;\n+ inIp: string;\n remoteAddr: string;\n interfaceName?: string;\n strategy: string;\n@@ -546,6 +547,7 @@ export default function ForwardPage() {\n name: \"\",\n tunnelId: null,\n inPort: null,\n+ inIp: \"\",\n remoteAddr: \"\",\n interfaceName: \"\",\n strategy: \"fifo\",\n@@ -1164,6 +1166,7 @@ export default function ForwardPage() {\n name: \"\",\n tunnelId: null,\n inPort: null,\n+ inIp: \"\",\n remoteAddr: \"\",\n interfaceName: \"\",\n strategy: \"fifo\",\n@@ -1182,6 +1185,7 @@ export default function ForwardPage() {\n name: forward.name,\n tunnelId: forward.tunnelId,\n inPort: forward.inPort,\n+ inIp: forward.inIp || \"\",\n remoteAddr: forward.remoteAddr.split(\",\").join(\"\\n\"),\n interfaceName: forward.interfaceName || \"\",\n strategy: forward.strategy || \"fifo\",\n@@ -1263,6 +1267,7 @@ export default function ForwardPage() {\n name: form.name,\n tunnelId: form.tunnelId,\n inPort: form.inPort,\n+ inIp: form.inIp || null,\n remoteAddr: processedRemoteAddr,\n strategy: addressCount > 1 ? form.strategy : \"fifo\",\n speedId: normalizeSpeedId(form.speedId),\n@@ -1270,11 +1275,11 @@ export default function ForwardPage() {\n \n res = await updateForward(updateData);\n } else {\n- // 创建时不需要id和userId(后端会自动设置)\n const createData = {\n name: form.name,\n tunnelId: form.tunnelId,\n inPort: form.inPort,\n+ inIp: form.inIp || null,\n remoteAddr: processedRemoteAddr,\n strategy: addressCount > 1 ? form.strategy : \"fifo\",\n speedId: normalizeSpeedId(form.speedId),\n@@ -4023,6 +4028,17 @@ export default function ForwardPage() {\n }}\n />\n \n+ \n+ setForm((prev) => ({ ...prev, inIp: e.target.value }))\n+ }\n+ />\n+\n \n \n+ \n+ setForm((prev) => ({ ...prev, extraIPs: e.target.value }))\n+ }\n+ />\n+\n