mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-12 20:26:37 +08:00
11244 lines
1.2 MiB
Plaintext
11244 lines
1.2 MiB
Plaintext
{
|
||
"info": {
|
||
"id": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"slug": "nimble-mountain",
|
||
"projectID": "0d4685396cc353e0f47ffbe712b4e8d0545f5bad",
|
||
"directory": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"title": "System Reminder: IP binding for 211任务入口/出口监听",
|
||
"version": "1.2.15",
|
||
"summary": {
|
||
"additions": 2288,
|
||
"deletions": 4,
|
||
"files": 7
|
||
},
|
||
"time": {
|
||
"created": 1772506001278,
|
||
"updated": 1772506367678
|
||
}
|
||
},
|
||
"messages": [
|
||
{
|
||
"info": {
|
||
"role": "user",
|
||
"time": {
|
||
"created": 1772506001501
|
||
},
|
||
"summary": {
|
||
"diffs": []
|
||
},
|
||
"agent": "plan",
|
||
"model": {
|
||
"providerID": "api",
|
||
"modelID": "gpt-5.3-codex"
|
||
},
|
||
"variant": "xhigh",
|
||
"id": "msg_cb1972fd9001q5a8C3Bm5amrRC",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "text",
|
||
"text": "211任务中,如果选择了指定的入口IP或者出口IP,在节点上应该只监听这个IP地址,而不再是[::],请检查",
|
||
"id": "prt_cb1972fd9002WcO5ub0NLy249H",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb1972fd9001q5a8C3Bm5amrRC"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506001503,
|
||
"completed": 1772506007740
|
||
},
|
||
"parentID": "msg_cb1972fd9001q5a8C3Bm5amrRC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 14342,
|
||
"input": 14020,
|
||
"output": 322,
|
||
"reasoning": 112,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb197305f002fn0hG1EBAzuk2i",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"id": "prt_cb1973efc001cObitnc64T9P6T",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197305f002fn0hG1EBAzuk2i"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Searching listener binding logic**",
|
||
"time": {
|
||
"start": 1772506005244,
|
||
"end": 1772506007653
|
||
},
|
||
"id": "prt_cb1973efc002of11WZ7XUh7iIz",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197305f002fn0hG1EBAzuk2i"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_agQZJDcNrj7j9fwxvyLhC4wC",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "\\[::\\]|::|0\\.0\\.0\\.0|listen|bind|entry|出口|入口|ingress|egress",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend",
|
||
"include": "*.go"
|
||
},
|
||
"output": "Found 413 matches (showing first 100)\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go:\n Line 637: \t\t\ttcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',\n Line 638: \t\t\tudp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]'\n Line 663: \t\tINSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr)\n Line 665: \t`, \"legacy-node\", \"legacy-secret\", \"10.10.0.1\", \"10000-10010\", \"eth0\", \"v-old\", 1, 1, 1, now, now, 1, \"[::]\", \"[::]\"); err != nil {\n Line 770: \t\t\"tcp_listen_addr\",\n Line 771: \t\t\"udp_listen_addr\",\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go:\n Line 229: \t\t{\"federation_tunnel_binding\", \"federation_tunnel_binding_resource_key_key\", \"uni_federation_tunnel_binding_resource_key\"},\n Line 1172: \t\t\"binding_id\": item.BindingID, \"role\": item.Role,\n Line 1220: func (r *Repository) GetPeerShareRuntimeByBindingID(shareID int64, bindingID string) (*model.PeerShareRuntime, error) {\n Line 1225: \terr := r.db.Where(\"share_id = ? AND binding_id = ?\", shareID, bindingID).First(&item).Error\n Line 1458: \t\treturn errors.New(\"binding item is nil\")\n Line 1465: \t\t\t\"remote_url\", \"resource_key\", \"remote_binding_id\",\n Line 2051: \t\t\t\t\"http\", \"tls\", \"socks\", \"updated_time\", \"status\", \"tcp_listen_addr\", \"udp_listen_addr\",\n Line 2596: \t\treturn fmt.Errorf(\"clear speed_limit tunnel binding: %w\", err)\n Line 2662: \t\t\t\"ALTER TABLE %s.%s ALTER COLUMN id SET DEFAULT nextval(%s::regclass)\",\n Line 2700: \treturn db.Exec(`SELECT setval(?::regclass, ?, ?)`, seqRef, setVal, isCalled).Error\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_mutations.go:\n Line 259: \t\t\t\"tcp_listen_addr\": tcpAddr,\n Line 260: \t\t\t\"udp_listen_addr\": udpAddr,\n Line 1081: func (r *Repository) ReplaceFederationTunnelBindingsTx(tx *gorm.DB, tunnelID int64, bindings []FederationTunnelBinding) error {\n Line 1088: \tif len(bindings) == 0 {\n Line 1092: \trows := make([]model.FederationTunnelBinding, 0, len(bindings))\n Line 1094: \tfor _, b := range bindings {\n Line 1180: 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 Line 1205: \t\tfor _, nodeID := range entryNodeIDs {\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go:\n Line 77: \tTCPListenAddr string `gorm:\"column:tcp_listen_addr;type:varchar(100);not null;default:'[::]'\"`\n Line 78: \tUDPListenAddr string `gorm:\"column:udp_listen_addr;type:varchar(100);not null;default:'[::]'\"`\n Line 267: \tBindingID string `gorm:\"column:binding_id;type:text;not null;default:'';index:idx_peer_share_runtime_binding_id\"`\n Line 285: \tTunnelID int64 `gorm:\"column:tunnel_id;not null;uniqueIndex:idx_federation_tunnel_binding_unique;index:idx_federation_tunnel_binding_tunnel\"`\n Line 286: \tNodeID int64 `gorm:\"column:node_id;not null;uniqueIndex:idx_federation_tunnel_binding_unique\"`\n Line 287: \tChainType int `gorm:\"column:chain_type;not null;uniqueIndex:idx_federation_tunnel_binding_unique\"`\n Line 288: \tHopInx int `gorm:\"column:hop_inx;not null;default:0;uniqueIndex:idx_federation_tunnel_binding_unique\"`\n Line 291: \tRemoteBindingID string `gorm:\"column:remote_binding_id;type:text;not null\"`\n Line 293: \tStatus int `gorm:\"not null;default:1;index:idx_federation_tunnel_binding_tunnel\"`\n Line 298: func (FederationTunnelBinding) TableName() string { return \"federation_tunnel_binding\" }\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_control.go:\n Line 200: \t\trec.TCPListenAddr = \"[::]\"\n Line 203: \t\trec.UDPListenAddr = \"[::]\"\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go:\n Line 267: \t\tdefaultString(asString(req[\"tcpListenAddr\"]), \"[::]\"),\n Line 268: \t\tdefaultString(asString(req[\"udpListenAddr\"]), \"[::]\"),\n Line 330: \t\tdefaultString(asString(req[\"tcpListenAddr\"]), \"[::]\"),\n Line 331: \t\tdefaultString(asString(req[\"udpListenAddr\"]), \"[::]\"),\n Line 1164: \tentryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)\n Line 1165: \tfor _, nodeID := range entryNodes {\n Line 1182: \tforwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req[\"strategy\"]), \"fifo\"), now, inx, entryNodes, port, inIp, nullableInt(speedID))\n Line 1999: \t\treturn nil, errors.New(\"入口不能为空\")\n Line 2005: \t\t\treturn nil, errors.New(\"出口不能为空\")\n Line 2039: \t\t\treturn nil, errors.New(\"出口不能为空\")\n Line 2225: \tbindings := make([]repo.FederationTunnelBinding, 0)\n Line 2228: \t\treturn bindings, releaseRefs, nil\n Line 2282: \t\tbindings = append(bindings, repo.FederationTunnelBinding{\n Line 2382: \t\t\tbindings = append(bindings, repo.FederationTunnelBinding{\n Line 2405: \treturn bindings, releaseRefs, nil\n Line 2432: \tbindings, err := h.repo.ListActiveFederationTunnelBindingsByTunnel(tunnelID)\n Line 2433: \tif err != nil || len(bindings) == 0 {\n Line 2439: \tfor _, b := range bindings {\n Line 2485: \t\t\treturn createdChains, createdServices, fmt.Errorf(\"入口节点 %s 下发转发链失败: %w\", nodeDisplayName(state.Nodes[inNode.NodeID]), err)\n Line 2522: \t\t\treturn createdChains, createdServices, fmt.Errorf(\"出口节点 %s 下发服务失败: %w\", nodeDisplayName(state.Nodes[outNode.NodeID]), err)\n Line 2648: \t\t\"listener\": map[string]interface{}{\n Line 2917: \tentryNodes, err := h.tunnelEntryNodeIDs(tunnelID)\n Line 2918: \tif err != nil || len(entryNodes) == 0 {\n Line 2925: \tfor _, nodeID := range entryNodes {\n Line 3018: \tentryNodes, err := h.tunnelEntryNodeIDs(tunnelID)\n Line 3026: \t}, len(entryNodes))\n Line 3027: \tfor i, nid := range entryNodes {\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go:\n Line 12: \tfrom := dualStackNode(\"from\", \"10.0.0.1\", \"2001:db8::1\")\n Line 13: \tto := dualStackNode(\"to\", \"10.0.0.2\", \"2001:db8::2\")\n Line 47: \tn := &nodeRecord{ServerIPv6: \"2001:db8::1\"}\n Line 54: \tn := &nodeRecord{ServerIPv6: \"2001:db8::1\"}\n Line 68: \tn := &nodeRecord{ServerIPv4: \"10.0.0.1\", ServerIPv6: \"2001:db8::1\"}\n Line 75: \tn := &nodeRecord{ServerIPv4: \"10.0.0.1\", ServerIPv6: \"2001:db8::1\"}\n Line 92: \tn := &nodeRecord{ServerIP: \"2001:db8::1\"}\n Line 112: \tn := &nodeRecord{ServerIP: \"[::1]\"}\n Line 154: \tn := &nodeRecord{ServerIPv6: \"2001:db8::1\", ServerIP: \"::1\"}\n Line 156: \tif got != \"2001:db8::1\" {\n Line 157: \t\tt.Fatalf(\"expected explicit v6 2001:db8::1, got %q\", got)\n Line 162: \tn := &nodeRecord{ServerIP: \"::1\"}\n Line 164: \tif got != \"::1\" {\n Line 165: \t\tt.Fatalf(\"expected legacy ::1, got %q\", got)\n Line 200: \t_, err = selectTunnelDialHost(dualStackNode(\"a\", \"1.1.1.1\", \"::1\"), nil, \"\", \"\")\n Line 204: \t_, err = selectTunnelDialHost(nil, dualStackNode(\"b\", \"1.1.1.1\", \"::1\"), \"\", \"\")\n Line 211: \tfrom := dualStackNode(\"from\", \"10.0.0.1\", \"2001:db8::1\")\n Line 212: \tto := dualStackNode(\"to\", \"10.0.0.2\", \"2001:db8::2\")\n Line 224: \tfrom := dualStackNode(\"from\", \"10.0.0.1\", \"2001:db8::1\")\n Line 225: \tto := dualStackNode(\"to\", \"10.0.0.2\", \"2001:db8::2\")\n Line 236: \tfrom := dualStackNode(\"from\", \"10.0.0.1\", \"2001:db8::1\")\n Line 237: \tto := dualStackNode(\"to\", \"10.0.0.2\", \"2001:db8::2\")\n Line 242: \tif host != \"2001:db8::2\" {\n Line 261: \tfrom := v6OnlyNode(\"from\", \"2001:db8::1\")\n Line 262: \tto := v6OnlyNode(\"to\", \"2001:db8::2\")\n Line 268: \tif host != \"2001:db8::2\" {\n Line 275: \tto := v6OnlyNode(\"to\", \"2001:db8::2\")\n Line 283: \tfrom := v6OnlyNode(\"from\", \"2001:db8::1\")\n Line 292: \tfrom := dualStackNode(\"from\", \"10.0.0.1\", \"2001:db8::1\")\n Line 293: \tto := dualStackNode(\"to\", \"10.0.0.2\", \"2001:db8::2\")\n Line 299: \tif host != \"2001:db8::2\" {\n Line 305: \tfrom := dualStackNode(\"from\", \"10.0.0.1\", \"2001:db8::1\")\n Line 318: \tfrom := dualStackNode(\"from\", \"10.0.0.1\", \"2001:db8::1\")\n Line 319: \tto := v6OnlyNode(\"to\", \"2001:db8::2\")\n Line 325: \tif host != \"2001:db8::2\" {\n Line 332: \tto := dualStackNode(\"to\", \"10.0.0.2\", \"2001:db8::2\")\n Line 344: \tfrom := v6OnlyNode(\"from\", \"2001:db8::1\")\n\n(Results truncated: showing 100 of 413 matches (313 hidden). Consider using a more specific path or pattern.)",
|
||
"title": "\\[::\\]|::|0\\.0\\.0\\.0|listen|bind|entry|出口|入口|ingress|egress",
|
||
"metadata": {
|
||
"matches": 413,
|
||
"truncated": true
|
||
},
|
||
"time": {
|
||
"start": 1772506007634,
|
||
"end": 1772506007658
|
||
}
|
||
},
|
||
"id": "prt_cb1974843001cSjAzqbSkKhbNU",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197305f002fn0hG1EBAzuk2i"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_qsyfMSPU1RCLWbLbjqiKjsws",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "入口IP|出口IP|entry IP|egress IP|ingress IP|指定.*IP|211",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"include": "*.{md,go,ts,tsx,json,yml,yaml}"
|
||
},
|
||
"output": "Found 12 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/.entire/metadata/ses_34ec18cadffeoVVSjfPXGJaxBu/context.md:\n Line 10: 211任务全量再逐一确认一下是否全部完成,并且功能完成\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/tunnel.tsx:\n Line 113: inIp: string; // 入口IP\n Line 1431: label=\"入口IP\"\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/forward.tsx:\n Line 4147: <SelectItem key=\"__default__\">默认入口IP</SelectItem>\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/package-lock.json:\n Line 7329: \"integrity\": \"sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==\",\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/211-custom-ip-selection.md:\n Line 1: # Issue #211: 转发自定义监听IP / 隧道指定连接IP\n Line 5: 2. **转发**: 创建/编辑时可指定入口监听IP\n Line 6: 3. **隧道**: 配置出口节点时可指定连接IP\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/node.tsx:\n Line 1631: description=\"用于多IP服务器指定使用那个IP请求远程地址,不懂的默认为空就行\"\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/components/icons.tsx:\n Line 253: d=\"M19.5 8.5C19.8978 8.10218 20.2374 7.63877 20.5 7.12132C20.7626 6.60387 20.8478 6.03677 20.8478 5.464C20.8478 4.89123 20.7626 4.32413 20.5 3.80668C20.2374 3.28923 19.8978 2.82582 19.5 2.428C19.1022 2.03018 18.6388 1.69063 18.1213 1.42801C17.6039 1.16539 17.0368 1.08023 16.464 1.08023C15.8912 1.08023 15.3241 1.16539 14.8067 1.42801C14.2892 1.69063 13.8258 2.03018 13.428 2.428L12 3.856L10.572 2.428C9.78889 1.64486 8.73968 1.21408 7.64800 1.21408C6.55632 1.21408 5.50711 1.64486 4.72400 2.428C3.94086 3.21111 3.51008 4.26032 3.51008 5.352C3.51008 6.44368 3.94086 7.49289 4.72400 8.276L6.15200 9.704L12 15.552L17.848 9.704L19.276 8.276C19.5 8.052 19.5 8.276 19.5 8.5Z\"\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/skills/flvx-api/references/tunnels.md:\n Line 84: | inIp | string | No | Custom entry IP |\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/limiter_sync_failure_contract_test.go:\n Line 287: \t`, \"addr-in-use-node\", \"addr-in-use-secret\", \"10.20.0.11\", \"10.20.0.11\", \"\", \"32100-32110\", \"\", \"v1\", 1, 1, 1, now, now, 1, \"[::]\", \"[::]\", 0).Error; err != nil {",
|
||
"title": "入口IP|出口IP|entry IP|egress IP|ingress IP|指定.*IP|211",
|
||
"metadata": {
|
||
"matches": 12,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772506007650,
|
||
"end": 1772506007686
|
||
}
|
||
},
|
||
"id": "prt_cb197485d001yeCPlIX4J6EP6o",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197305f002fn0hG1EBAzuk2i"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 14342,
|
||
"input": 14020,
|
||
"output": 322,
|
||
"reasoning": 112,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb19748870010sK7guahGnYO5Y",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197305f002fn0hG1EBAzuk2i"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506007740,
|
||
"completed": 1772506014239
|
||
},
|
||
"parentID": "msg_cb1972fd9001q5a8C3Bm5amrRC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 18818,
|
||
"input": 18543,
|
||
"output": 275,
|
||
"reasoning": 43,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb19748bc002uAeoZBxxFQdtMo",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"id": "prt_cb1975930001JH4LdraKufrAA2",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19748bc002uAeoZBxxFQdtMo"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Planning deeper code inspection**",
|
||
"time": {
|
||
"start": 1772506011953,
|
||
"end": 1772506014143
|
||
},
|
||
"id": "prt_cb19759310015LQ8Y5J8pGt6in",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19748bc002uAeoZBxxFQdtMo"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_F3PI8zOdSqsNSy7o1w0OpmX6",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/211-custom-ip-selection.md",
|
||
"offset": 1,
|
||
"limit": 220
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/211-custom-ip-selection.md</path>\n<type>file</type>\n<content>1: # Issue #211: 转发自定义监听IP / 隧道指定连接IP\n2: \n3: ## 需求总结\n4: 1. **节点**: 高级配置增加\"额外IP地址\"字段(逗号分隔)\n5: 2. **转发**: 创建/编辑时可指定入口监听IP\n6: 3. **隧道**: 配置出口节点时可指定连接IP\n7: \n8: ---\n9: \n10: ## 任务清单\n11: \n12: ### 后端\n13: - [x] 1. 数据模型扩展 - Node/ForwardPort/ChainTunnel 增加字段\n14: - [x] 2. Repository - CreateNode/UpdateNode 处理 extraIPs\n15: - [x] 3. Repository - resolveForwardIngress 使用 forward_port.in_ip\n16: - [x] 4. Repository - GetNodeAllIPs 辅助函数(返回节点所有可用IP)\n17: - [x] 5. Handler - 转发创建/更新处理 inIp 参数\n18: - [x] 6. Handler - 隧道出口节点处理 connectIp 参数\n19: - [x] 7. Handler - 节点API返回 extraIPs 字段\n20: \n21: ### 前端\n22: - [x] 8. 节点编辑页 - 高级配置增加\"额外IP\"输入\n23: - [x] 9. 转发编辑弹窗 - 增加\"监听IP\"下拉选择\n24: - [x] 10. 隧道配置页 - 出口节点增加\"连接IP\"输入\n25: \n26: ---\n27: \n28: ## 完成进度\n29: - 开始时间: 2026-03-02\n30: - 完成时间: 2026-03-02\n31: - 完成任务: 10/10\n32: - 后端完成: ✅\n33: - 前端完成: ✅\n\n(End of file - total 33 lines)\n</content>",
|
||
"title": "211-custom-ip-selection.md",
|
||
"metadata": {
|
||
"preview": "# Issue #211: 转发自定义监听IP / 隧道指定连接IP\n\n## 需求总结\n1. **节点**: 高级配置增加\"额外IP地址\"字段(逗号分隔)\n2. **转发**: 创建/编辑时可指定入口监听IP\n3. **隧道**: 配置出口节点时可指定连接IP\n\n---\n\n## 任务清单\n\n### 后端\n- [x] 1. 数据模型扩展 - Node/ForwardPort/ChainTunnel 增加字段\n- [x] 2. Repository - CreateNode/UpdateNode 处理 extraIPs\n- [x] 3. Repository - resolveForwardIngress 使用 forward_port.in_ip\n- [x] 4. Repository - GetNodeAllIPs 辅助函数(返回节点所有可用IP)\n- [x] 5. Handler - 转发创建/更新处理 inIp 参数\n- [x] 6. Handler - 隧道出口节点处理 connectIp 参数\n- [x] 7. Handler - 节点API返回 extraIPs 字段\n",
|
||
"truncated": false,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772506014116,
|
||
"end": 1772506014122
|
||
}
|
||
},
|
||
"id": "prt_cb19761a1001DnYgfto1VFNrQ9",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19748bc002uAeoZBxxFQdtMo"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_O90tATaaAUhpwNi8WZLPwN4D",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go",
|
||
"offset": 2580,
|
||
"limit": 220
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go</path>\n<type>file</type>\n<content>2580: \t\treturn nil, errors.New(\"转发链目标不能为空\")\n2581: \t}\n2582: \tnodeItems := make([]map[string]interface{}, 0, len(targets))\n2583: \tfor idx, target := range targets {\n2584: \t\ttargetNode := nodes[target.NodeID]\n2585: \t\tif targetNode == nil {\n2586: \t\t\treturn nil, errors.New(\"节点不存在\")\n2587: \t\t}\n2588: \t\thost, err := selectTunnelDialHost(fromNode, targetNode, ipPreference, target.ConnectIP)\n2589: \t\tif err != nil {\n2590: \t\t\treturn nil, err\n2591: \t\t}\n2592: \t\tport := target.Port\n2593: \t\tif port <= 0 {\n2594: \t\t\treturn nil, errors.New(\"节点端口不能为空\")\n2595: \t\t}\n2596: \t\tprotocol := defaultString(target.Protocol, \"tls\")\n2597: \t\tconnector := map[string]interface{}{\n2598: \t\t\t\"type\": \"relay\",\n2599: \t\t}\n2600: \t\tif isTLSTunnelProtocol(protocol) {\n2601: \t\t\tconnector[\"metadata\"] = map[string]interface{}{\"nodelay\": true}\n2602: \t\t}\n2603: \t\tnodeItems = append(nodeItems, map[string]interface{}{\n2604: \t\t\t\"name\": fmt.Sprintf(\"node_%d\", idx+1),\n2605: \t\t\t\"addr\": processServerAddress(fmt.Sprintf(\"%s:%d\", host, port)),\n2606: \t\t\t\"connector\": connector,\n2607: \t\t\t\"dialer\": map[string]interface{}{\n2608: \t\t\t\t\"type\": protocol,\n2609: \t\t\t},\n2610: \t\t})\n2611: \t}\n2612: \n2613: \tstrategy := defaultString(strings.TrimSpace(targets[0].Strategy), \"round\")\n2614: \thop := map[string]interface{}{\n2615: \t\t\"name\": fmt.Sprintf(\"hop_%d\", tunnelID),\n2616: \t\t\"selector\": map[string]interface{}{\n2617: \t\t\t\"strategy\": strategy,\n2618: \t\t\t\"maxFails\": 1,\n2619: \t\t\t\"failTimeout\": int64(600000000000),\n2620: \t\t},\n2621: \t\t\"nodes\": nodeItems,\n2622: \t}\n2623: \tif strings.TrimSpace(fromNode.InterfaceName) != \"\" {\n2624: \t\thop[\"interface\"] = fromNode.InterfaceName\n2625: \t}\n2626: \n2627: \treturn map[string]interface{}{\n2628: \t\t\"name\": fmt.Sprintf(\"chains_%d\", tunnelID),\n2629: \t\t\"hops\": []map[string]interface{}{hop},\n2630: \t}, nil\n2631: }\n2632: \n2633: func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode, node *nodeRecord) []map[string]interface{} {\n2634: \tif node == nil {\n2635: \t\treturn nil\n2636: \t}\n2637: \tprotocol := defaultString(chainNode.Protocol, \"tls\")\n2638: \thandlerCfg := map[string]interface{}{\n2639: \t\t\"type\": \"relay\",\n2640: \t}\n2641: \tif isTLSTunnelProtocol(protocol) {\n2642: \t\thandlerCfg[\"metadata\"] = map[string]interface{}{\"nodelay\": true}\n2643: \t}\n2644: \tservice := map[string]interface{}{\n2645: \t\t\"name\": fmt.Sprintf(\"%d_tls\", tunnelID),\n2646: \t\t\"addr\": fmt.Sprintf(\"%s:%d\", node.TCPListenAddr, chainNode.Port),\n2647: \t\t\"handler\": handlerCfg,\n2648: \t\t\"listener\": map[string]interface{}{\n2649: \t\t\t\"type\": protocol,\n2650: \t\t},\n2651: \t}\n2652: \tif chainNode.ChainType == 2 {\n2653: \t\tservice[\"handler\"].(map[string]interface{})[\"chain\"] = fmt.Sprintf(\"chains_%d\", tunnelID)\n2654: \t}\n2655: \tif chainNode.ChainType == 3 && strings.TrimSpace(node.InterfaceName) != \"\" {\n2656: \t\tservice[\"metadata\"] = map[string]interface{}{\"interface\": node.InterfaceName}\n2657: \t}\n2658: \treturn []map[string]interface{}{service}\n2659: }\n2660: \n2661: func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, connectIp string) (string, error) {\n2662: \tif fromNode == nil || toNode == nil {\n2663: \t\treturn \"\", errors.New(\"节点不存在\")\n2664: \t}\n2665: \tif strings.TrimSpace(connectIp) != \"\" {\n2666: \t\treturn strings.TrimSpace(connectIp), nil\n2667: \t}\n2668: \tfromV4 := nodeSupportsV4(fromNode)\n2669: \tfromV6 := nodeSupportsV6(fromNode)\n2670: \ttoV4 := nodeSupportsV4(toNode)\n2671: \ttoV6 := nodeSupportsV6(toNode)\n2672: \n2673: \tswitch strings.TrimSpace(ipPreference) {\n2674: \tcase \"v6\":\n2675: \t\tif fromV6 && toV6 {\n2676: \t\t\tif host := pickNodeAddressV6(toNode); host != \"\" {\n2677: \t\t\t\treturn host, nil\n2678: \t\t\t}\n2679: \t\t}\n2680: \t\tif fromV4 && toV4 {\n2681: \t\t\tif host := pickNodeAddressV4(toNode); host != \"\" {\n2682: \t\t\t\treturn host, nil\n2683: \t\t\t}\n2684: \t\t}\n2685: \tcase \"v4\":\n2686: \t\tif fromV4 && toV4 {\n2687: \t\t\tif host := pickNodeAddressV4(toNode); host != \"\" {\n2688: \t\t\t\treturn host, nil\n2689: \t\t\t}\n2690: \t\t}\n2691: \t\tif fromV6 && toV6 {\n2692: \t\t\tif host := pickNodeAddressV6(toNode); host != \"\" {\n2693: \t\t\t\treturn host, nil\n2694: \t\t\t}\n2695: \t\t}\n2696: \tdefault:\n2697: \t\tif fromV4 && toV4 {\n2698: \t\t\tif host := pickNodeAddressV4(toNode); host != \"\" {\n2699: \t\t\t\treturn host, nil\n2700: \t\t\t}\n2701: \t\t}\n2702: \t\tif fromV6 && toV6 {\n2703: \t\t\tif host := pickNodeAddressV6(toNode); host != \"\" {\n2704: \t\t\t\treturn host, nil\n2705: \t\t\t}\n2706: \t\t}\n2707: \t}\n2708: \treturn \"\", fmt.Errorf(\"节点链路不兼容:%s(v4=%t,v6=%t) -> %s(v4=%t,v6=%t)\", nodeDisplayName(fromNode), fromV4, fromV6, nodeDisplayName(toNode), toV4, toV6)\n2709: }\n2710: \n2711: func nodeDisplayName(node *nodeRecord) string {\n2712: \tif node == nil {\n2713: \t\treturn \"node\"\n2714: \t}\n2715: \tif strings.TrimSpace(node.Name) != \"\" {\n2716: \t\treturn strings.TrimSpace(node.Name)\n2717: \t}\n2718: \treturn fmt.Sprintf(\"node_%d\", node.ID)\n2719: }\n2720: \n2721: func isTLSTunnelProtocol(protocol string) bool {\n2722: \treturn strings.EqualFold(strings.TrimSpace(defaultString(protocol, \"tls\")), \"tls\")\n2723: }\n2724: \n2725: func nodeSupportsV4(node *nodeRecord) bool {\n2726: \tif node == nil {\n2727: \t\treturn false\n2728: \t}\n2729: \tif strings.TrimSpace(node.ServerIPv4) != \"\" {\n2730: \t\treturn true\n2731: \t}\n2732: \tif strings.TrimSpace(node.ServerIPv6) != \"\" {\n2733: \t\treturn false\n2734: \t}\n2735: \tlegacy := strings.Trim(strings.TrimSpace(node.ServerIP), \"[]\")\n2736: \tif legacy == \"\" {\n2737: \t\treturn false\n2738: \t}\n2739: \tif ip := net.ParseIP(legacy); ip != nil {\n2740: \t\treturn ip.To4() != nil\n2741: \t}\n2742: \treturn true\n2743: }\n2744: \n2745: func nodeSupportsV6(node *nodeRecord) bool {\n2746: \tif node == nil {\n2747: \t\treturn false\n2748: \t}\n2749: \tif strings.TrimSpace(node.ServerIPv6) != \"\" {\n2750: \t\treturn true\n2751: \t}\n2752: \tif strings.TrimSpace(node.ServerIPv4) != \"\" {\n2753: \t\treturn false\n2754: \t}\n2755: \tlegacy := strings.Trim(strings.TrimSpace(node.ServerIP), \"[]\")\n2756: \tif legacy == \"\" {\n2757: \t\treturn false\n2758: \t}\n2759: \tif ip := net.ParseIP(legacy); ip != nil {\n2760: \t\treturn ip.To4() == nil\n2761: \t}\n2762: \treturn true\n2763: }\n2764: \n2765: func pickNodeAddressV4(node *nodeRecord) string {\n2766: \tif node == nil {\n2767: \t\treturn \"\"\n2768: \t}\n2769: \tif v := strings.TrimSpace(node.ServerIPv4); v != \"\" {\n2770: \t\treturn v\n2771: \t}\n2772: \treturn strings.TrimSpace(node.ServerIP)\n2773: }\n2774: \n2775: func pickNodeAddressV6(node *nodeRecord) string {\n2776: \tif node == nil {\n2777: \t\treturn \"\"\n2778: \t}\n2779: \tif v := strings.TrimSpace(node.ServerIPv6); v != \"\" {\n2780: \t\treturn v\n2781: \t}\n2782: \treturn strings.TrimSpace(node.ServerIP)\n2783: }\n2784: \n2785: func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[string]interface{}) error {\n2786: \tallocated := map[int64]int{}\n2787: \tinNodes := asMapSlice(req[\"inNodeId\"])\n2788: \tfor i, n := range inNodes {\n2789: \t\tnodeID := asInt64(n[\"nodeId\"], 0)\n2790: \t\tif nodeID <= 0 {\n2791: \t\t\tcontinue\n2792: \t\t}\n2793: \t\tif err := h.repo.CreateChainTunnelTx(\n2794: \t\t\ttx,\n2795: \t\t\ttunnelID,\n2796: \t\t\t\"1\",\n2797: \t\t\tnodeID,\n2798: \t\t\tsql.NullInt64{},\n2799: \t\t\tdefaultString(asString(n[\"strategy\"]), \"round\"),\n\n(Showing lines 2580-2799 of 3419. Use offset=2800 to continue.)\n</content>\n\n<system-reminder>\nInstructions from: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/AGENTS.md\n# BACKEND HTTP HANDLER KNOWLEDGE BASE\n\n**Generated:** Thu Feb 26 2026\n\n## OVERVIEW\nHTTP request handlers for FLVX Admin API. Core business logic layer.\n**Stack:** Go 1.24, net/http, GORM via Repository pattern.\n\n## STRUCTURE\n```\nhandler/\n├── handler.go # Main Handler struct, login/captcha, job scheduling\n├── control_plane.go # Node control plane API (add/delete/list)\n├── federation.go # Federation/cluster sync API\n├── flow_policy.go # Traffic policy API\n├── jobs.go # Background job management (sync, cleanup)\n├── mutations.go # CRUD for users, tunnels, forwards (~3700 LOC)\n└── upgrade.go # System upgrade API\n```\n\n## WHERE TO LOOK\n| Task | Location | Notes |\n|------|----------|-------|\n| **User/Tunnel CRUD** | `mutations.go` | Largest file; all create/update/delete ops |\n| **Login/Captcha** | `handler.go` | Login flow, captcha verification |\n| **Federation Sync** | `federation.go` | Panel-to-panel sync |\n| **Traffic Policies** | `flow_policy.go` | Flow limiting, quota management |\n| **Background Jobs** | `jobs.go` | Scheduled sync/cleanup tasks |\n| **Node Control** | `control_plane.go` | Node add/delete/list operations |\n\n## CONVENTIONS\n- Inherits from parent: GORM via Repository pattern, JWT in Authorization header.\n- Large files expected (`mutations.go` ~3700 LOC - central mutation hub).\n- Uses `repo.Repository` for DB access via `h.repo.XXX()` methods.\n- Handlers never call `repo.DB()` directly — all queries go through Repository methods.\n- Domain-driven file split: one file per functional area (federation, jobs, etc.).\n\n## ANTI-PATTERNS\n- Do NOT let handlers call `repo.DB()` directly — add a Repository method instead.\n- Do NOT change handler signatures without updating router.go.\n\n## COMMANDS\n```bash\ncd go-backend\ngo test ./internal/http/handler/...\n```\n\n</system-reminder>",
|
||
"title": "go-backend/internal/http/handler/mutations.go",
|
||
"metadata": {
|
||
"preview": "\t\treturn nil, errors.New(\"转发链目标不能为空\")\n\t}\n\tnodeItems := make([]map[string]interface{}, 0, len(targets))\n\tfor idx, target := range targets {\n\t\ttargetNode := nodes[target.NodeID]\n\t\tif targetNode == nil {\n\t\t\treturn nil, errors.New(\"节点不存在\")\n\t\t}\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\t\tport := target.Port\n\t\tif port <= 0 {\n\t\t\treturn nil, errors.New(\"节点端口不能为空\")\n\t\t}\n\t\tprotocol := defaultString(target.Protocol, \"tls\")\n\t\tconnector := map[string]interface{}{\n\t\t\t\"type\": \"relay\",\n\t\t}",
|
||
"truncated": true,
|
||
"loaded": [
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/AGENTS.md"
|
||
]
|
||
},
|
||
"time": {
|
||
"start": 1772506014138,
|
||
"end": 1772506014174
|
||
}
|
||
},
|
||
"id": "prt_cb19761b8001UH7TWD6nDnxAsD",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19748bc002uAeoZBxxFQdtMo"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_dfFEnWVWHVwIOSsznnGi3dgp",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_mutations.go",
|
||
"offset": 1160,
|
||
"limit": 200
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_mutations.go</path>\n<type>file</type>\n<content>1160: \tnum := user.Num\n1161: \texpTime := user.ExpTime\n1162: \tflowReset := user.FlowResetTime\n1163: \tut := model.UserTunnel{\n1164: \t\tUserID: userID,\n1165: \t\tTunnelID: tunnelID,\n1166: \t\tNum: num,\n1167: \t\tFlow: flow,\n1168: \t\tInFlow: 0,\n1169: \t\tOutFlow: 0,\n1170: \t\tFlowResetTime: flowReset,\n1171: \t\tExpTime: expTime,\n1172: \t\tStatus: 1,\n1173: \t}\n1174: \tif err := r.db.Create(&ut).Error; err != nil {\n1175: \t\treturn 0, false, err\n1176: \t}\n1177: \treturn ut.ID, true, nil\n1178: }\n1179: \n1180: 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) {\n1181: \tif r == nil || r.db == nil {\n1182: \t\treturn 0, errors.New(\"repository not initialized\")\n1183: \t}\n1184: \tvar forwardID int64\n1185: \terr := r.db.Transaction(func(tx *gorm.DB) error {\n1186: \t\tfwd := model.Forward{\n1187: \t\t\tUserID: userID,\n1188: \t\t\tUserName: userName,\n1189: \t\t\tName: name,\n1190: \t\t\tTunnelID: tunnelID,\n1191: \t\t\tRemoteAddr: remoteAddr,\n1192: \t\t\tStrategy: strategy,\n1193: \t\t\tInFlow: 0,\n1194: \t\t\tOutFlow: 0,\n1195: \t\t\tCreatedTime: now,\n1196: \t\t\tUpdatedTime: now,\n1197: \t\t\tStatus: 1,\n1198: \t\t\tInx: inx,\n1199: \t\t\tSpeedID: nullInt64FromInterface(speedID),\n1200: \t\t}\n1201: \t\tif err := tx.Create(&fwd).Error; err != nil {\n1202: \t\t\treturn err\n1203: \t\t}\n1204: \t\tforwardID = fwd.ID\n1205: \t\tfor _, nodeID := range entryNodeIDs {\n1206: \t\t\tfp := model.ForwardPort{\n1207: \t\t\t\tForwardID: forwardID,\n1208: \t\t\t\tNodeID: nodeID,\n1209: \t\t\t\tPort: port,\n1210: \t\t\t\tInIP: sql.NullString{String: inIp, Valid: inIp != \"\"},\n1211: \t\t\t}\n1212: \t\t\tif err := tx.Create(&fp).Error; err != nil {\n1213: \t\t\t\treturn err\n1214: \t\t\t}\n1215: \t\t}\n1216: \t\treturn nil\n1217: \t})\n1218: \treturn forwardID, err\n1219: }\n1220: \n1221: func (r *Repository) BatchUpdateForwardStatus(ids []int64, status int) (int, int) {\n1222: \tif r == nil || r.db == nil {\n1223: \t\treturn 0, len(ids)\n1224: \t}\n1225: \ts := 0\n1226: \tf := 0\n1227: \tnow := time.Now().UnixMilli()\n1228: \tfor _, id := range ids {\n1229: \t\tif err := r.db.Model(&model.Forward{}).Where(\"id = ?\", id).Updates(map[string]interface{}{\"status\": status, \"updated_time\": now}).Error; err != nil {\n1230: \t\t\tf++\n1231: \t\t} else {\n1232: \t\t\ts++\n1233: \t\t}\n1234: \t}\n1235: \treturn s, f\n1236: }\n1237: \n1238: func (r *Repository) CreateTunnelTx(tx *gorm.DB, name string, trafficRatio float64, typeVal int, flow int64, now int64, status int, inIP interface{}, inx int, ipPreference string) (int64, error) {\n1239: \tinIPVal := nullStringFromInterface(inIP)\n1240: \ttunnel := model.Tunnel{\n1241: \t\tName: name,\n1242: \t\tTrafficRatio: trafficRatio,\n1243: \t\tType: typeVal,\n1244: \t\tProtocol: \"tls\",\n1245: \t\tFlow: flow,\n1246: \t\tCreatedTime: now,\n1247: \t\tUpdatedTime: now,\n1248: \t\tStatus: status,\n1249: \t\tInIP: inIPVal,\n1250: \t\tInx: inx,\n1251: \t\tIPPreference: ipPreference,\n1252: \t}\n1253: \tif err := tx.Create(&tunnel).Error; err != nil {\n1254: \t\treturn 0, err\n1255: \t}\n1256: \treturn tunnel.ID, nil\n1257: }\n1258: \n1259: func normalizeNotFoundErr(err error) error {\n1260: \tif errors.Is(err, gorm.ErrRecordNotFound) {\n1261: \t\treturn sql.ErrNoRows\n1262: \t}\n1263: \treturn err\n1264: }\n1265: \n1266: func nullStringFromInterface(v interface{}) sql.NullString {\n1267: \tswitch t := v.(type) {\n1268: \tcase nil:\n1269: \t\treturn sql.NullString{}\n1270: \tcase sql.NullString:\n1271: \t\treturn t\n1272: \tcase *sql.NullString:\n1273: \t\tif t == nil {\n1274: \t\t\treturn sql.NullString{}\n1275: \t\t}\n1276: \t\treturn *t\n1277: \tcase string:\n1278: \t\tif t == \"\" {\n1279: \t\t\treturn sql.NullString{}\n1280: \t\t}\n1281: \t\treturn sql.NullString{String: t, Valid: true}\n1282: \tcase *string:\n1283: \t\tif t == nil || *t == \"\" {\n1284: \t\t\treturn sql.NullString{}\n1285: \t\t}\n1286: \t\treturn sql.NullString{String: *t, Valid: true}\n1287: \tdefault:\n1288: \t\treturn sql.NullString{}\n1289: \t}\n1290: }\n1291: \n1292: func nullInt64FromInterface(v interface{}) sql.NullInt64 {\n1293: \tswitch t := v.(type) {\n1294: \tcase nil:\n1295: \t\treturn sql.NullInt64{}\n1296: \tcase sql.NullInt64:\n1297: \t\treturn t\n1298: \tcase *sql.NullInt64:\n1299: \t\tif t == nil {\n1300: \t\t\treturn sql.NullInt64{}\n1301: \t\t}\n1302: \t\treturn *t\n1303: \tcase int64:\n1304: \t\treturn sql.NullInt64{Int64: t, Valid: true}\n1305: \tcase *int64:\n1306: \t\tif t == nil {\n1307: \t\t\treturn sql.NullInt64{}\n1308: \t\t}\n1309: \t\treturn sql.NullInt64{Int64: *t, Valid: true}\n1310: \tcase int:\n1311: \t\treturn sql.NullInt64{Int64: int64(t), Valid: true}\n1312: \tcase int32:\n1313: \t\treturn sql.NullInt64{Int64: int64(t), Valid: true}\n1314: \tcase int16:\n1315: \t\treturn sql.NullInt64{Int64: int64(t), Valid: true}\n1316: \tcase int8:\n1317: \t\treturn sql.NullInt64{Int64: int64(t), Valid: true}\n1318: \tcase uint64:\n1319: \t\treturn sql.NullInt64{Int64: int64(t), Valid: true}\n1320: \tcase uint:\n1321: \t\treturn sql.NullInt64{Int64: int64(t), Valid: true}\n1322: \tcase uint32:\n1323: \t\treturn sql.NullInt64{Int64: int64(t), Valid: true}\n1324: \tcase uint16:\n1325: \t\treturn sql.NullInt64{Int64: int64(t), Valid: true}\n1326: \tcase uint8:\n1327: \t\treturn sql.NullInt64{Int64: int64(t), Valid: true}\n1328: \tcase float64:\n1329: \t\treturn sql.NullInt64{Int64: int64(t), Valid: true}\n1330: \tdefault:\n1331: \t\treturn sql.NullInt64{}\n1332: \t}\n1333: }\n1334: \n1335: func stringFromInterface(v interface{}) string {\n1336: \tswitch t := v.(type) {\n1337: \tcase nil:\n1338: \t\treturn \"\"\n1339: \tcase string:\n1340: \t\treturn t\n1341: \tcase sql.NullString:\n1342: \t\tif t.Valid {\n1343: \t\t\treturn t.String\n1344: \t\t}\n1345: \t\treturn \"\"\n1346: \tcase *sql.NullString:\n1347: \t\tif t != nil && t.Valid {\n1348: \t\t\treturn t.String\n1349: \t\t}\n1350: \t\treturn \"\"\n1351: \tcase []byte:\n1352: \t\treturn string(t)\n1353: \tdefault:\n1354: \t\treturn \"\"\n1355: \t}\n1356: }\n1357: \n1358: func parsePortRangeSpec(input string) []int {\n1359: \tinput = strings.TrimSpace(input)\n\n(Showing lines 1160-1359 of 1467. Use offset=1360 to continue.)\n</content>\n\n<system-reminder>\nInstructions from: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/AGENTS.md\n# GO BACKEND KNOWLEDGE BASE\n\n## OVERVIEW\nGo-based Admin API for FLVX. Replaced legacy Spring Boot backend.\n**Stack:** Go 1.24, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).\n\n## STRUCTURE\n```\ngo-backend/\n├── cmd/paneld/main.go # Entry point; starts HTTP server + WebSocket\n├── internal/\n│ ├── http/ # HTTP layer\n│ │ ├── router.go # Routes (NewServeMux) + Middleware chain\n│ │ ├── handler/ # API Handlers (User, Tunnel, Node, etc.)\n│ │ ├── middleware/ # JWT, CORS, Logging, Recover\n│ │ └── response/ # JSON response helpers\n│ ├── store/\n│ │ ├── model/model.go # GORM model structs (single source of truth)\n│ │ └── repo/ # Data Access Layer (Repository pattern, GORM)\n│ │ ├── repository.go # Core queries, Open/OpenPostgres, AutoMigrate (83k LOC)\n│ │ ├── repository_mutations.go # Mutation helpers (user/node/tunnel/forward CRUD, 43k LOC)\n│ │ ├── repository_federation.go # Federation-specific queries\n│ │ ├── repository_flow.go # Flow/forward status queries\n│ │ ├── repository_control.go # Control plane queries\n│ │ └── repository_groups.go # Group management queries\n│ └── auth/ # Auth logic\n├── tests/contract/ # Integration/contract tests (14 tests)\n├── Dockerfile # Multi-stage build (golang:1.24-bookworm → debian:bookworm-slim)\n└── Makefile # Build commands\n```\n\n## WHERE TO LOOK\n| Task | Location | Notes |\n|------|----------|-------|\n| **API Routes** | `go-backend/internal/http/router.go` | Registers handlers to `http.ServeMux` |\n| **DB Models** | `go-backend/internal/store/model/model.go` | GORM structs with `TableName()` methods |\n| **Repository** | `go-backend/internal/store/repo/` | GORM-based queries, all DB ops encapsulated |\n| **Auth Middleware** | `go-backend/internal/http/middleware/jwt.go` | Extracts `Authorization` header |\n| **WebSocket** | `go-backend/internal/ws/` | Real-time updates (traffic, status) |\n| **Contract Tests** | `go-backend/tests/contract/` | Integration tests for auth, federation, tunnels |\n\n## CONVENTIONS\n- **GORM ORM**: Uses GORM with `glebarez/sqlite` (CGO-free) and `gorm.io/driver/postgres`.\n- **AutoMigrate**: Schema created at startup via `autoMigrateAll()` — no hand-written DDL.\n- **TableName()**: All models define explicit `TableName()` returning singular snake_case names.\n- **Repository Pattern**: Handlers never access `*gorm.DB` directly — all queries go through `repo.Repository` methods.\n- **Standard Lib**: Uses `net/http` for routing (Go 1.22+ patterns).\n- **Auth**: Expects raw JWT in `Authorization` header (no `Bearer` prefix).\n- **API Envelope**: All responses use `response.R{code, msg, data, ts}` structure.\n- **Config**: Loaded from environment variables (see `cmd/paneld/main.go`).\n- **SQLite Constraints**: `MaxOpenConns(1)`, WAL mode, busy_timeout=5000.\n- **PostgreSQL**: Supported via `DB_TYPE=postgres` and `DATABASE_URL` env vars.\n\n## ANTI-PATTERNS\n- **DO NOT** let handlers call `repo.DB()` directly — add a Repository method instead.\n- **DO NOT CHANGE** handler signatures without updating `router.go`.\n- **DO NOT** use `type:jsonb` or `type:serial` in GORM tags (SQLite incompatible).\n- **DO NOT** omit `TableName()` on new models — GORM pluralizes by default.\n\n## COMMANDS\n```bash\ncd go-backend\ngo run ./cmd/paneld # Default: SERVER_ADDR=:6365\ngo test ./... # Unit tests\ngo test ./tests/contract/... # Contract tests\nmake build\n```\n\n</system-reminder>",
|
||
"title": "go-backend/internal/store/repo/repository_mutations.go",
|
||
"metadata": {
|
||
"preview": "\tnum := user.Num\n\texpTime := user.ExpTime\n\tflowReset := user.FlowResetTime\n\tut := model.UserTunnel{\n\t\tUserID: userID,\n\t\tTunnelID: tunnelID,\n\t\tNum: num,\n\t\tFlow: flow,\n\t\tInFlow: 0,\n\t\tOutFlow: 0,\n\t\tFlowResetTime: flowReset,\n\t\tExpTime: expTime,\n\t\tStatus: 1,\n\t}\n\tif err := r.db.Create(&ut).Error; err != nil {\n\t\treturn 0, false, err\n\t}\n\treturn ut.ID, true, nil\n}\n",
|
||
"truncated": true,
|
||
"loaded": [
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/AGENTS.md"
|
||
]
|
||
},
|
||
"time": {
|
||
"start": 1772506014141,
|
||
"end": 1772506014155
|
||
}
|
||
},
|
||
"id": "prt_cb19761bc001uvMoVv0RqR0HND",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19748bc002uAeoZBxxFQdtMo"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 18818,
|
||
"input": 18543,
|
||
"output": 275,
|
||
"reasoning": 43,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb19761e3001pk8c7SVhMvw8XS",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19748bc002uAeoZBxxFQdtMo"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506014239,
|
||
"completed": 1772506021361
|
||
},
|
||
"parentID": "msg_cb1972fd9001q5a8C3Bm5amrRC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 25982,
|
||
"input": 25620,
|
||
"output": 362,
|
||
"reasoning": 162,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb197621f0029Z6Dhlqi7FI3Ky",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"id": "prt_cb197744b001sfzw7Z3gL9q62G",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197621f0029Z6Dhlqi7FI3Ky"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Inspecting forward service binding addresses**",
|
||
"time": {
|
||
"start": 1772506018892,
|
||
"end": 1772506021279
|
||
},
|
||
"id": "prt_cb197744c001X6HuscvRCv39ba",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197621f0029Z6Dhlqi7FI3Ky"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_36n21NaK5pdoBRZUp2GpwbMn",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "resolveForwardIngress|InIP|in_ip|forward.*listener|buildForward|ForwardPort|tcp_listen_addr|\\[::\\]",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal",
|
||
"include": "*.go"
|
||
},
|
||
"output": "Found 166 matches (showing first 100)\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go:\n Line 42: type ForwardPortBackup = model.ForwardPortBackup\n Line 165: \t\t&model.ForwardPort{},\n Line 495: \t\tinIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)\n Line 501: \t\t\tTunnelName: row.TunnelName, InIP: inIP, InPort: inPort,\n Line 731: \t\tinIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)\n Line 816: \t\t\t\"inIp\": nullableString(t.InIP),\n Line 1719: \t\tif t.InIP.Valid {\n Line 1720: \t\t\tb.InIP = t.InIP.String\n Line 1772: \t\tports, err := r.exportForwardPorts(f.ID)\n Line 1776: \t\tportsCopy := append([]model.ForwardPortBackup(nil), ports...)\n Line 1777: \t\tb.ForwardPorts = &portsCopy\n Line 1783: func (r *Repository) exportForwardPorts(forwardID int64) ([]model.ForwardPortBackup, error) {\n Line 1784: \tvar fps []model.ForwardPort\n Line 1788: \tout := make([]model.ForwardPortBackup, 0, len(fps))\n Line 1790: \t\tout = append(out, model.ForwardPortBackup{NodeID: fp.NodeID, Port: fp.Port})\n Line 2051: \t\t\t\t\"http\", \"tls\", \"socks\", \"updated_time\", \"status\", \"tcp_listen_addr\", \"udp_listen_addr\",\n Line 2076: \t\t\tInIP: sql.NullString{String: t.InIP, Valid: true},\n Line 2083: \t\t\t\t\"name\", \"traffic_ratio\", \"type\", \"protocol\", \"flow\", \"updated_time\", \"status\", \"in_ip\", \"inx\", \"ip_preference\",\n Line 2143: \t\tif f.ForwardPorts != nil {\n Line 2144: \t\t\tif err := tx.Where(\"forward_id = ?\", f.ID).Delete(&model.ForwardPort{}).Error; err != nil {\n Line 2147: \t\t\tfor _, fp := range *f.ForwardPorts {\n Line 2148: \t\t\t\tif err := tx.Create(&model.ForwardPort{ForwardID: f.ID, NodeID: fp.NodeID, Port: fp.Port}).Error; err != nil {\n Line 2731: func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string, sql.NullInt64, error) {\n Line 2732: \tvar tunnelInIP sql.NullString\n Line 2733: \tdb.Model(&model.Tunnel{}).Select(\"in_ip\").Where(\"id = ?\", tunnelID).Limit(1).Scan(&tunnelInIP)\n Line 2738: \t\tInIP sql.NullString\n Line 2741: \terr := db.Model(&model.ForwardPort{}).\n Line 2742: \t\tSelect(\"forward_port.port, node.server_ip, forward_port.in_ip\").\n Line 2757: \tif tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != \"\" {\n Line 2758: \t\ttunnelIPs := strings.Split(tunnelInIP.String, \",\")\n Line 2778: \t\tif row.InIP.Valid && strings.TrimSpace(row.InIP.String) != \"\" {\n Line 2779: \t\t\tip = strings.TrimSpace(row.InIP.String)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_mutations.go:\n Line 128: \t\tif err := tx.Where(\"forward_id IN (?)\", forwardIDs).Delete(&model.ForwardPort{}).Error; err != nil {\n Line 259: \t\t\t\"tcp_listen_addr\": tcpAddr,\n Line 306: \t\tif err := tx.Where(\"node_id = ?\", nodeID).Delete(&model.ForwardPort{}).Error; err != nil {\n Line 387: \t\t\t\"in_ip\": nullStringFromInterface(inIP),\n Line 477: \tif err := tx.Model(&model.ForwardPort{}).\n Line 519: \t\tif err := tx.Where(\"forward_id IN (?)\", forwardIDs).Delete(&model.ForwardPort{}).Error; err != nil {\n Line 637: func (r *Repository) GetMinForwardPort(forwardID int64) sql.NullInt64 {\n Line 642: \t_ = r.db.Model(&model.ForwardPort{}).\n Line 688: \t\tif err := tx.Where(\"forward_id = ?\", forwardID).Delete(&model.ForwardPort{}).Error; err != nil {\n Line 695: func (r *Repository) ReplaceForwardPorts(forwardID int64, entries []struct {\n Line 698: \tInIP string\n Line 704: \t\tif err := tx.Where(\"forward_id = ?\", forwardID).Delete(&model.ForwardPort{}).Error; err != nil {\n Line 710: \t\trows := make([]model.ForwardPort, 0, len(entries))\n Line 712: \t\t\trows = append(rows, model.ForwardPort{\n Line 716: \t\t\t\tInIP: sql.NullString{String: e.InIP, Valid: e.InIP != \"\"},\n Line 748: \tif err := r.db.Model(&model.ForwardPort{}).Where(\"node_id = ?\", nodeID).Pluck(\"port\", &forwardPorts).Error; err != nil {\n Line 1206: \t\t\tfp := model.ForwardPort{\n Line 1210: \t\t\t\tInIP: sql.NullString{String: inIp, Valid: inIp != \"\"},\n Line 1249: \t\tInIP: inIPVal,\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_control.go:\n Line 82: \terr := r.db.Model(&model.ForwardPort{}).\n Line 94: func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecord, error) {\n Line 98: \tvar ports []model.ForwardPort\n Line 103: \trows := make([]model.ForwardPortRecord, 0, len(ports))\n Line 106: \t\tif p.InIP.Valid {\n Line 107: \t\t\tinIP = p.InIP.String\n Line 109: \t\trows = append(rows, model.ForwardPortRecord{NodeID: p.NodeID, Port: p.Port, InIP: inIP})\n Line 200: \t\trec.TCPListenAddr = \"[::]\"\n Line 203: \t\trec.UDPListenAddr = \"[::]\"\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go:\n Line 50: type ForwardPort struct {\n Line 55: \tInIP sql.NullString `gorm:\"column:in_ip;type:text\"`\n Line 58: func (ForwardPort) TableName() string { return \"forward_port\" }\n Line 77: \tTCPListenAddr string `gorm:\"column:tcp_listen_addr;type:varchar(100);not null;default:'[::]'\"`\n Line 78: \tUDPListenAddr string `gorm:\"column:udp_listen_addr;type:varchar(100);not null;default:'[::]'\"`\n Line 122: \tInIP sql.NullString `gorm:\"column:in_ip;type:text\"`\n Line 372: \tInIP string `json:\"inIp,omitempty\"`\n Line 404: \tForwardPorts *[]ForwardPortBackup `json:\"forwardPorts,omitempty\"`\n Line 407: type ForwardPortBackup struct {\n Line 513: // ForwardPortRecord is a forward port mapping used by control plane.\n Line 514: type ForwardPortRecord struct {\n Line 517: \tInIP string\n Line 594: \tInIP string\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go:\n Line 267: \t\tdefaultString(asString(req[\"tcpListenAddr\"]), \"[::]\"),\n Line 268: \t\tdefaultString(asString(req[\"udpListenAddr\"]), \"[::]\"),\n Line 330: \t\tdefaultString(asString(req[\"tcpListenAddr\"]), \"[::]\"),\n Line 331: \t\tdefaultString(asString(req[\"udpListenAddr\"]), \"[::]\"),\n Line 498: \t\tinIP = buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes, ipPreference)\n Line 549: \tvar tunnelInIP sql.NullString\n Line 551: \t\ttunnelInIP = sql.NullString{String: trimmed, Valid: true}\n Line 562: \t\tInIP: tunnelInIP,\n Line 695: \tinIp := buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes, ipPreference)\n Line 1220: \toldPorts, err := h.listForwardPorts(id)\n Line 1278: \t\tminPort := h.repo.GetMinForwardPort(id)\n Line 1303: \tif err := h.replaceForwardPorts(id, tunnelID, port, inIp); err != nil {\n Line 1602: \t\toldPorts, listPortsErr := h.listForwardPorts(id)\n Line 1607: \t\tport := h.repo.GetMinForwardPort(id)\n Line 1635: \t\tif err := h.replaceForwardPorts(id, req.TargetTunnelID, p, \"\"); err != nil {\n Line 2119: func buildTunnelInIP(inNodes []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) string {\n Line 3017: func (h *Handler) replaceForwardPorts(forwardID, tunnelID int64, port int, inIp string) error {\n Line 3025: \t\tInIP string\n Line 3031: \t\t\tInIP string\n Line 3032: \t\t}{NodeID: nid, Port: port, InIP: inIp}\n Line 3034: \treturn h.repo.ReplaceForwardPorts(forwardID, entries)\n Line 3037: func (h *Handler) replaceForwardPortsWithRecords(forwardID int64, ports []forwardPortRecord) error {\n Line 3041: \t\tInIP string\n Line 3047: \t\t\tInIP string\n Line 3048: \t\t}{NodeID: fp.NodeID, Port: fp.Port, InIP: fp.InIP}\n Line 3050: \treturn h.repo.ReplaceForwardPorts(forwardID, entries)\n Line 3065: \tif err := h.replaceForwardPortsWithRecords(oldForward.ID, oldPorts); err != nil {\n\n(Results truncated: showing 100 of 166 matches (66 hidden). Consider using a more specific path or pattern.)",
|
||
"title": "resolveForwardIngress|InIP|in_ip|forward.*listener|buildForward|ForwardPort|tcp_listen_addr|\\[::\\]",
|
||
"metadata": {
|
||
"matches": 166,
|
||
"truncated": true
|
||
},
|
||
"time": {
|
||
"start": 1772506021265,
|
||
"end": 1772506021288
|
||
}
|
||
},
|
||
"id": "prt_cb1977d7e001do8Qj73t1O1Lmp",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197621f0029Z6Dhlqi7FI3Ky"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_pAMTgZuwXr4sPWg0YA2G0OI3",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "inIp|监听IP|默认入口IP|__default__|forward.*inIp",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages",
|
||
"include": "*.tsx"
|
||
},
|
||
"output": "Found 53 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/tunnel.tsx:\n Line 84: inIp: string;\n Line 113: inIp: string; // 入口IP\n Line 296: inIp: tunnel.inIp\n Line 297: ? tunnel.inIp\n Line 479: const inIpString = form.inIp\n Line 487: inIp: inIpString,\n Line 858: (t.inIp && t.inIp.toLowerCase().includes(lowerKeyword)),\n Line 1429: errorMessage={errors.inIp}\n Line 1430: isInvalid={!!errors.inIp}\n Line 1435: value={form.inIp}\n Line 1438: setForm((prev) => ({ ...prev, inIp: e.target.value }))\n Line 1833: selectedGroupConnectIp || \"__default__\",\n Line 1842: selectedKey === \"__default__\"\n Line 1848: <SelectItem key=\"__default__\">默认连接IP</SelectItem>\n Line 2146: ? form.outNodeId[0].connectIp || \"__default__\"\n Line 2147: : \"__default__\",\n Line 2154: selectedKey === \"__default__\" ? \"\" : selectedKey;\n Line 2181: <SelectItem key=\"__default__\">默认连接IP</SelectItem>\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/forward.tsx:\n Line 102: inIp: string;\n Line 122: inIp?: string;\n Line 142: inIp: string;\n Line 564: inIp: \"\",\n Line 632: (tunnel.inIp || \"\")\n Line 1256: inIp: \"\",\n Line 1275: inIp: forward.inIp || \"\",\n Line 1336: inIp: options.includes(prev.inIp) ? prev.inIp : \"\",\n Line 1364: inIp: form.inIp || undefined,\n Line 1376: inIp: form.inIp || undefined,\n Line 2755: hasMultipleAddresses(forward.inIp)\n Line 2759: title={formatInAddress(forward.inIp, forward.inPort)}\n Line 2762: showAddressModal(forward.inIp, forward.inPort, \"入口端口\")\n Line 2765: {formatInAddress(forward.inIp, forward.inPort)}\n Line 2974: hasMultipleAddresses(forward.inIp)\n Line 2978: title={formatInAddress(forward.inIp, forward.inPort)}\n Line 2981: showAddressModal(forward.inIp, forward.inPort, \"入口端口\")\n Line 2984: {formatInAddress(forward.inIp, forward.inPort)}\n Line 3173: hasMultipleAddresses(forward.inIp)\n Line 3177: title={formatInAddress(forward.inIp, forward.inPort)}\n Line 3180: showAddressModal(forward.inIp, forward.inPort, \"入口端口\")\n Line 3189: {formatInAddress(forward.inIp, forward.inPort)}\n Line 3192: {hasMultipleAddresses(forward.inIp) && (\n Line 4128: label=\"监听IP\"\n Line 4132: ? \"选择入口监听IP\"\n Line 4136: selectedKeys={[form.inIp || \"__default__\"]}\n Line 4143: inIp: selectedKey === \"__default__\" ? \"\" : selectedKey,\n Line 4147: <SelectItem key=\"__default__\">默认入口IP</SelectItem>\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/dashboard.tsx:\n Line 324: // inIp 已经包含完整的 IP:Port 组合\n Line 332: // inIp 只包含IP,需要添加端口(兼容旧数据)\n Line 859: className={`block px-2 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300 rounded font-mono text-xs truncate ${hasMultipleIps(forward.inIp) ? \"cursor-pointer hover:bg-green-200 dark:hover:bg-green-500/30\" : \"\"}`}\n Line 860: disabled={!hasMultipleIps(forward.inIp)}\n Line 862: forward.inIp,\n Line 868: forward.inIp,\n Line 874: {formatInAddress(forward.inIp, forward.inPort)}",
|
||
"title": "inIp|监听IP|默认入口IP|__default__|forward.*inIp",
|
||
"metadata": {
|
||
"matches": 53,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772506021271,
|
||
"end": 1772506021290
|
||
}
|
||
},
|
||
"id": "prt_cb1977d94001U15iMweBb71wtM",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197621f0029Z6Dhlqi7FI3Ky"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 25982,
|
||
"input": 25620,
|
||
"output": 362,
|
||
"reasoning": 162,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb1977dab001sOgZK6M5trdF1G",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197621f0029Z6Dhlqi7FI3Ky"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506021362,
|
||
"completed": 1772506030112
|
||
},
|
||
"parentID": "msg_cb1972fd9001q5a8C3Bm5amrRC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 29770,
|
||
"input": 29447,
|
||
"output": 323,
|
||
"reasoning": 88,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb1977df2002ADRTJ34ObEVSML",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"id": "prt_cb19798a3001E7WxiADTvaj7yD",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb1977df2002ADRTJ34ObEVSML"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Planning service address inspection**",
|
||
"time": {
|
||
"start": 1772506028195,
|
||
"end": 1772506030035
|
||
},
|
||
"id": "prt_cb19798a3002LJpEfCHc5W5ZJ6",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb1977df2002ADRTJ34ObEVSML"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_eAe5QLvsq5jzowK1DQLckJ03",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go",
|
||
"offset": 2710,
|
||
"limit": 130
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go</path>\n<type>file</type>\n<content>2710: \tif firstQuote < 0 {\n2711: \t\treturn \"\"\n2712: \t}\n2713: \texpr = expr[firstQuote+1:]\n2714: \tsecondQuote := strings.Index(expr, \"'\")\n2715: \tif secondQuote < 0 {\n2716: \t\treturn \"\"\n2717: \t}\n2718: \treturn strings.TrimSpace(expr[:secondQuote])\n2719: }\n2720: \n2721: func quoteSQLIdentifier(ident string) string {\n2722: \treturn `\"` + strings.ReplaceAll(ident, `\"`, `\"\"`) + `\"`\n2723: }\n2724: \n2725: func quoteSQLLiteral(value string) string {\n2726: \treturn \"'\" + strings.ReplaceAll(value, \"'\", \"''\") + \"'\"\n2727: }\n2728: \n2729: // ─── Helper Functions ────────────────────────────────────────────────\n2730: \n2731: func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string, sql.NullInt64, error) {\n2732: \tvar tunnelInIP sql.NullString\n2733: \tdb.Model(&model.Tunnel{}).Select(\"in_ip\").Where(\"id = ?\", tunnelID).Limit(1).Scan(&tunnelInIP)\n2734: \n2735: \ttype fpRow struct {\n2736: \t\tPort sql.NullInt64\n2737: \t\tServerIP sql.NullString\n2738: \t\tInIP sql.NullString\n2739: \t}\n2740: \tvar fpRows []fpRow\n2741: \terr := db.Model(&model.ForwardPort{}).\n2742: \t\tSelect(\"forward_port.port, node.server_ip, forward_port.in_ip\").\n2743: \t\tJoins(\"LEFT JOIN node ON node.id = forward_port.node_id\").\n2744: \t\tWhere(\"forward_port.forward_id = ?\", forwardID).\n2745: \t\tOrder(\"forward_port.id ASC\").\n2746: \t\tFind(&fpRows).Error\n2747: \tif err != nil {\n2748: \t\treturn \"\", sql.NullInt64{}, err\n2749: \t}\n2750: \n2751: \tports := make([]int64, 0)\n2752: \tentries := make([]string, 0)\n2753: \tseenPorts := make(map[int64]struct{})\n2754: \tseenPairs := make(map[string]struct{})\n2755: \n2756: \tvar tunnelFirstIP string\n2757: \tif tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != \"\" {\n2758: \t\ttunnelIPs := strings.Split(tunnelInIP.String, \",\")\n2759: \t\tfor _, ip := range tunnelIPs {\n2760: \t\t\tip = strings.TrimSpace(ip)\n2761: \t\t\tif ip != \"\" {\n2762: \t\t\t\ttunnelFirstIP = ip\n2763: \t\t\t\tbreak\n2764: \t\t\t}\n2765: \t\t}\n2766: \t}\n2767: \n2768: \tfor _, row := range fpRows {\n2769: \t\tif !row.Port.Valid {\n2770: \t\t\tcontinue\n2771: \t\t}\n2772: \t\tif _, ok := seenPorts[row.Port.Int64]; !ok {\n2773: \t\t\tseenPorts[row.Port.Int64] = struct{}{}\n2774: \t\t\tports = append(ports, row.Port.Int64)\n2775: \t\t}\n2776: \n2777: \t\tvar ip string\n2778: \t\tif row.InIP.Valid && strings.TrimSpace(row.InIP.String) != \"\" {\n2779: \t\t\tip = strings.TrimSpace(row.InIP.String)\n2780: \t\t} else if tunnelFirstIP != \"\" {\n2781: \t\t\tip = tunnelFirstIP\n2782: \t\t} else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != \"\" {\n2783: \t\t\tip = strings.TrimSpace(row.ServerIP.String)\n2784: \t\t}\n2785: \n2786: \t\tif ip != \"\" {\n2787: \t\t\tpair := fmt.Sprintf(\"%s:%d\", ip, row.Port.Int64)\n2788: \t\t\tif _, ok := seenPairs[pair]; !ok {\n2789: \t\t\t\tseenPairs[pair] = struct{}{}\n2790: \t\t\t\tentries = append(entries, pair)\n2791: \t\t\t}\n2792: \t\t}\n2793: \t}\n2794: \n2795: \tif len(ports) == 0 {\n2796: \t\treturn \"\", sql.NullInt64{}, nil\n2797: \t}\n2798: \n2799: \tinPort := sql.NullInt64{Int64: ports[0], Valid: true}\n2800: \n2801: \treturn strings.Join(entries, \",\"), inPort, nil\n2802: }\n2803: \n2804: func nullableString(v sql.NullString) interface{} {\n2805: \tif v.Valid {\n2806: \t\treturn v.String\n2807: \t}\n2808: \treturn nil\n2809: }\n2810: \n2811: func nullableForwardIngress(v string) interface{} {\n2812: \tv = strings.TrimSpace(v)\n2813: \tif v == \"\" {\n2814: \t\treturn nil\n2815: \t}\n2816: \treturn v\n2817: }\n2818: \n2819: func nullableInt64(v sql.NullInt64) interface{} {\n2820: \tif v.Valid {\n2821: \t\treturn v.Int64\n2822: \t}\n2823: \treturn nil\n2824: }\n2825: \n2826: func unixMilliNow() int64 {\n2827: \treturn time.Now().UnixMilli()\n2828: }\n2829: \n2830: func ensureParentDir(dbPath string) error {\n2831: \tif dbPath == \"\" {\n2832: \t\treturn fmt.Errorf(\"empty db path\")\n2833: \t}\n2834: \tdir := filepath.Dir(dbPath)\n2835: \tif dir == \"\" || dir == \".\" {\n2836: \t\treturn nil\n2837: \t}\n2838: \treturn osMkdirAll(dir)\n2839: }\n\n(Showing lines 2710-2839 of 2846. Use offset=2840 to continue.)\n</content>",
|
||
"title": "go-backend/internal/store/repo/repository.go",
|
||
"metadata": {
|
||
"preview": "\tif firstQuote < 0 {\n\t\treturn \"\"\n\t}\n\texpr = expr[firstQuote+1:]\n\tsecondQuote := strings.Index(expr, \"'\")\n\tif secondQuote < 0 {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(expr[:secondQuote])\n}\n\nfunc quoteSQLIdentifier(ident string) string {\n\treturn `\"` + strings.ReplaceAll(ident, `\"`, `\"\"`) + `\"`\n}\n\nfunc quoteSQLLiteral(value string) string {\n\treturn \"'\" + strings.ReplaceAll(value, \"'\", \"''\") + \"'\"\n}\n\n// ─── Helper Functions ────────────────────────────────────────────────",
|
||
"truncated": true,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772506030015,
|
||
"end": 1772506030045
|
||
}
|
||
},
|
||
"id": "prt_cb1979fb30018cuXgHwiG39nhF",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb1977df2002ADRTJ34ObEVSML"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_gLF6NjM3dnF5P55w146wsxyJ",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go",
|
||
"offset": 1180,
|
||
"limit": 180
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go</path>\n<type>file</type>\n<content>1180: \t}\n1181: \tinIp := strings.TrimSpace(asString(req[\"inIp\"]))\n1182: \tforwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req[\"strategy\"]), \"fifo\"), now, inx, entryNodes, port, inIp, nullableInt(speedID))\n1183: \tif err != nil {\n1184: \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n1185: \t\treturn\n1186: \t}\n1187: \tcreatedForward, err := h.getForwardRecord(forwardID)\n1188: \tif err != nil {\n1189: \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n1190: \t\treturn\n1191: \t}\n1192: \tif err := h.syncForwardServices(createdForward, \"UpdateService\", true); err != nil {\n1193: \t\t_ = h.deleteForwardByID(forwardID)\n1194: \t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n1195: \t\treturn\n1196: \t}\n1197: \tresponse.WriteJSON(w, response.OKEmpty())\n1198: }\n1199: \n1200: func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {\n1201: \tvar req map[string]interface{}\n1202: \tif err := decodeJSON(r.Body, &req); err != nil {\n1203: \t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n1204: \t\treturn\n1205: \t}\n1206: \tid := asInt64(req[\"id\"], 0)\n1207: \tif id <= 0 {\n1208: \t\tresponse.WriteJSON(w, response.ErrDefault(\"转发ID不能为空\"))\n1209: \t\treturn\n1210: \t}\n1211: \tforward, actorUserID, actorRole, err := h.resolveForwardAccess(r, id)\n1212: \tif err != nil {\n1213: \t\tif errors.Is(err, errForwardNotFound) {\n1214: \t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n1215: \t\t\treturn\n1216: \t\t}\n1217: \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n1218: \t\treturn\n1219: \t}\n1220: \toldPorts, err := h.listForwardPorts(id)\n1221: \tif err != nil {\n1222: \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n1223: \t\treturn\n1224: \t}\n1225: \n1226: \ttunnelID := asInt64(req[\"tunnelId\"], forward.TunnelID)\n1227: \tif tunnelID <= 0 {\n1228: \t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道ID不能为空\"))\n1229: \t\treturn\n1230: \t}\n1231: \tif err := h.ensureTunnelPermission(actorUserID, actorRole, tunnelID); err != nil {\n1232: \t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n1233: \t\treturn\n1234: \t}\n1235: \ttunnel, err := h.getTunnelRecord(tunnelID)\n1236: \tif err != nil {\n1237: \t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道不存在\"))\n1238: \t\treturn\n1239: \t}\n1240: \tif tunnel.Status != 1 {\n1241: \t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道已禁用,无法更新转发\"))\n1242: \t\treturn\n1243: \t}\n1244: \n1245: \tname := strings.TrimSpace(asString(req[\"name\"]))\n1246: \tif name == \"\" {\n1247: \t\tname = forward.Name\n1248: \t}\n1249: \tremoteAddr := strings.TrimSpace(asString(req[\"remoteAddr\"]))\n1250: \tif remoteAddr == \"\" {\n1251: \t\tremoteAddr = forward.RemoteAddr\n1252: \t}\n1253: \tstrategy := strings.TrimSpace(asString(req[\"strategy\"]))\n1254: \tif strategy == \"\" {\n1255: \t\tstrategy = forward.Strategy\n1256: \t}\n1257: \tspeedID := asAnyToInt64Ptr(req[\"speedId\"])\n1258: \tif speedID != nil {\n1259: \t\texists, speedErr := h.repo.SpeedLimitExists(*speedID)\n1260: \t\tif speedErr != nil {\n1261: \t\t\tresponse.WriteJSON(w, response.Err(-2, speedErr.Error()))\n1262: \t\t\treturn\n1263: \t\t}\n1264: \t\tif !exists {\n1265: \t\t\tresponse.WriteJSON(w, response.ErrDefault(\"限速规则不存在\"))\n1266: \t\t\treturn\n1267: \t\t}\n1268: \t}\n1269: \tnewSpeedID := forward.SpeedID\n1270: \tif speedID != nil {\n1271: \t\tnewSpeedID = sql.NullInt64{Int64: *speedID, Valid: true}\n1272: \t} else if _, ok := req[\"speedId\"]; ok {\n1273: \t\tnewSpeedID = sql.NullInt64{Valid: false}\n1274: \t}\n1275: \n1276: \tport := asInt(req[\"inPort\"], 0)\n1277: \tif port <= 0 {\n1278: \t\tminPort := h.repo.GetMinForwardPort(id)\n1279: \t\tif minPort.Valid {\n1280: \t\t\tport = int(minPort.Int64)\n1281: \t\t}\n1282: \t\tif port <= 0 {\n1283: \t\t\tport = h.pickTunnelPort(tunnelID)\n1284: \t\t}\n1285: \t}\n1286: \tinIp := asString(req[\"inIp\"])\n1287: \tfwdEntryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)\n1288: \tfor _, nodeID := range fwdEntryNodes {\n1289: \t\tnode, nodeErr := h.getNodeRecord(nodeID)\n1290: \t\tif nodeErr != nil {\n1291: \t\t\tcontinue\n1292: \t\t}\n1293: \t\tif err := validateRemoteNodePort(node, port); err != nil {\n1294: \t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n1295: \t\t\treturn\n1296: \t\t}\n1297: \t}\n1298: \tnow := time.Now().UnixMilli()\n1299: \tif err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID); err != nil {\n1300: \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n1301: \t\treturn\n1302: \t}\n1303: \tif err := h.replaceForwardPorts(id, tunnelID, port, inIp); err != nil {\n1304: \t\th.rollbackForwardMutation(forward, oldPorts)\n1305: \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n1306: \t\treturn\n1307: \t}\n1308: \tupdatedForward, err := h.getForwardRecord(id)\n1309: \tif err != nil {\n1310: \t\th.rollbackForwardMutation(forward, oldPorts)\n1311: \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n1312: \t\treturn\n1313: \t}\n1314: \tif err := h.syncForwardServices(updatedForward, \"UpdateService\", true); err != nil {\n1315: \t\th.rollbackForwardMutation(forward, oldPorts)\n1316: \t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n1317: \t\treturn\n1318: \t}\n1319: \tresponse.WriteJSON(w, response.OKEmpty())\n1320: }\n1321: \n1322: func (h *Handler) forwardDelete(w http.ResponseWriter, r *http.Request) {\n1323: \tid := idFromBody(r, w)\n1324: \tif id <= 0 {\n1325: \t\treturn\n1326: \t}\n1327: \tforward, _, _, err := h.resolveForwardAccess(r, id)\n1328: \tif err != nil {\n1329: \t\tif errors.Is(err, errForwardNotFound) {\n1330: \t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n1331: \t\t\treturn\n1332: \t\t}\n1333: \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n1334: \t\treturn\n1335: \t}\n1336: \tif err := h.controlForwardServices(forward, \"DeleteService\", true); err != nil {\n1337: \t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n1338: \t\treturn\n1339: \t}\n1340: \tif err := h.deleteForwardByID(id); err != nil {\n1341: \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n1342: \t\treturn\n1343: \t}\n1344: \tresponse.WriteJSON(w, response.OKEmpty())\n1345: }\n1346: \n1347: func (h *Handler) forwardForceDelete(w http.ResponseWriter, r *http.Request) {\n1348: \th.forwardDelete(w, r)\n1349: }\n1350: \n1351: func (h *Handler) forwardPause(w http.ResponseWriter, r *http.Request) {\n1352: \tid := idFromBody(r, w)\n1353: \tif id <= 0 {\n1354: \t\treturn\n1355: \t}\n1356: \tforward, _, _, err := h.resolveForwardAccess(r, id)\n1357: \tif err != nil {\n1358: \t\tif errors.Is(err, errForwardNotFound) {\n1359: \t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\n(Showing lines 1180-1359 of 3419. Use offset=1360 to continue.)\n</content>",
|
||
"title": "go-backend/internal/http/handler/mutations.go",
|
||
"metadata": {
|
||
"preview": "\t}\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\t}\n\tcreatedForward, err := h.getForwardRecord(forwardID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.syncForwardServices(createdForward, \"UpdateService\", true); err != nil {\n\t\t_ = h.deleteForwardByID(forwardID)\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n",
|
||
"truncated": true,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772506030025,
|
||
"end": 1772506030046
|
||
}
|
||
},
|
||
"id": "prt_cb1979fc4001fLY5i2ir6ahmO2",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb1977df2002ADRTJ34ObEVSML"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_V1wvrNFYigb97Qz2T420irLL",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go",
|
||
"offset": 2990,
|
||
"limit": 110
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go</path>\n<type>file</type>\n<content>2990: \t\tif part == \"\" {\n2991: \t\t\tcontinue\n2992: \t\t}\n2993: \t\tif strings.Contains(part, \"-\") {\n2994: \t\t\trangeParts := strings.Split(part, \"-\")\n2995: \t\t\tif len(rangeParts) != 2 {\n2996: \t\t\t\treturn nil, errors.New(\"invalid port range format\")\n2997: \t\t\t}\n2998: \t\t\tstart, err1 := strconv.Atoi(strings.TrimSpace(rangeParts[0]))\n2999: \t\t\tend, err2 := strconv.Atoi(strings.TrimSpace(rangeParts[1]))\n3000: \t\t\tif err1 != nil || err2 != nil || start > end {\n3001: \t\t\t\treturn nil, errors.New(\"invalid port range values\")\n3002: \t\t\t}\n3003: \t\t\tfor i := start; i <= end; i++ {\n3004: \t\t\t\tports = append(ports, i)\n3005: \t\t\t}\n3006: \t\t} else {\n3007: \t\t\tp, err := strconv.Atoi(part)\n3008: \t\t\tif err != nil {\n3009: \t\t\t\treturn nil, errors.New(\"invalid port value\")\n3010: \t\t\t}\n3011: \t\t\tports = append(ports, p)\n3012: \t\t}\n3013: \t}\n3014: \treturn ports, nil\n3015: }\n3016: \n3017: func (h *Handler) replaceForwardPorts(forwardID, tunnelID int64, port int, inIp string) error {\n3018: \tentryNodes, err := h.tunnelEntryNodeIDs(tunnelID)\n3019: \tif err != nil {\n3020: \t\treturn err\n3021: \t}\n3022: \tentries := make([]struct {\n3023: \t\tNodeID int64\n3024: \t\tPort int\n3025: \t\tInIP string\n3026: \t}, len(entryNodes))\n3027: \tfor i, nid := range entryNodes {\n3028: \t\tentries[i] = struct {\n3029: \t\t\tNodeID int64\n3030: \t\t\tPort int\n3031: \t\t\tInIP string\n3032: \t\t}{NodeID: nid, Port: port, InIP: inIp}\n3033: \t}\n3034: \treturn h.repo.ReplaceForwardPorts(forwardID, entries)\n3035: }\n3036: \n3037: func (h *Handler) replaceForwardPortsWithRecords(forwardID int64, ports []forwardPortRecord) error {\n3038: \tentries := make([]struct {\n3039: \t\tNodeID int64\n3040: \t\tPort int\n3041: \t\tInIP string\n3042: \t}, len(ports))\n3043: \tfor i, fp := range ports {\n3044: \t\tentries[i] = struct {\n3045: \t\t\tNodeID int64\n3046: \t\t\tPort int\n3047: \t\t\tInIP string\n3048: \t\t}{NodeID: fp.NodeID, Port: fp.Port, InIP: fp.InIP}\n3049: \t}\n3050: \treturn h.repo.ReplaceForwardPorts(forwardID, entries)\n3051: }\n3052: \n3053: func (h *Handler) rollbackForwardMutation(oldForward *forwardRecord, oldPorts []forwardPortRecord) {\n3054: \tif h == nil || oldForward == nil || h.repo == nil {\n3055: \t\treturn\n3056: \t}\n3057: \n3058: \th.repo.RollbackForwardFields(\n3059: \t\toldForward.ID, oldForward.UserID, oldForward.UserName, oldForward.Name,\n3060: \t\toldForward.TunnelID, oldForward.RemoteAddr, oldForward.Strategy, oldForward.Status,\n3061: \t\toldForward.SpeedID,\n3062: \t\ttime.Now().UnixMilli(),\n3063: \t)\n3064: \n3065: \tif err := h.replaceForwardPortsWithRecords(oldForward.ID, oldPorts); err != nil {\n3066: \t\treturn\n3067: \t}\n3068: \n3069: \t_ = h.syncForwardServices(oldForward, \"UpdateService\", true)\n3070: }\n3071: \n3072: func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {\n3073: \tuserID := asInt64(req[\"userId\"], 0)\n3074: \ttunnelID := asInt64(req[\"tunnelId\"], 0)\n3075: \tif userID <= 0 || tunnelID <= 0 {\n3076: \t\treturn fmt.Errorf(\"userId or tunnelId missing\")\n3077: \t}\n3078: \n3079: \texistingID, currentFlow, currentNum, currentExpTime, currentFlowReset, currentSpeedID, currentStatus, err :=\n3080: \t\th.repo.GetExistingUserTunnel(userID, tunnelID)\n3081: \n3082: \tspeedID := asAnyToInt64Ptr(req[\"speedId\"])\n3083: \tif err := h.validateSpeedLimitReference(speedID); err != nil {\n3084: \t\treturn err\n3085: \t}\n3086: \n3087: \treqFlow := asInt64(req[\"flow\"], -1)\n3088: \treqNum := asInt(req[\"num\"], -1)\n3089: \treqExpTime := asInt64(req[\"expTime\"], -1)\n3090: \treqFlowReset := asInt64(req[\"flowResetTime\"], -1)\n3091: \treqStatus := asInt(req[\"status\"], -1)\n3092: \n3093: \tif err == sql.ErrNoRows {\n3094: \t\tif reqFlow < 0 || reqNum < 0 || reqExpTime < 0 || reqFlowReset < 0 {\n3095: \t\t\tuFlow, uNum, uExp, uReset, uErr := h.repo.GetUserDefaultsForTunnel(userID)\n3096: \t\t\tif uErr == nil {\n3097: \t\t\t\tif reqFlow < 0 {\n3098: \t\t\t\t\treqFlow = uFlow\n3099: \t\t\t\t}\n\n(Showing lines 2990-3099 of 3419. Use offset=3100 to continue.)\n</content>",
|
||
"title": "go-backend/internal/http/handler/mutations.go",
|
||
"metadata": {
|
||
"preview": "\t\tif part == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.Contains(part, \"-\") {\n\t\t\trangeParts := strings.Split(part, \"-\")\n\t\t\tif len(rangeParts) != 2 {\n\t\t\t\treturn nil, errors.New(\"invalid port range format\")\n\t\t\t}\n\t\t\tstart, err1 := strconv.Atoi(strings.TrimSpace(rangeParts[0]))\n\t\t\tend, err2 := strconv.Atoi(strings.TrimSpace(rangeParts[1]))\n\t\t\tif err1 != nil || err2 != nil || start > end {\n\t\t\t\treturn nil, errors.New(\"invalid port range values\")\n\t\t\t}\n\t\t\tfor i := start; i <= end; i++ {\n\t\t\t\tports = append(ports, i)\n\t\t\t}\n\t\t} else {\n\t\t\tp, err := strconv.Atoi(part)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, errors.New(\"invalid port value\")",
|
||
"truncated": true,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772506030030,
|
||
"end": 1772506030047
|
||
}
|
||
},
|
||
"id": "prt_cb1979fcc001hnqOREKQu7EkLV",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb1977df2002ADRTJ34ObEVSML"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 29770,
|
||
"input": 29447,
|
||
"output": 323,
|
||
"reasoning": 88,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb1979fe0001DrbiMVwpFlG4Zk",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb1977df2002ADRTJ34ObEVSML"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506030113,
|
||
"completed": 1772506035073
|
||
},
|
||
"parentID": "msg_cb1972fd9001q5a8C3Bm5amrRC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 34876,
|
||
"input": 34720,
|
||
"output": 156,
|
||
"reasoning": 57,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb197a021002Yajl4IpyZUEh7G",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"id": "prt_cb197ae97001XVWlBvfJLoUydR",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197a021002Yajl4IpyZUEh7G"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Investigating forward service config**",
|
||
"time": {
|
||
"start": 1772506033815,
|
||
"end": 1772506034981
|
||
},
|
||
"id": "prt_cb197ae97002cLpowAlHcFJqkv",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197a021002Yajl4IpyZUEh7G"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_9Hfp7cbPNmUdAUBG7XHmfxc4",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "syncForwardServices|UpdateService|buildForward|forward.*service|listener|addr|InIP|inIp",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go",
|
||
"include": "*.go"
|
||
},
|
||
"output": "Found 38 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go:\n Line 478: \tinIP := asString(req[\"inIp\"])\n Line 498: \t\tinIP = buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes, ipPreference)\n Line 549: \tvar tunnelInIP sql.NullString\n Line 551: \t\ttunnelInIP = sql.NullString{String: trimmed, Valid: true}\n Line 562: \t\tInIP: tunnelInIP,\n Line 695: \tinIp := buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes, ipPreference)\n Line 714: \t\tinIp,\n Line 759: \t\t\t_ = h.syncForwardServices(&forwards[i], \"UpdateService\", true)\n Line 970: \t\tif err := h.syncForwardServices(&forwards[i], \"UpdateService\", true); err != nil {\n Line 1181: \tinIp := strings.TrimSpace(asString(req[\"inIp\"]))\n Line 1182: \tforwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req[\"strategy\"]), \"fifo\"), now, inx, entryNodes, port, inIp, nullableInt(speedID))\n Line 1192: \tif err := h.syncForwardServices(createdForward, \"UpdateService\", true); err != nil {\n Line 1286: \tinIp := asString(req[\"inIp\"])\n Line 1303: \tif err := h.replaceForwardPorts(id, tunnelID, port, inIp); err != nil {\n Line 1314: \tif err := h.syncForwardServices(updatedForward, \"UpdateService\", true); err != nil {\n Line 1551: \t\tif err := h.syncForwardServices(forward, \"UpdateService\", true); err != nil {\n Line 1646: \t\tif err := h.syncForwardServices(updatedForward, \"UpdateService\", true); err != nil {\n Line 2119: func buildTunnelInIP(inNodes []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) string {\n Line 2130: \t\tvar addrs []string\n Line 2133: \t\t\t\taddrs = append(addrs, v6)\n Line 2136: \t\t\t\taddrs = append(addrs, v4)\n Line 2140: \t\t\t\taddrs = append(addrs, v4)\n Line 2143: \t\t\t\taddrs = append(addrs, v6)\n Line 2146: \t\tif len(addrs) == 0 {\n Line 2148: \t\t\t\taddrs = append(addrs, v)\n Line 2151: \t\tfor _, a := range addrs {\n Line 2605: \t\t\t\"addr\": processServerAddress(fmt.Sprintf(\"%s:%d\", host, port)),\n Line 2646: \t\t\"addr\": fmt.Sprintf(\"%s:%d\", node.TCPListenAddr, chainNode.Port),\n Line 2648: \t\t\"listener\": map[string]interface{}{\n Line 3017: func (h *Handler) replaceForwardPorts(forwardID, tunnelID int64, port int, inIp string) error {\n Line 3025: \t\tInIP string\n Line 3031: \t\t\tInIP string\n Line 3032: \t\t}{NodeID: nid, Port: port, InIP: inIp}\n Line 3041: \t\tInIP string\n Line 3047: \t\t\tInIP string\n Line 3048: \t\t}{NodeID: fp.NodeID, Port: fp.Port, InIP: fp.InIP}\n Line 3069: \t_ = h.syncForwardServices(oldForward, \"UpdateService\", true)\n Line 3214: \t\t\tif err := h.syncForwardServices(f, \"UpdateService\", true); err != nil {",
|
||
"title": "syncForwardServices|UpdateService|buildForward|forward.*service|listener|addr|InIP|inIp",
|
||
"metadata": {
|
||
"matches": 38,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772506034878,
|
||
"end": 1772506034896
|
||
}
|
||
},
|
||
"id": "prt_cb197af0b001lwD703iACdlQ9Y",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197a021002Yajl4IpyZUEh7G"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 34876,
|
||
"input": 34720,
|
||
"output": 156,
|
||
"reasoning": 57,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb197b328001c0RlXXWY2GAwZY",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197a021002Yajl4IpyZUEh7G"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506035074,
|
||
"completed": 1772506039475
|
||
},
|
||
"parentID": "msg_cb1972fd9001q5a8C3Bm5amrRC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 35840,
|
||
"input": 35708,
|
||
"output": 132,
|
||
"reasoning": 27,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb197b382002qZQKFDDGyIYsmj",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"id": "prt_cb197c0a2001L0oJx4bwZrpdlw",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197b382002qZQKFDDGyIYsmj"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Locating syncForwardServices implementation**",
|
||
"time": {
|
||
"start": 1772506038434,
|
||
"end": 1772506039383
|
||
},
|
||
"id": "prt_cb197c0a2002y2gp1lZ0R7lOuJ",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197b382002qZQKFDDGyIYsmj"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_fBvkaYV8nbLmiROx5tyCkYgl",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "func \\(h \\*Handler\\) syncForwardServices|syncForwardServices\\(|controlForwardServices\\(|buildForwardService|forward service|UpdateService",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler",
|
||
"include": "*.go"
|
||
},
|
||
"output": "Found 28 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go:\n Line 759: \t\t\t_ = h.syncForwardServices(&forwards[i], \"UpdateService\", true)\n Line 970: \t\tif err := h.syncForwardServices(&forwards[i], \"UpdateService\", true); err != nil {\n Line 1192: \tif err := h.syncForwardServices(createdForward, \"UpdateService\", true); err != nil {\n Line 1314: \tif err := h.syncForwardServices(updatedForward, \"UpdateService\", true); err != nil {\n Line 1336: \tif err := h.controlForwardServices(forward, \"DeleteService\", true); err != nil {\n Line 1365: \tif err := h.controlForwardServices(forward, \"PauseService\", false); err != nil {\n Line 1387: \tif err := h.controlForwardServices(forward, \"ResumeService\", false); err != nil {\n Line 1458: \t\tif err := h.controlForwardServices(forward, \"DeleteService\", true); err != nil {\n Line 1489: \t\tif err := h.controlForwardServices(forward, \"PauseService\", false); err != nil {\n Line 1520: \t\tif err := h.controlForwardServices(forward, \"ResumeService\", false); err != nil {\n Line 1551: \t\tif err := h.syncForwardServices(forward, \"UpdateService\", true); err != nil {\n Line 1646: \t\tif err := h.syncForwardServices(updatedForward, \"UpdateService\", true); err != nil {\n Line 3069: \t_ = h.syncForwardServices(oldForward, \"UpdateService\", true)\n Line 3214: \t\t\tif err := h.syncForwardServices(f, \"UpdateService\", true); err != nil {\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go:\n Line 225: func (h *Handler) syncForwardServices(forward *forwardRecord, method string, allowFallbackAdd bool) error {\n Line 267: \tserviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)\n Line 284: \t\tservices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID, tunnelTLSProtocol)\n Line 286: \t\tif err != nil && allowFallbackAdd && method == \"UpdateService\" {\n Line 296: func (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {\n Line 322: \tbases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)\n Line 1245: func buildForwardServiceBase(forwardID, userID, userTunnelID int64) string {\n Line 1249: func buildForwardServiceBaseCandidates(forwardID, userID, preferredUserTunnelID int64, userTunnelIDs []int64) []string {\n Line 1269: \t\tbases = append(bases, buildForwardServiceBase(forwardID, userID, id))\n Line 1306: func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/upgrade.go:\n Line 403: \t\tif err := h.syncForwardServices(forward, \"UpdateService\", true); err != nil {\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/flow_policy.go:\n Line 400: \t\t_ = h.controlForwardServices(&forward, \"PauseService\", false)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane_test.go:\n Line 30: \tgot := buildForwardServiceBaseCandidates(12, 34, 56, []int64{56, 78, 90})\n Line 38: \tgot := buildForwardServiceBaseCandidates(12, 34, 0, []int64{78, 0, 90})",
|
||
"title": "func \\(h \\*Handler\\) syncForwardServices|syncForwardServices\\(|controlForwardServices\\(|buildForwardService|forward service|UpdateService",
|
||
"metadata": {
|
||
"matches": 28,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772506039266,
|
||
"end": 1772506039284
|
||
}
|
||
},
|
||
"id": "prt_cb197c12700133nNLLew1ZjKAy",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197b382002qZQKFDDGyIYsmj"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 35840,
|
||
"input": 35708,
|
||
"output": 132,
|
||
"reasoning": 27,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb197c4590014LIyhpS0BWnasj",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197b382002qZQKFDDGyIYsmj"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506039476,
|
||
"completed": 1772506042765
|
||
},
|
||
"parentID": "msg_cb1972fd9001q5a8C3Bm5amrRC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 36946,
|
||
"input": 36871,
|
||
"output": 75,
|
||
"reasoning": 0,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb197c4b40029hC3e7bM03p3Uu",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"id": "prt_cb197cdd0001LR8luMa8uJEdMd",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197c4b40029hC3e7bM03p3Uu"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_ROr9ZU0SkJeshesLwOP1eqNO",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go",
|
||
"offset": 1300,
|
||
"limit": 140
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go</path>\n<type>file</type>\n<content>1300: \tif strings.Contains(msg, \"address already in use\") {\n1301: \t\treturn false\n1302: \t}\n1303: \treturn strings.Contains(msg, \"already exists\") || strings.Contains(msg, \"已存在\")\n1304: }\n1305: \n1306: func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {\n1307: \tprotocols := []string{\"tcp\", \"udp\"}\n1308: \tservices := make([]map[string]interface{}, 0, 2)\n1309: \ttargets := splitRemoteTargets(forward.RemoteAddr)\n1310: \tstrategy := strings.TrimSpace(forward.Strategy)\n1311: \tif strategy == \"\" {\n1312: \t\tstrategy = \"fifo\"\n1313: \t}\n1314: \n1315: \tfor _, protocol := range protocols {\n1316: \t\tlistenerAddr := node.TCPListenAddr\n1317: \t\tif protocol == \"udp\" {\n1318: \t\t\tlistenerAddr = node.UDPListenAddr\n1319: \t\t}\n1320: \t\tservice := map[string]interface{}{\n1321: \t\t\t\"name\": fmt.Sprintf(\"%s_%s\", baseName, protocol),\n1322: \t\t\t\"addr\": fmt.Sprintf(\"%s:%d\", listenerAddr, port),\n1323: \t\t\t\"handler\": map[string]interface{}{\n1324: \t\t\t\t\"type\": protocol,\n1325: \t\t\t},\n1326: \t\t\t\"listener\": map[string]interface{}{\n1327: \t\t\t\t\"type\": protocol,\n1328: \t\t\t},\n1329: \t\t\t\"forwarder\": map[string]interface{}{\n1330: \t\t\t\t\"nodes\": buildForwarderNodes(targets),\n1331: \t\t\t\t\"selector\": map[string]interface{}{\n1332: \t\t\t\t\t\"strategy\": strategy,\n1333: \t\t\t\t\t\"maxFails\": 1,\n1334: \t\t\t\t\t\"failTimeout\": \"600s\",\n1335: \t\t\t\t},\n1336: \t\t\t},\n1337: \t\t}\n1338: \t\tif protocol == \"udp\" {\n1339: \t\t\tlistenerMetadata := map[string]interface{}{\"keepAlive\": true}\n1340: \t\t\tif tunnelTLSProtocol {\n1341: \t\t\t\tlistenerMetadata[\"ttl\"] = \"10s\"\n1342: \t\t\t}\n1343: \t\t\tservice[\"listener\"].(map[string]interface{})[\"metadata\"] = listenerMetadata\n1344: \t\t}\n1345: \t\tif tunnel != nil && tunnel.Type == 2 {\n1346: \t\t\tservice[\"handler\"].(map[string]interface{})[\"chain\"] = fmt.Sprintf(\"chains_%d\", forward.TunnelID)\n1347: \t\t}\n1348: \t\tif tunnel != nil && tunnel.Type == 1 && strings.TrimSpace(node.InterfaceName) != \"\" {\n1349: \t\t\tservice[\"metadata\"] = map[string]interface{}{\"interface\": node.InterfaceName}\n1350: \t\t}\n1351: \t\tif limiterID != nil && *limiterID > 0 {\n1352: \t\t\tservice[\"limiter\"] = strconv.FormatInt(*limiterID, 10)\n1353: \t\t}\n1354: \t\tservices = append(services, service)\n1355: \t}\n1356: \n1357: \treturn services\n1358: }\n1359: \n1360: func buildForwarderNodes(targets []string) []map[string]interface{} {\n1361: \tnodes := make([]map[string]interface{}, 0, len(targets))\n1362: \tfor i, addr := range targets {\n1363: \t\tnodes = append(nodes, map[string]interface{}{\n1364: \t\t\t\"name\": fmt.Sprintf(\"node_%d\", i+1),\n1365: \t\t\t\"addr\": addr,\n1366: \t\t})\n1367: \t}\n1368: \treturn nodes\n1369: }\n1370: \n1371: func processServerAddress(serverAddr string) string {\n1372: \tserverAddr = strings.TrimSpace(serverAddr)\n1373: \tif serverAddr == \"\" {\n1374: \t\treturn serverAddr\n1375: \t}\n1376: \tif strings.HasPrefix(serverAddr, \"[\") {\n1377: \t\treturn serverAddr\n1378: \t}\n1379: \tidx := strings.LastIndex(serverAddr, \":\")\n1380: \tif idx < 0 {\n1381: \t\tif looksLikeIPv6(serverAddr) {\n1382: \t\t\treturn \"[\" + serverAddr + \"]\"\n1383: \t\t}\n1384: \t\treturn serverAddr\n1385: \t}\n1386: \thost := strings.TrimSpace(serverAddr[:idx])\n1387: \tport := strings.TrimSpace(serverAddr[idx+1:])\n1388: \tif host == \"\" || port == \"\" {\n1389: \t\treturn serverAddr\n1390: \t}\n1391: \tif looksLikeIPv6(host) {\n1392: \t\treturn \"[\" + host + \"]:\" + port\n1393: \t}\n1394: \treturn serverAddr\n1395: }\n1396: \n1397: func looksLikeIPv6(address string) bool {\n1398: \treturn strings.Count(address, \":\") >= 2\n1399: }\n1400: \n1401: func asBool(v interface{}, def bool) bool {\n1402: \ts := strings.TrimSpace(strings.ToLower(asString(v)))\n1403: \tif s == \"\" {\n1404: \t\treturn def\n1405: \t}\n1406: \tswitch s {\n1407: \tcase \"1\", \"t\", \"true\", \"yes\", \"y\":\n1408: \t\treturn true\n1409: \tcase \"0\", \"f\", \"false\", \"no\", \"n\":\n1410: \t\treturn false\n1411: \tdefault:\n1412: \t\treturn def\n1413: \t}\n1414: }\n1415: \n1416: func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) error {\n1417: \tif err := h.upsertLimiterOnNode(nodeID, limiterID, speed); err != nil {\n1418: \t\treturn fmt.Errorf(\"限速规则下发失败: %w\", err)\n1419: \t}\n1420: \n1421: \treturn nil\n1422: }\n1423: \n1424: func buildLimiterAddPayload(limiterID int64, speed int) (string, map[string]interface{}) {\n1425: \trate := float64(speed) / 8.0\n1426: \tlimitStr := fmt.Sprintf(\"$ %.1fMB %.1fMB\", rate, rate)\n1427: \tname := strconv.FormatInt(limiterID, 10)\n1428: \n1429: \treturn name, map[string]interface{}{\n1430: \t\t\"name\": name,\n1431: \t\t\"limits\": []string{limitStr},\n1432: \t}\n1433: }\n1434: \n1435: func buildLimiterUpdatePayload(name string, data map[string]interface{}) map[string]interface{} {\n1436: \treturn map[string]interface{}{\n1437: \t\t\"limiter\": name,\n1438: \t\t\"data\": data,\n1439: \t}\n\n(Showing lines 1300-1439 of 1458. Use offset=1440 to continue.)\n</content>",
|
||
"title": "go-backend/internal/http/handler/control_plane.go",
|
||
"metadata": {
|
||
"preview": "\tif strings.Contains(msg, \"address already in use\") {\n\t\treturn false\n\t}\n\treturn strings.Contains(msg, \"already exists\") || strings.Contains(msg, \"已存在\")\n}\n\nfunc buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {\n\tprotocols := []string{\"tcp\", \"udp\"}\n\tservices := make([]map[string]interface{}, 0, 2)\n\ttargets := splitRemoteTargets(forward.RemoteAddr)\n\tstrategy := strings.TrimSpace(forward.Strategy)\n\tif strategy == \"\" {\n\t\tstrategy = \"fifo\"\n\t}\n\n\tfor _, protocol := range protocols {\n\t\tlistenerAddr := node.TCPListenAddr\n\t\tif protocol == \"udp\" {\n\t\t\tlistenerAddr = node.UDPListenAddr\n\t\t}",
|
||
"truncated": true,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772506042563,
|
||
"end": 1772506042569
|
||
}
|
||
},
|
||
"id": "prt_cb197cdd1001jOfTZdphT1CKsi",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197c4b40029hC3e7bM03p3Uu"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 36946,
|
||
"input": 36871,
|
||
"output": 75,
|
||
"reasoning": 0,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb197d131001h8Heq2avCZMKeK",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197c4b40029hC3e7bM03p3Uu"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506042766,
|
||
"completed": 1772506050507
|
||
},
|
||
"parentID": "msg_cb1972fd9001q5a8C3Bm5amrRC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 38708,
|
||
"input": 24781,
|
||
"output": 103,
|
||
"reasoning": 27,
|
||
"cache": {
|
||
"read": 13824,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb197d18e0022sHBD0AOoNqemd",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"id": "prt_cb197ece5001EPQmLpf3hesMIq",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197d18e0022sHBD0AOoNqemd"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Inspecting forwardRecord usage**",
|
||
"time": {
|
||
"start": 1772506049766,
|
||
"end": 1772506050436
|
||
},
|
||
"id": "prt_cb197ece60019FOzqe2vkID2Kf",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197d18e0022sHBD0AOoNqemd"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_kEciVVFU6yKpnj7DjfRRnZ2x",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go",
|
||
"offset": 200,
|
||
"limit": 140
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go</path>\n<type>file</type>\n<content>200: \tif n == nil {\n201: \t\treturn nil, errors.New(\"节点不存在\")\n202: \t}\n203: \treturn n, nil\n204: }\n205: \n206: func (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *int64, *int, error) {\n207: \tinfo, err := h.repo.ResolveUserTunnelAndLimiter(userID, tunnelID)\n208: \tif err != nil {\n209: \t\treturn 0, nil, nil, err\n210: \t}\n211: \tif info == nil {\n212: \t\treturn 0, nil, nil, nil\n213: \t}\n214: \treturn info.UserTunnelID, info.LimiterID, info.Speed, nil\n215: }\n216: \n217: func (h *Handler) listUserTunnelIDs(userID, tunnelID int64) ([]int64, error) {\n218: \treturn h.repo.ListUserTunnelIDs(userID, tunnelID)\n219: }\n220: \n221: func (h *Handler) listUserTunnelIDsByUser(userID int64) ([]int64, error) {\n222: \treturn h.repo.ListUserTunnelIDsByUser(userID)\n223: }\n224: \n225: func (h *Handler) syncForwardServices(forward *forwardRecord, method string, allowFallbackAdd bool) error {\n226: \tif h == nil || forward == nil {\n227: \t\treturn errors.New(\"invalid forward sync context\")\n228: \t}\n229: \n230: \ttunnel, err := h.getTunnelRecord(forward.TunnelID)\n231: \tif err != nil {\n232: \t\treturn err\n233: \t}\n234: \tports, err := h.listForwardPorts(forward.ID)\n235: \tif err != nil {\n236: \t\treturn err\n237: \t}\n238: \tif len(ports) == 0 {\n239: \t\treturn errors.New(\"转发入口端口不存在\")\n240: \t}\n241: \n242: \t// Determine limiter from forward's SpeedID first, fallback to UserTunnel's limiter\n243: \tvar limiterID *int64\n244: \tvar speed *int\n245: \n246: \tif forward.SpeedID.Valid && forward.SpeedID.Int64 > 0 {\n247: \t\t// Forward has its own speed limit\n248: \t\tspeedVal, err := h.repo.GetSpeedLimitSpeed(forward.SpeedID.Int64)\n249: \t\tif err == nil && speedVal > 0 {\n250: \t\t\tlimiterID = &forward.SpeedID.Int64\n251: \t\t\tspeed = &speedVal\n252: \t\t}\n253: \t}\n254: \n255: \tif limiterID == nil {\n256: \t\t// Fall back to UserTunnel speed limit\n257: \t\tvar utLimiterID *int64\n258: \t\tvar utSpeed *int\n259: \t\t_, utLimiterID, utSpeed, err = h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)\n260: \t\tif err != nil {\n261: \t\t\treturn err\n262: \t\t}\n263: \t\tlimiterID = utLimiterID\n264: \t\tspeed = utSpeed\n265: \t}\n266: \n267: \tserviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)\n268: \ttunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)\n269: \tif err != nil {\n270: \t\treturn err\n271: \t}\n272: \n273: \tfor _, fp := range ports {\n274: \t\tif limiterID != nil && speed != nil {\n275: \t\t\tif err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {\n276: \t\t\t\treturn err\n277: \t\t\t}\n278: \t\t}\n279: \n280: \t\tnode, err := h.getNodeRecord(fp.NodeID)\n281: \t\tif err != nil {\n282: \t\t\treturn err\n283: \t\t}\n284: \t\tservices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID, tunnelTLSProtocol)\n285: \t\t_, err = h.sendNodeCommand(node.ID, method, services, true, false)\n286: \t\tif err != nil && allowFallbackAdd && method == \"UpdateService\" {\n287: \t\t\t_, err = h.sendNodeCommand(node.ID, \"AddService\", services, true, false)\n288: \t\t}\n289: \t\tif err != nil {\n290: \t\t\treturn fmt.Errorf(\"节点 %s 下发失败: %w\", node.Name, err)\n291: \t\t}\n292: \t}\n293: \treturn nil\n294: }\n295: \n296: func (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {\n297: \tif h == nil || forward == nil {\n298: \t\treturn errors.New(\"invalid forward control context\")\n299: \t}\n300: \tports, err := h.listForwardPorts(forward.ID)\n301: \tif err != nil {\n302: \t\treturn err\n303: \t}\n304: \tif len(ports) == 0 {\n305: \t\treturn nil\n306: \t}\n307: \tuserTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)\n308: \tif err != nil {\n309: \t\treturn err\n310: \t}\n311: \tuserTunnelIDs, err := h.listUserTunnelIDs(forward.UserID, forward.TunnelID)\n312: \tif err != nil {\n313: \t\treturn err\n314: \t}\n315: \tallUserTunnelIDs, err := h.listUserTunnelIDsByUser(forward.UserID)\n316: \tif err != nil {\n317: \t\treturn err\n318: \t}\n319: \tcandidateTunnelIDs := make([]int64, 0, len(userTunnelIDs)+len(allUserTunnelIDs))\n320: \tcandidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)\n321: \tcandidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)\n322: \tbases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)\n323: \tseen := map[int64]struct{}{}\n324: \tfor _, fp := range ports {\n325: \t\tif _, ok := seen[fp.NodeID]; ok {\n326: \t\t\tcontinue\n327: \t\t}\n328: \t\tseen[fp.NodeID] = struct{}{}\n329: \n330: \t\tvar lastNotFoundErr error\n331: \t\tnodeHandled := false\n332: \n333: \t\tfor _, base := range bases {\n334: \t\t\tvariants := []string{base + \"_tcp\", base + \"_udp\"}\n335: \t\t\tif shouldTryLegacySingleService(commandType) || strings.EqualFold(strings.TrimSpace(commandType), \"DeleteService\") {\n336: \t\t\t\tvariants = append(variants, base)\n337: \t\t\t}\n338: \n339: \t\t\tcandidateHandled := false\n\n(Showing lines 200-339 of 1458. Use offset=340 to continue.)\n</content>",
|
||
"title": "go-backend/internal/http/handler/control_plane.go",
|
||
"metadata": {
|
||
"preview": "\tif n == nil {\n\t\treturn nil, errors.New(\"节点不存在\")\n\t}\n\treturn n, nil\n}\n\nfunc (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *int64, *int, error) {\n\tinfo, err := h.repo.ResolveUserTunnelAndLimiter(userID, tunnelID)\n\tif err != nil {\n\t\treturn 0, nil, nil, err\n\t}\n\tif info == nil {\n\t\treturn 0, nil, nil, nil\n\t}\n\treturn info.UserTunnelID, info.LimiterID, info.Speed, nil\n}\n\nfunc (h *Handler) listUserTunnelIDs(userID, tunnelID int64) ([]int64, error) {\n\treturn h.repo.ListUserTunnelIDs(userID, tunnelID)\n}",
|
||
"truncated": true,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772506050412,
|
||
"end": 1772506050417
|
||
}
|
||
},
|
||
"id": "prt_cb197ee91001GeZPlcXmlYbLhc",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197d18e0022sHBD0AOoNqemd"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 38708,
|
||
"input": 24781,
|
||
"output": 103,
|
||
"reasoning": 27,
|
||
"cache": {
|
||
"read": 13824,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb197ef850010OxsIpx4HChNB1",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197d18e0022sHBD0AOoNqemd"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506050508,
|
||
"completed": 1772506055891
|
||
},
|
||
"parentID": "msg_cb1972fd9001q5a8C3Bm5amrRC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 40398,
|
||
"input": 40296,
|
||
"output": 102,
|
||
"reasoning": 25,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb197efcc002tkPForz4g3lSnL",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"id": "prt_cb19801b4001JUKKimpCUd4owf",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197efcc002tkPForz4g3lSnL"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Inspecting port forwarding struct usage**",
|
||
"time": {
|
||
"start": 1772506055092,
|
||
"end": 1772506055803
|
||
},
|
||
"id": "prt_cb19801b4002RB8NcdJJzMFTsL",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197efcc002tkPForz4g3lSnL"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_ZfhlonDJCVzH8nxuh5M93mqS",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_control.go",
|
||
"offset": 70,
|
||
"limit": 90
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_control.go</path>\n<type>file</type>\n<content>70: \t\tPluck(\"chain_tunnel.tunnel_id\", &ids).Error\n71: \tif err != nil {\n72: \t\treturn nil, err\n73: \t}\n74: \treturn ids, nil\n75: }\n76: \n77: func (r *Repository) ListActiveForwardIDsByNode(nodeID int64) ([]int64, error) {\n78: \tif r == nil || r.db == nil {\n79: \t\treturn nil, errors.New(\"repository not initialized\")\n80: \t}\n81: \tvar ids []int64\n82: \terr := r.db.Model(&model.ForwardPort{}).\n83: \t\tJoins(\"JOIN forward ON forward.id = forward_port.forward_id\").\n84: \t\tWhere(\"forward_port.node_id = ? AND forward.status = 1\", nodeID).\n85: \t\tSelect(\"DISTINCT forward_port.forward_id\").\n86: \t\tOrder(\"forward_port.forward_id ASC\").\n87: \t\tPluck(\"forward_port.forward_id\", &ids).Error\n88: \tif err != nil {\n89: \t\treturn nil, err\n90: \t}\n91: \treturn ids, nil\n92: }\n93: \n94: func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecord, error) {\n95: \tif r == nil || r.db == nil {\n96: \t\treturn nil, errors.New(\"repository not initialized\")\n97: \t}\n98: \tvar ports []model.ForwardPort\n99: \terr := r.db.Where(\"forward_id = ?\", forwardID).Order(\"id ASC\").Find(&ports).Error\n100: \tif err != nil {\n101: \t\treturn nil, err\n102: \t}\n103: \trows := make([]model.ForwardPortRecord, 0, len(ports))\n104: \tfor _, p := range ports {\n105: \t\tinIP := \"\"\n106: \t\tif p.InIP.Valid {\n107: \t\t\tinIP = p.InIP.String\n108: \t\t}\n109: \t\trows = append(rows, model.ForwardPortRecord{NodeID: p.NodeID, Port: p.Port, InIP: inIP})\n110: \t}\n111: \treturn rows, nil\n112: }\n113: \n114: func (r *Repository) GetTunnelOutProtocol(tunnelID int64) (string, error) {\n115: \tif r == nil || r.db == nil {\n116: \t\treturn \"\", errors.New(\"repository not initialized\")\n117: \t}\n118: \tvar ct model.ChainTunnel\n119: \terr := r.db.Select(\"protocol\").\n120: \t\tWhere(\"tunnel_id = ? AND chain_type = ?\", tunnelID, \"3\").\n121: \t\tOrder(\"id ASC\").\n122: \t\tTake(&ct).Error\n123: \tif err != nil {\n124: \t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n125: \t\t\treturn \"\", nil\n126: \t\t}\n127: \t\treturn \"\", err\n128: \t}\n129: \tif ct.Protocol.Valid {\n130: \t\treturn ct.Protocol.String, nil\n131: \t}\n132: \treturn \"\", nil\n133: }\n134: \n135: func (r *Repository) GetNodeRecord(nodeID int64) (*model.NodeRecord, error) {\n136: \tif r == nil || r.db == nil {\n137: \t\treturn nil, errors.New(\"repository not initialized\")\n138: \t}\n139: \tvar n model.Node\n140: \terr := r.db.Where(\"id = ?\", nodeID).First(&n).Error\n141: \tif errors.Is(err, gorm.ErrRecordNotFound) {\n142: \t\treturn nil, nil\n143: \t}\n144: \tif err != nil {\n145: \t\treturn nil, err\n146: \t}\n147: \treturn nodeRecordFromModel(&n), nil\n148: }\n149: \n150: func (r *Repository) GetNodeRecordTx(tx *gorm.DB, nodeID int64) (*model.NodeRecord, error) {\n151: \tif tx == nil {\n152: \t\treturn nil, errors.New(\"database unavailable\")\n153: \t}\n154: \tvar n model.Node\n155: \terr := tx.Where(\"id = ?\", nodeID).First(&n).Error\n156: \tif errors.Is(err, gorm.ErrRecordNotFound) {\n157: \t\treturn nil, nil\n158: \t}\n159: \tif err != nil {\n\n(Showing lines 70-159 of 354. Use offset=160 to continue.)\n</content>",
|
||
"title": "go-backend/internal/store/repo/repository_control.go",
|
||
"metadata": {
|
||
"preview": "\t\tPluck(\"chain_tunnel.tunnel_id\", &ids).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\nfunc (r *Repository) ListActiveForwardIDsByNode(nodeID int64) ([]int64, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ids []int64\n\terr := r.db.Model(&model.ForwardPort{}).\n\t\tJoins(\"JOIN forward ON forward.id = forward_port.forward_id\").\n\t\tWhere(\"forward_port.node_id = ? AND forward.status = 1\", nodeID).\n\t\tSelect(\"DISTINCT forward_port.forward_id\").\n\t\tOrder(\"forward_port.forward_id ASC\").\n\t\tPluck(\"forward_port.forward_id\", &ids).Error\n\tif err != nil {\n\t\treturn nil, err",
|
||
"truncated": true,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772506055682,
|
||
"end": 1772506055687
|
||
}
|
||
},
|
||
"id": "prt_cb19801fb0017F2plevT7pCfEs",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197efcc002tkPForz4g3lSnL"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 40398,
|
||
"input": 40296,
|
||
"output": 102,
|
||
"reasoning": 25,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb198047d001WNcyRpcsjT3EdB",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb197efcc002tkPForz4g3lSnL"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506055892,
|
||
"completed": 1772506062356
|
||
},
|
||
"parentID": "msg_cb1972fd9001q5a8C3Bm5amrRC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 41649,
|
||
"input": 41420,
|
||
"output": 229,
|
||
"reasoning": 57,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb19804d40021iFUjo0D1YsDJr",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"id": "prt_cb198160a0013YJTpF4mkMx7Sj",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19804d40021iFUjo0D1YsDJr"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Investigating listener address inconsistency**",
|
||
"time": {
|
||
"start": 1772506060298,
|
||
"end": 1772506062280
|
||
},
|
||
"id": "prt_cb198160a002vrVYc82WzN5ngN",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19804d40021iFUjo0D1YsDJr"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_sSnjC94oiZbTMwurDtsNAplS",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "type forwardPortRecord|ForwardPortRecord struct|InIP",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler",
|
||
"include": "*.go"
|
||
},
|
||
"output": "Found 14 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go:\n Line 498: \t\tinIP = buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes, ipPreference)\n Line 549: \tvar tunnelInIP sql.NullString\n Line 551: \t\ttunnelInIP = sql.NullString{String: trimmed, Valid: true}\n Line 562: \t\tInIP: tunnelInIP,\n Line 695: \tinIp := buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes, ipPreference)\n Line 2119: func buildTunnelInIP(inNodes []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) string {\n Line 3025: \t\tInIP string\n Line 3031: \t\t\tInIP string\n Line 3032: \t\t}{NodeID: nid, Port: port, InIP: inIp}\n Line 3041: \t\tInIP string\n Line 3047: \t\t\tInIP string\n Line 3048: \t\t}{NodeID: fp.NodeID, Port: fp.Port, InIP: fp.InIP}\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go:\n Line 24: type forwardPortRecord = model.ForwardPortRecord\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/handler.go:\n Line 925: \t\t\t\"inIp\": f.InIP,",
|
||
"title": "type forwardPortRecord|ForwardPortRecord struct|InIP",
|
||
"metadata": {
|
||
"matches": 14,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772506062214,
|
||
"end": 1772506062229
|
||
}
|
||
},
|
||
"id": "prt_cb1981d7d001KUVQBX6uFEQRK1",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19804d40021iFUjo0D1YsDJr"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_LfeBw419MLIFZcTQuKnEySlF",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go",
|
||
"offset": 1180,
|
||
"limit": 120
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go</path>\n<type>file</type>\n<content>1180: }\n1181: \n1182: func (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {\n1183: \tif node == nil {\n1184: \t\treturn nil, errors.New(\"节点不存在\")\n1185: \t}\n1186: \tremoteURL := strings.TrimSpace(node.RemoteURL)\n1187: \tremoteToken := strings.TrimSpace(node.RemoteToken)\n1188: \tif remoteURL == \"\" || remoteToken == \"\" {\n1189: \t\treturn nil, errors.New(\"远程节点缺少共享配置\")\n1190: \t}\n1191: \tif options.commandTimeout <= 0 {\n1192: \t\toptions.commandTimeout = diagnosisCommandTimeout\n1193: \t}\n1194: \tif options.pingTimeoutMS <= 0 {\n1195: \t\toptions.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)\n1196: \t}\n1197: \n1198: \tfc := client.NewFederationClientWithTimeout(options.commandTimeout)\n1199: \treturn fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{\n1200: \t\tIP: strings.TrimSpace(ip),\n1201: \t\tPort: port,\n1202: \t\tCount: 4,\n1203: \t\tTimeout: options.pingTimeoutMS,\n1204: \t})\n1205: }\n1206: \n1207: func splitRemoteTargets(remoteAddr string) []string {\n1208: \tparts := strings.Split(remoteAddr, \",\")\n1209: \tout := make([]string, 0, len(parts))\n1210: \tfor _, part := range parts {\n1211: \t\tpart = strings.TrimSpace(part)\n1212: \t\tif part == \"\" {\n1213: \t\t\tcontinue\n1214: \t\t}\n1215: \t\tout = append(out, processServerAddress(part))\n1216: \t}\n1217: \treturn out\n1218: }\n1219: \n1220: func parseTargetAddress(addr string) (string, int, error) {\n1221: \taddr = strings.TrimSpace(addr)\n1222: \tif addr == \"\" {\n1223: \t\treturn \"\", 0, errors.New(\"empty address\")\n1224: \t}\n1225: \thost, portStr, err := net.SplitHostPort(addr)\n1226: \tif err != nil {\n1227: \t\tidx := strings.LastIndex(addr, \":\")\n1228: \t\tif idx <= 0 || idx >= len(addr)-1 {\n1229: \t\t\treturn \"\", 0, err\n1230: \t\t}\n1231: \t\thost = strings.TrimSpace(addr[:idx])\n1232: \t\tportStr = strings.TrimSpace(addr[idx+1:])\n1233: \t}\n1234: \tport, err := strconv.Atoi(strings.TrimSpace(portStr))\n1235: \tif err != nil || port <= 0 || port > 65535 {\n1236: \t\treturn \"\", 0, errors.New(\"invalid port\")\n1237: \t}\n1238: \thost = strings.Trim(strings.TrimSpace(host), \"[]\")\n1239: \tif host == \"\" {\n1240: \t\treturn \"\", 0, errors.New(\"invalid host\")\n1241: \t}\n1242: \treturn host, port, nil\n1243: }\n1244: \n1245: func buildForwardServiceBase(forwardID, userID, userTunnelID int64) string {\n1246: \treturn fmt.Sprintf(\"%d_%d_%d\", forwardID, userID, userTunnelID)\n1247: }\n1248: \n1249: func buildForwardServiceBaseCandidates(forwardID, userID, preferredUserTunnelID int64, userTunnelIDs []int64) []string {\n1250: \torderedIDs := make([]int64, 0, len(userTunnelIDs)+2)\n1251: \tseen := make(map[int64]struct{}, len(userTunnelIDs)+2)\n1252: \n1253: \tappendID := func(id int64) {\n1254: \t\tif _, ok := seen[id]; ok {\n1255: \t\t\treturn\n1256: \t\t}\n1257: \t\tseen[id] = struct{}{}\n1258: \t\torderedIDs = append(orderedIDs, id)\n1259: \t}\n1260: \n1261: \tappendID(preferredUserTunnelID)\n1262: \tfor _, id := range userTunnelIDs {\n1263: \t\tappendID(id)\n1264: \t}\n1265: \tappendID(0)\n1266: \n1267: \tbases := make([]string, 0, len(orderedIDs))\n1268: \tfor _, id := range orderedIDs {\n1269: \t\tbases = append(bases, buildForwardServiceBase(forwardID, userID, id))\n1270: \t}\n1271: \treturn bases\n1272: }\n1273: \n1274: func buildForwardControlServiceNames(base, commandType string) []string {\n1275: \tnames := []string{base + \"_tcp\", base + \"_udp\"}\n1276: \tif strings.EqualFold(strings.TrimSpace(commandType), \"DeleteService\") {\n1277: \t\treturn append([]string{base}, names...)\n1278: \t}\n1279: \treturn names\n1280: }\n1281: \n1282: func shouldTryLegacySingleService(commandType string) bool {\n1283: \tcmd := strings.ToLower(strings.TrimSpace(commandType))\n1284: \treturn cmd == \"pauseservice\" || cmd == \"resumeservice\"\n1285: }\n1286: \n1287: func isNotFoundError(err error) bool {\n1288: \tif err == nil {\n1289: \t\treturn false\n1290: \t}\n1291: \tmsg := strings.ToLower(strings.TrimSpace(err.Error()))\n1292: \treturn strings.Contains(msg, \"not found\") || strings.Contains(msg, \"不存在\")\n1293: }\n1294: \n1295: func isAlreadyExistsMessage(message string) bool {\n1296: \tmsg := strings.ToLower(strings.TrimSpace(message))\n1297: \tif msg == \"\" {\n1298: \t\treturn false\n1299: \t}\n\n(Showing lines 1180-1299 of 1458. Use offset=1300 to continue.)\n</content>",
|
||
"title": "go-backend/internal/http/handler/control_plane.go",
|
||
"metadata": {
|
||
"preview": "}\n\nfunc (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {\n\tif node == nil {\n\t\treturn nil, errors.New(\"节点不存在\")\n\t}\n\tremoteURL := strings.TrimSpace(node.RemoteURL)\n\tremoteToken := strings.TrimSpace(node.RemoteToken)\n\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\treturn nil, errors.New(\"远程节点缺少共享配置\")\n\t}\n\tif options.commandTimeout <= 0 {\n\t\toptions.commandTimeout = diagnosisCommandTimeout\n\t}\n\tif options.pingTimeoutMS <= 0 {\n\t\toptions.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)\n\t}\n\n\tfc := client.NewFederationClientWithTimeout(options.commandTimeout)\n\treturn fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{",
|
||
"truncated": true,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772506062245,
|
||
"end": 1772506062251
|
||
}
|
||
},
|
||
"id": "prt_cb1981d97001YdDtmPd2jbc6br",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19804d40021iFUjo0D1YsDJr"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 41649,
|
||
"input": 41420,
|
||
"output": 229,
|
||
"reasoning": 57,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb1981dca001iZI6GQszmD37ol",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19804d40021iFUjo0D1YsDJr"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506062357,
|
||
"completed": 1772506072968
|
||
},
|
||
"parentID": "msg_cb1972fd9001q5a8C3Bm5amrRC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 44009,
|
||
"input": 43466,
|
||
"output": 543,
|
||
"reasoning": 349,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb1981e15002CnoomqyDnJfzTY",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"id": "prt_cb198418c001tTIgK9MrEsYwaR",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb1981e15002CnoomqyDnJfzTY"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Planning IP bind override**",
|
||
"time": {
|
||
"start": 1772506071437,
|
||
"end": 1772506072909
|
||
},
|
||
"id": "prt_cb198418d001Xtu19Z9bzLnQ4z",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb1981e15002CnoomqyDnJfzTY"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_V377Bwrdi7CMjuooJQkXNfhk",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "type tunnelRuntimeNode|ConnectIP|connectIp|buildTunnelChainServiceConfig|buildTunnelChainConfig|replaceTunnelChainsTx|CreateChainTunnelTx",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go",
|
||
"include": "*.go"
|
||
},
|
||
"output": "Found 25 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go:\n Line 580: \tif err := h.replaceTunnelChainsTx(tx, tunnelID, req); err != nil {\n Line 726: \tif err := h.replaceTunnelChainsTx(tx, id, req); err != nil {\n Line 1954: type tunnelRuntimeNode struct {\n Line 1961: \tConnectIP string\n Line 2035: \t\t\t\tConnectIP: asString(item[\"connectIp\"]),\n Line 2071: \t\t\t\t\tConnectIP: asString(item[\"connectIp\"]),\n Line 2348: \t\t\t\thost, hostErr := selectTunnelDialHost(node, targetNode, state.IPPreference, target.ConnectIP)\n Line 2477: \t\tchainData, err := buildTunnelChainConfig(state.TunnelID, inNode.NodeID, targets, state.Nodes, state.IPPreference)\n Line 2499: \t\t\tchainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)\n Line 2508: \t\t\tserviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID])\n Line 2520: \t\tserviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID])\n Line 2574: func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) (map[string]interface{}, error) {\n Line 2588: \t\thost, err := selectTunnelDialHost(fromNode, targetNode, ipPreference, target.ConnectIP)\n Line 2633: func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode, node *nodeRecord) []map[string]interface{} {\n Line 2661: func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, connectIp string) (string, error) {\n Line 2665: \tif strings.TrimSpace(connectIp) != \"\" {\n Line 2666: \t\treturn strings.TrimSpace(connectIp), nil\n Line 2785: func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[string]interface{}) error {\n Line 2793: \t\tif err := h.repo.CreateChainTunnelTx(\n Line 2820: \t\tconnectIp := asString(n[\"connectIp\"])\n Line 2821: \t\tif err := h.repo.CreateChainTunnelTx(\n Line 2830: \t\t\tconnectIp,\n Line 2850: \t\t\tconnectIp := asString(n[\"connectIp\"])\n Line 2851: \t\t\tif err := h.repo.CreateChainTunnelTx(\n Line 2860: \t\t\t\tconnectIp,",
|
||
"title": "type tunnelRuntimeNode|ConnectIP|connectIp|buildTunnelChainServiceConfig|buildTunnelChainConfig|replaceTunnelChainsTx|CreateChainTunnelTx",
|
||
"metadata": {
|
||
"matches": 25,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772506072879,
|
||
"end": 1772506072889
|
||
}
|
||
},
|
||
"id": "prt_cb1984729001AZFib0QB9kzBAw",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb1981e15002CnoomqyDnJfzTY"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_7MSOcdwZy29QaQjNQsgmuN8i",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go",
|
||
"offset": 2180,
|
||
"limit": 360
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go</path>\n<type>file</type>\n<content>2180: \t\t}\n2181: \t}\n2182: \tfor _, hopRaw := range asAnySlice(req[\"chainNodes\"]) {\n2183: \t\tfor _, item := range asMapSlice(hopRaw) {\n2184: \t\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n2185: \t\t\tif port, ok := chainPorts[nodeID]; ok && port > 0 {\n2186: \t\t\t\titem[\"port\"] = port\n2187: \t\t\t}\n2188: \t\t}\n2189: \t}\n2190: }\n2191: \n2192: type federationRuntimeReleaseRef struct {\n2193: \tRemoteURL string\n2194: \tRemoteToken string\n2195: \tBindingID string\n2196: \tReservationID string\n2197: \tResourceKey string\n2198: }\n2199: \n2200: func federationRuntimeResourceKey(tunnelID int64, nodeID int64, chainType int, hopInx int) string {\n2201: \treturn fmt.Sprintf(\"tunnel:%d:node:%d:type:%d:hop:%d\", tunnelID, nodeID, chainType, hopInx)\n2202: }\n2203: \n2204: func remoteShareIDFromConfig(raw string) int64 {\n2205: \traw = strings.TrimSpace(raw)\n2206: \tif raw == \"\" {\n2207: \t\treturn 0\n2208: \t}\n2209: \tvar cfg map[string]interface{}\n2210: \tif err := json.Unmarshal([]byte(raw), &cfg); err != nil {\n2211: \t\treturn 0\n2212: \t}\n2213: \treturn asInt64(cfg[\"shareId\"], 0)\n2214: }\n2215: \n2216: func (h *Handler) federationLocalDomain() string {\n2217: \tcfg, _ := h.repo.GetConfigByName(\"panel_domain\")\n2218: \tif cfg == nil {\n2219: \t\treturn \"\"\n2220: \t}\n2221: \treturn strings.TrimSpace(cfg.Value)\n2222: }\n2223: \n2224: func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain string) ([]repo.FederationTunnelBinding, []federationRuntimeReleaseRef, error) {\n2225: \tbindings := make([]repo.FederationTunnelBinding, 0)\n2226: \treleaseRefs := make([]federationRuntimeReleaseRef, 0)\n2227: \tif h == nil || state == nil {\n2228: \t\treturn bindings, releaseRefs, nil\n2229: \t}\n2230: \tfc := client.NewFederationClient()\n2231: \tnow := time.Now().UnixMilli()\n2232: \n2233: \tfor outIdx := range state.OutNodes {\n2234: \t\toutNode := state.OutNodes[outIdx]\n2235: \t\tnode := state.Nodes[outNode.NodeID]\n2236: \t\tif node == nil || node.IsRemote != 1 {\n2237: \t\t\tcontinue\n2238: \t\t}\n2239: \t\tremoteURL := strings.TrimSpace(node.RemoteURL)\n2240: \t\tremoteToken := strings.TrimSpace(node.RemoteToken)\n2241: \t\tif remoteURL == \"\" || remoteToken == \"\" {\n2242: \t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n2243: \t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 缺少共享配置\", nodeDisplayName(node))\n2244: \t\t}\n2245: \n2246: \t\tresourceKey := federationRuntimeResourceKey(state.TunnelID, outNode.NodeID, 3, 0)\n2247: \t\treserveReq := client.RuntimeReservePortRequest{\n2248: \t\t\tResourceKey: resourceKey,\n2249: \t\t\tProtocol: defaultString(outNode.Protocol, \"tls\"),\n2250: \t\t\tRequestedPort: outNode.Port,\n2251: \t\t}\n2252: \t\treserveRes, err := fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n2253: \t\tif err != nil && reserveReq.RequestedPort > 0 {\n2254: \t\t\treserveReq.RequestedPort = 0\n2255: \t\t\treserveRes, err = fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n2256: \t\t}\n2257: \t\tif err != nil {\n2258: \t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n2259: \t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 端口分配失败: %w\", nodeDisplayName(node), err)\n2260: \t\t}\n2261: \n2262: \t\tstate.OutNodes[outIdx].Port = reserveRes.AllocatedPort\n2263: \t\toutNode = state.OutNodes[outIdx]\n2264: \n2265: \t\tapplyReq := client.RuntimeApplyRoleRequest{\n2266: \t\t\tReservationID: reserveRes.ReservationID,\n2267: \t\t\tResourceKey: resourceKey,\n2268: \t\t\tRole: \"exit\",\n2269: \t\t\tProtocol: defaultString(outNode.Protocol, \"tls\"),\n2270: \t\t\tStrategy: defaultString(outNode.Strategy, \"round\"),\n2271: \t\t}\n2272: \t\tapplyRes, err := fc.ApplyRole(remoteURL, remoteToken, localDomain, applyReq)\n2273: \t\tif err != nil {\n2274: \t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n2275: \t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 运行时下发失败: %w\", nodeDisplayName(node), err)\n2276: \t\t}\n2277: \t\tif applyRes.AllocatedPort > 0 {\n2278: \t\t\tstate.OutNodes[outIdx].Port = applyRes.AllocatedPort\n2279: \t\t\toutNode = state.OutNodes[outIdx]\n2280: \t\t}\n2281: \n2282: \t\tbindings = append(bindings, repo.FederationTunnelBinding{\n2283: \t\t\tTunnelID: state.TunnelID,\n2284: \t\t\tNodeID: outNode.NodeID,\n2285: \t\t\tChainType: 3,\n2286: \t\t\tHopInx: 0,\n2287: \t\t\tRemoteURL: remoteURL,\n2288: \t\t\tResourceKey: resourceKey,\n2289: \t\t\tRemoteBindingID: defaultString(applyRes.BindingID, reserveRes.BindingID),\n2290: \t\t\tAllocatedPort: outNode.Port,\n2291: \t\t\tStatus: 1,\n2292: \t\t\tCreatedTime: now,\n2293: \t\t\tUpdatedTime: now,\n2294: \t\t})\n2295: \t\treleaseRefs = append(releaseRefs, federationRuntimeReleaseRef{\n2296: \t\t\tRemoteURL: remoteURL,\n2297: \t\t\tRemoteToken: remoteToken,\n2298: \t\t\tBindingID: applyRes.BindingID,\n2299: \t\t\tReservationID: reserveRes.ReservationID,\n2300: \t\t\tResourceKey: resourceKey,\n2301: \t\t})\n2302: \t}\n2303: \n2304: \tfor hopIdx := len(state.ChainHops) - 1; hopIdx >= 0; hopIdx-- {\n2305: \t\tfor nodeIdx := range state.ChainHops[hopIdx] {\n2306: \t\t\tchainNode := state.ChainHops[hopIdx][nodeIdx]\n2307: \t\t\tnode := state.Nodes[chainNode.NodeID]\n2308: \t\t\tif node == nil || node.IsRemote != 1 {\n2309: \t\t\t\tcontinue\n2310: \t\t\t}\n2311: \t\t\tremoteURL := strings.TrimSpace(node.RemoteURL)\n2312: \t\t\tremoteToken := strings.TrimSpace(node.RemoteToken)\n2313: \t\t\tif remoteURL == \"\" || remoteToken == \"\" {\n2314: \t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n2315: \t\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 缺少共享配置\", nodeDisplayName(node))\n2316: \t\t\t}\n2317: \n2318: \t\t\tresourceKey := federationRuntimeResourceKey(state.TunnelID, chainNode.NodeID, 2, hopIdx+1)\n2319: \t\t\treserveReq := client.RuntimeReservePortRequest{\n2320: \t\t\t\tResourceKey: resourceKey,\n2321: \t\t\t\tProtocol: defaultString(chainNode.Protocol, \"tls\"),\n2322: \t\t\t\tRequestedPort: chainNode.Port,\n2323: \t\t\t}\n2324: \t\t\treserveRes, err := fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n2325: \t\t\tif err != nil && reserveReq.RequestedPort > 0 {\n2326: \t\t\t\treserveReq.RequestedPort = 0\n2327: \t\t\t\treserveRes, err = fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n2328: \t\t\t}\n2329: \t\t\tif err != nil {\n2330: \t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n2331: \t\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 端口分配失败: %w\", nodeDisplayName(node), err)\n2332: \t\t\t}\n2333: \n2334: \t\t\tstate.ChainHops[hopIdx][nodeIdx].Port = reserveRes.AllocatedPort\n2335: \t\t\tchainNode = state.ChainHops[hopIdx][nodeIdx]\n2336: \n2337: \t\t\tnextTargets := state.OutNodes\n2338: \t\t\tif hopIdx+1 < len(state.ChainHops) {\n2339: \t\t\t\tnextTargets = state.ChainHops[hopIdx+1]\n2340: \t\t\t}\n2341: \t\t\tapplyTargets := make([]client.RuntimeTarget, 0, len(nextTargets))\n2342: \t\t\tfor _, target := range nextTargets {\n2343: \t\t\t\ttargetNode := state.Nodes[target.NodeID]\n2344: \t\t\t\tif targetNode == nil {\n2345: \t\t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n2346: \t\t\t\t\treturn nil, nil, errors.New(\"节点不存在\")\n2347: \t\t\t\t}\n2348: \t\t\t\thost, hostErr := selectTunnelDialHost(node, targetNode, state.IPPreference, target.ConnectIP)\n2349: \t\t\t\tif hostErr != nil {\n2350: \t\t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n2351: \t\t\t\t\treturn nil, nil, hostErr\n2352: \t\t\t\t}\n2353: \t\t\t\tif target.Port <= 0 {\n2354: \t\t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n2355: \t\t\t\t\treturn nil, nil, errors.New(\"节点端口不能为空\")\n2356: \t\t\t\t}\n2357: \t\t\t\tapplyTargets = append(applyTargets, client.RuntimeTarget{\n2358: \t\t\t\t\tHost: host,\n2359: \t\t\t\t\tPort: target.Port,\n2360: \t\t\t\t\tProtocol: defaultString(target.Protocol, \"tls\"),\n2361: \t\t\t\t})\n2362: \t\t\t}\n2363: \n2364: \t\t\tapplyReq := client.RuntimeApplyRoleRequest{\n2365: \t\t\t\tReservationID: reserveRes.ReservationID,\n2366: \t\t\t\tResourceKey: resourceKey,\n2367: \t\t\t\tRole: \"middle\",\n2368: \t\t\t\tProtocol: defaultString(chainNode.Protocol, \"tls\"),\n2369: \t\t\t\tStrategy: defaultString(chainNode.Strategy, \"round\"),\n2370: \t\t\t\tTargets: applyTargets,\n2371: \t\t\t}\n2372: \t\t\tapplyRes, err := fc.ApplyRole(remoteURL, remoteToken, localDomain, applyReq)\n2373: \t\t\tif err != nil {\n2374: \t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n2375: \t\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 运行时下发失败: %w\", nodeDisplayName(node), err)\n2376: \t\t\t}\n2377: \t\t\tif applyRes.AllocatedPort > 0 {\n2378: \t\t\t\tstate.ChainHops[hopIdx][nodeIdx].Port = applyRes.AllocatedPort\n2379: \t\t\t\tchainNode = state.ChainHops[hopIdx][nodeIdx]\n2380: \t\t\t}\n2381: \n2382: \t\t\tbindings = append(bindings, repo.FederationTunnelBinding{\n2383: \t\t\t\tTunnelID: state.TunnelID,\n2384: \t\t\t\tNodeID: chainNode.NodeID,\n2385: \t\t\t\tChainType: 2,\n2386: \t\t\t\tHopInx: hopIdx + 1,\n2387: \t\t\t\tRemoteURL: remoteURL,\n2388: \t\t\t\tResourceKey: resourceKey,\n2389: \t\t\t\tRemoteBindingID: defaultString(applyRes.BindingID, reserveRes.BindingID),\n2390: \t\t\t\tAllocatedPort: chainNode.Port,\n2391: \t\t\t\tStatus: 1,\n2392: \t\t\t\tCreatedTime: now,\n2393: \t\t\t\tUpdatedTime: now,\n2394: \t\t\t})\n2395: \t\t\treleaseRefs = append(releaseRefs, federationRuntimeReleaseRef{\n2396: \t\t\t\tRemoteURL: remoteURL,\n2397: \t\t\t\tRemoteToken: remoteToken,\n2398: \t\t\t\tBindingID: applyRes.BindingID,\n2399: \t\t\t\tReservationID: reserveRes.ReservationID,\n2400: \t\t\t\tResourceKey: resourceKey,\n2401: \t\t\t})\n2402: \t\t}\n2403: \t}\n2404: \n2405: \treturn bindings, releaseRefs, nil\n2406: }\n2407: \n2408: func (h *Handler) releaseFederationRuntimeRefs(refs []federationRuntimeReleaseRef) {\n2409: \tif h == nil || len(refs) == 0 {\n2410: \t\treturn\n2411: \t}\n2412: \tfc := client.NewFederationClient()\n2413: \tlocalDomain := h.federationLocalDomain()\n2414: \tfor i := len(refs) - 1; i >= 0; i-- {\n2415: \t\tref := refs[i]\n2416: \t\tif strings.TrimSpace(ref.RemoteURL) == \"\" || strings.TrimSpace(ref.RemoteToken) == \"\" {\n2417: \t\t\tcontinue\n2418: \t\t}\n2419: \t\treq := client.RuntimeReleaseRoleRequest{\n2420: \t\t\tBindingID: ref.BindingID,\n2421: \t\t\tReservationID: ref.ReservationID,\n2422: \t\t\tResourceKey: ref.ResourceKey,\n2423: \t\t}\n2424: \t\t_ = fc.ReleaseRole(ref.RemoteURL, ref.RemoteToken, localDomain, req)\n2425: \t}\n2426: }\n2427: \n2428: func (h *Handler) cleanupFederationRuntime(tunnelID int64) {\n2429: \tif h == nil || tunnelID <= 0 {\n2430: \t\treturn\n2431: \t}\n2432: \tbindings, err := h.repo.ListActiveFederationTunnelBindingsByTunnel(tunnelID)\n2433: \tif err != nil || len(bindings) == 0 {\n2434: \t\treturn\n2435: \t}\n2436: \n2437: \tfc := client.NewFederationClient()\n2438: \tlocalDomain := h.federationLocalDomain()\n2439: \tfor _, b := range bindings {\n2440: \t\tnode, nodeErr := h.repo.GetNodeByID(b.NodeID)\n2441: \t\tif nodeErr != nil || node == nil {\n2442: \t\t\tcontinue\n2443: \t\t}\n2444: \t\tremoteURL := strings.TrimSpace(node.RemoteURL.String)\n2445: \t\tif remoteURL == \"\" {\n2446: \t\t\tremoteURL = strings.TrimSpace(b.RemoteURL)\n2447: \t\t}\n2448: \t\tremoteToken := strings.TrimSpace(node.RemoteToken.String)\n2449: \t\tif remoteURL == \"\" || remoteToken == \"\" {\n2450: \t\t\tcontinue\n2451: \t\t}\n2452: \t\treq := client.RuntimeReleaseRoleRequest{\n2453: \t\t\tBindingID: strings.TrimSpace(b.RemoteBindingID),\n2454: \t\t\tResourceKey: strings.TrimSpace(b.ResourceKey),\n2455: \t\t}\n2456: \t\t_ = fc.ReleaseRole(remoteURL, remoteToken, localDomain, req)\n2457: \t}\n2458: \t_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)\n2459: }\n2460: \n2461: func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64, error) {\n2462: \tif h == nil || state == nil {\n2463: \t\treturn nil, nil, errors.New(\"invalid tunnel runtime state\")\n2464: \t}\n2465: \tcreatedChains := make([]int64, 0)\n2466: \tcreatedServices := make([]int64, 0)\n2467: \tif state.Type != 2 {\n2468: \t\treturn createdChains, createdServices, nil\n2469: \t}\n2470: \n2471: \tfor _, inNode := range state.InNodes {\n2472: \t\tnode := state.Nodes[inNode.NodeID]\n2473: \t\ttargets := state.OutNodes\n2474: \t\tif len(state.ChainHops) > 0 {\n2475: \t\t\ttargets = state.ChainHops[0]\n2476: \t\t}\n2477: \t\tchainData, err := buildTunnelChainConfig(state.TunnelID, inNode.NodeID, targets, state.Nodes, state.IPPreference)\n2478: \t\tif err != nil {\n2479: \t\t\treturn createdChains, createdServices, err\n2480: \t\t}\n2481: \t\tif _, err := h.sendNodeCommand(inNode.NodeID, \"AddChains\", chainData, true, false); err != nil {\n2482: \t\t\tif node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) {\n2483: \t\t\t\tcontinue\n2484: \t\t\t}\n2485: \t\t\treturn createdChains, createdServices, fmt.Errorf(\"入口节点 %s 下发转发链失败: %w\", nodeDisplayName(state.Nodes[inNode.NodeID]), err)\n2486: \t\t}\n2487: \t\tcreatedChains = append(createdChains, inNode.NodeID)\n2488: \t}\n2489: \n2490: \tfor i, hop := range state.ChainHops {\n2491: \t\tnextTargets := state.OutNodes\n2492: \t\tif i+1 < len(state.ChainHops) {\n2493: \t\t\tnextTargets = state.ChainHops[i+1]\n2494: \t\t}\n2495: \t\tfor _, chainNode := range hop {\n2496: \t\t\tif node := state.Nodes[chainNode.NodeID]; node != nil && node.IsRemote == 1 {\n2497: \t\t\t\tcontinue\n2498: \t\t\t}\n2499: \t\t\tchainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)\n2500: \t\t\tif err != nil {\n2501: \t\t\t\treturn createdChains, createdServices, err\n2502: \t\t\t}\n2503: \t\t\tif _, err := h.sendNodeCommand(chainNode.NodeID, \"AddChains\", chainData, true, false); err != nil {\n2504: \t\t\t\treturn createdChains, createdServices, fmt.Errorf(\"转发链节点 %s 下发转发链失败: %w\", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)\n2505: \t\t\t}\n2506: \t\t\tcreatedChains = append(createdChains, chainNode.NodeID)\n2507: \n2508: \t\t\tserviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID])\n2509: \t\t\tif _, err := h.sendNodeCommand(chainNode.NodeID, \"AddService\", serviceData, true, false); err != nil {\n2510: \t\t\t\treturn createdChains, createdServices, fmt.Errorf(\"转发链节点 %s 下发服务失败: %w\", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)\n2511: \t\t\t}\n2512: \t\t\tcreatedServices = append(createdServices, chainNode.NodeID)\n2513: \t\t}\n2514: \t}\n2515: \n2516: \tfor _, outNode := range state.OutNodes {\n2517: \t\tif node := state.Nodes[outNode.NodeID]; node != nil && node.IsRemote == 1 {\n2518: \t\t\tcontinue\n2519: \t\t}\n2520: \t\tserviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID])\n2521: \t\tif _, err := h.sendNodeCommand(outNode.NodeID, \"AddService\", serviceData, true, false); err != nil {\n2522: \t\t\treturn createdChains, createdServices, fmt.Errorf(\"出口节点 %s 下发服务失败: %w\", nodeDisplayName(state.Nodes[outNode.NodeID]), err)\n2523: \t\t}\n2524: \t\tcreatedServices = append(createdServices, outNode.NodeID)\n2525: \t}\n2526: \n2527: \treturn createdChains, createdServices, nil\n2528: }\n2529: \n2530: func (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tunnelID int64) {\n2531: \tif h == nil || tunnelID <= 0 {\n2532: \t\treturn\n2533: \t}\n2534: \tseenServices := make(map[int64]struct{})\n2535: \tserviceName := fmt.Sprintf(\"%d_tls\", tunnelID)\n2536: \tfor i := len(serviceNodeIDs) - 1; i >= 0; i-- {\n2537: \t\tnodeID := serviceNodeIDs[i]\n2538: \t\tif _, ok := seenServices[nodeID]; ok {\n2539: \t\t\tcontinue\n\n(Showing lines 2180-2539 of 3419. Use offset=2540 to continue.)\n</content>",
|
||
"title": "go-backend/internal/http/handler/mutations.go",
|
||
"metadata": {
|
||
"preview": "\t\t}\n\t}\n\tfor _, hopRaw := range asAnySlice(req[\"chainNodes\"]) {\n\t\tfor _, item := range asMapSlice(hopRaw) {\n\t\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\t\tif port, ok := chainPorts[nodeID]; ok && port > 0 {\n\t\t\t\titem[\"port\"] = port\n\t\t\t}\n\t\t}\n\t}\n}\n\ntype federationRuntimeReleaseRef struct {\n\tRemoteURL string\n\tRemoteToken string\n\tBindingID string\n\tReservationID string\n\tResourceKey string\n}\n",
|
||
"truncated": true,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772506072892,
|
||
"end": 1772506072902
|
||
}
|
||
},
|
||
"id": "prt_cb198473b001dmOiGXnYm6JGr7",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb1981e15002CnoomqyDnJfzTY"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 44009,
|
||
"input": 43466,
|
||
"output": 543,
|
||
"reasoning": 349,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb198474d001OUIEgckVkGX7M0",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb1981e15002CnoomqyDnJfzTY"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506072969,
|
||
"completed": 1772506080736
|
||
},
|
||
"parentID": "msg_cb1972fd9001q5a8C3Bm5amrRC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 49512,
|
||
"input": 19948,
|
||
"output": 380,
|
||
"reasoning": 278,
|
||
"cache": {
|
||
"read": 29184,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb198478900282srEek7NyG0rF",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"id": "prt_cb198634c001nSuXix5WOxVsMd",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb198478900282srEek7NyG0rF"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Analyzing IP listening logic**",
|
||
"time": {
|
||
"start": 1772506080077,
|
||
"end": 1772506080644
|
||
},
|
||
"id": "prt_cb198634d001sod2UxNG00roIP",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb198478900282srEek7NyG0rF"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_H9vSXnZJUhidDkfzyPQRc6oU",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "GetNodeAllIPs|extraIPs|connectIp|inIp.*validate|validate.*inIp|validate.*connectIp|allowed IP|node all ip",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal",
|
||
"include": "*.go"
|
||
},
|
||
"output": "Found 29 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go:\n Line 640: \t\t\t\"extraIPs\": nullableString(n.ExtraIPs),\n Line 869: \t\t\tnodeObj[\"connectIp\"] = c.ConnectIP.String\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_mutations.go:\n Line 199: 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 Line 209: \t\tExtraIPs: nullStringFromInterface(extraIPs),\n Line 242: 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 Line 253: \t\t\t\"extra_ips\": nullStringFromInterface(extraIPs),\n Line 400: 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 Line 412: \t\tConnectIP: sql.NullString{String: connectIp, Valid: connectIp != \"\"},\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go:\n Line 343: \tExtraIPs string `json:\"extraIPs,omitempty\"`\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go:\n Line 15: \t// Empty connectIp should be ignored, IP preference takes effect\n Line 21: \t\tt.Fatalf(\"empty connectIp should be ignored (v4 preference applies), got %q\", host)\n Line 23: \t// Non-empty connectIp should override IP preference\n Line 29: \t\tt.Fatalf(\"connectIp should override v6 preference, got %q\", host)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go:\n Line 274: \t\tnullableText(asString(req[\"extraIPs\"])),\n Line 326: \t\tnullableText(asString(req[\"extraIPs\"])),\n Line 2035: \t\t\t\tConnectIP: asString(item[\"connectIp\"]),\n Line 2071: \t\t\t\t\tConnectIP: asString(item[\"connectIp\"]),\n Line 2661: func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, connectIp string) (string, error) {\n Line 2665: \tif strings.TrimSpace(connectIp) != \"\" {\n Line 2666: \t\treturn strings.TrimSpace(connectIp), nil\n Line 2820: \t\tconnectIp := asString(n[\"connectIp\"])\n Line 2830: \t\t\tconnectIp,\n Line 2850: \t\t\tconnectIp := asString(n[\"connectIp\"])\n Line 2860: \t\t\t\tconnectIp,\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go:\n Line 1110: func resolveChainProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string, connectIp string) (string, int, error) {\n Line 1114: \thost, err := selectTunnelDialHost(fromNode, targetNode, ipPreference, connectIp)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/federation_share_test.go:\n Line 123: \tif !strings.Contains(payload.Msg, \"Invalid allowed IP or CIDR\") {\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/federation.go:\n Line 1611: \t\t\t\treturn \"\", fmt.Errorf(\"Invalid allowed IP or CIDR: %s\", item)\n Line 1617: \t\t\t\treturn \"\", fmt.Errorf(\"Invalid allowed IP or CIDR: %s\", item)",
|
||
"title": "GetNodeAllIPs|extraIPs|connectIp|inIp.*validate|validate.*inIp|validate.*connectIp|allowed IP|node all ip",
|
||
"metadata": {
|
||
"matches": 29,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772506080561,
|
||
"end": 1772506080583
|
||
}
|
||
},
|
||
"id": "prt_cb19863a3001Q8qmM7FVyHqvam",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb198478900282srEek7NyG0rF"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 49512,
|
||
"input": 19948,
|
||
"output": 380,
|
||
"reasoning": 278,
|
||
"cache": {
|
||
"read": 29184,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb19865870012w56rlhOWYNFfI",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb198478900282srEek7NyG0rF"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506080737,
|
||
"completed": 1772506090843
|
||
},
|
||
"parentID": "msg_cb1972fd9001q5a8C3Bm5amrRC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 50929,
|
||
"input": 50390,
|
||
"output": 539,
|
||
"reasoning": 252,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb19865e1002yy2E5UR6Or0JL2",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"id": "prt_cb198828e001LTt3x7JOCJDVSn",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19865e1002yy2E5UR6Or0JL2"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Analyzing IP binding mismatch in tunnel setup**",
|
||
"time": {
|
||
"start": 1772506088078,
|
||
"end": 1772506090766
|
||
},
|
||
"id": "prt_cb198828e002FTH3zqN4AvHODD",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19865e1002yy2E5UR6Or0JL2"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_3uRUUKeY2vqNJ5fX1zhIShYL",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "监听|listen|[\\[]::[\\]]|connectIp|inIp|入口IP|出口IP",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/211-custom-ip-selection.md",
|
||
"include": "*.md"
|
||
},
|
||
"output": "Found 5 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/211-custom-ip-selection.md:\n Line 1: # Issue #211: 转发自定义监听IP / 隧道指定连接IP\n Line 5: 2. **转发**: 创建/编辑时可指定入口监听IP\n Line 17: - [x] 5. Handler - 转发创建/更新处理 inIp 参数\n Line 18: - [x] 6. Handler - 隧道出口节点处理 connectIp 参数\n Line 23: - [x] 9. 转发编辑弹窗 - 增加\"监听IP\"下拉选择",
|
||
"title": "监听|listen|[\\[]::[\\]]|connectIp|inIp|入口IP|出口IP",
|
||
"metadata": {
|
||
"matches": 5,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772506090750,
|
||
"end": 1772506090768
|
||
}
|
||
},
|
||
"id": "prt_cb1988cf8001YM11iT76jqxl24",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19865e1002yy2E5UR6Or0JL2"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_E6m1HrgLRdpacEhWQ6oJXHc2",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "connectIp|默认连接IP|出口节点|监听|[\\[]::[\\]]",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/tunnel.tsx",
|
||
"include": "*.tsx"
|
||
},
|
||
"output": "Found 18 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/tunnel.tsx:\n Line 73: connectIp?: string; // 连接IP(多IP节点指定连接地址)\n Line 82: outNodeId?: ChainTunnel[]; // 出口节点列表\n Line 392: const updateChainConnectIp = (groupIndex: number, connectIp: string) => {\n Line 398: connectIp,\n Line 473: // 过滤掉出口节点中的占位节点\n Line 1173: {/* 出口节点 */}\n Line 1599: ? groupNodes[0].connectIp || \"\"\n Line 1848: <SelectItem key=\"__default__\">默认连接IP</SelectItem>\n Line 1898: placeholder=\"请选择出口节点(可多选)\"\n Line 2019: // 如果还没有出口节点,创建一个占位节点保存设置\n Line 2033: // 更新所有出口节点的协议\n Line 2115: {/* 连接IP - 出口节点 */}\n Line 2121: description=\"按出口节点共同可用IP选择,留空使用默认\"\n Line 2135: ? \"请先选择出口节点\"\n Line 2146: ? form.outNodeId[0].connectIp || \"__default__\"\n Line 2166: connectIp: value,\n Line 2175: connectIp: value,\n Line 2181: <SelectItem key=\"__default__\">默认连接IP</SelectItem>",
|
||
"title": "connectIp|默认连接IP|出口节点|监听|[\\[]::[\\]]",
|
||
"metadata": {
|
||
"matches": 18,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772506090757,
|
||
"end": 1772506090775
|
||
}
|
||
},
|
||
"id": "prt_cb1988d01001X1ZMiU8xLjkJr2",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19865e1002yy2E5UR6Or0JL2"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_jeRxV4fkPY3oeTzhdjFNg5wo",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "buildTunnelChainServiceConfig|service.*addr|connectIp|inIp|listen",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/*test.go",
|
||
"include": "*.go"
|
||
},
|
||
"output": "No files found",
|
||
"title": "buildTunnelChainServiceConfig|service.*addr|connectIp|inIp|listen",
|
||
"metadata": {
|
||
"matches": 0,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772506090764,
|
||
"end": 1772506090776
|
||
}
|
||
},
|
||
"id": "prt_cb1988d080017lqFzq5378MqpX",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19865e1002yy2E5UR6Or0JL2"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 50929,
|
||
"input": 50390,
|
||
"output": 539,
|
||
"reasoning": 252,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb1988d190016mrbm6yKCITxzs",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19865e1002yy2E5UR6Or0JL2"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506090844,
|
||
"completed": 1772506101151
|
||
},
|
||
"parentID": "msg_cb1972fd9001q5a8C3Bm5amrRC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 51393,
|
||
"input": 51260,
|
||
"output": 133,
|
||
"reasoning": 34,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb1988d5c0024lk1GpkRG37WAq",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"id": "prt_cb198b241001CJEEwzduLauFCL",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb1988d5c0024lk1GpkRG37WAq"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Adjusting test search approach**",
|
||
"time": {
|
||
"start": 1772506100289,
|
||
"end": 1772506101057
|
||
},
|
||
"id": "prt_cb198b241002gUSt1rHjDgCaYX",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb1988d5c0024lk1GpkRG37WAq"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_BUK7R79OA4AQgGYjUYRhukFz",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "buildForwardServiceConfigs|addr\": fmt.Sprintf|TCPListenAddr|InIP|connectIp|buildTunnelChainServiceConfig",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler",
|
||
"include": "*test.go"
|
||
},
|
||
"output": "Found 4 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go:\n Line 15: \t// Empty connectIp should be ignored, IP preference takes effect\n Line 21: \t\tt.Fatalf(\"empty connectIp should be ignored (v4 preference applies), got %q\", host)\n Line 23: \t// Non-empty connectIp should override IP preference\n Line 29: \t\tt.Fatalf(\"connectIp should override v6 preference, got %q\", host)",
|
||
"title": "buildForwardServiceConfigs|addr\": fmt.Sprintf|TCPListenAddr|InIP|connectIp|buildTunnelChainServiceConfig",
|
||
"metadata": {
|
||
"matches": 4,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772506100945,
|
||
"end": 1772506100965
|
||
}
|
||
},
|
||
"id": "prt_cb198b286001C6TUBtJozAC5QT",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb1988d5c0024lk1GpkRG37WAq"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 51393,
|
||
"input": 51260,
|
||
"output": 133,
|
||
"reasoning": 34,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb198b543001Cf9U7Mf7xWGUCZ",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb1988d5c0024lk1GpkRG37WAq"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506101152,
|
||
"completed": 1772506147736
|
||
},
|
||
"parentID": "msg_cb1972fd9001q5a8C3Bm5amrRC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 53533,
|
||
"input": 51512,
|
||
"output": 2021,
|
||
"reasoning": 1466,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "stop",
|
||
"id": "msg_cb198b5a0002fAHj33hn0FdPBb",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"id": "prt_cb19936ea001g91f76NMMJSd2Z",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb198b5a0002fAHj33hn0FdPBb"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Summarizing IP listening bug and plan**",
|
||
"time": {
|
||
"start": 1772506134251,
|
||
"end": 1772506147642
|
||
},
|
||
"id": "prt_cb19936eb001BaLsiCg46SNs9c",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb198b5a0002fAHj33hn0FdPBb"
|
||
},
|
||
{
|
||
"type": "text",
|
||
"text": "我检查了现状,结论是:**你说的问题存在**,211 目前只做了“记录/传递 IP”,但在节点下发服务时并没有按所选 IP 绑定监听。\n\n- 转发入口 IP 已写入 `forward_port.in_ip`(`go-backend/internal/store/repo/repository_mutations.go:1210`),但实际生成服务监听地址时仍用节点默认监听地址(常见为 `[::]`):`go-backend/internal/http/handler/control_plane.go:1316`、`go-backend/internal/http/handler/control_plane.go:1322`。\n- 隧道出口 `connectIp` 目前用于“上游拨号目标选择”(`go-backend/internal/http/handler/mutations.go:2665`),但隧道服务监听地址仍固定用 `node.TCPListenAddr`:`go-backend/internal/http/handler/mutations.go:2646`。\n- 所以现在即使选了入口/出口 IP,节点侧多数情况下仍会监听 `[::]`(全地址),不符合你提的“只监听指定 IP”。\n\n**执行计划(实现时)**\n- 在转发下发路径里,让 `buildForwardServiceConfigs` 优先使用 `forward_port.in_ip` 作为监听 host(分别用于 tcp/udp)。\n- 在隧道下发路径里,让 `buildTunnelChainServiceConfig` 在有 `connectIp` 时优先绑定该 IP(无值再回退 `node.TCPListenAddr`)。\n- 监听地址统一走 `processServerAddress(...)` 组装,避免 IPv6 变成非法的 `2001:db8::1:443` 这种格式。\n- 保持现有回退逻辑:未指定时继续使用节点默认监听地址(兼容旧数据)。\n- 增补 handler 层单测,覆盖:\n - 指定 IPv4/IPv6 时监听地址确实变为指定 IP;\n - 未指定时仍为默认监听地址;\n - `connectIp` 仍保留现有“拨号目标优先”语义。\n\n一个需要你拍板的点(我给推荐默认):\n- **推荐:`connectIp` 同时用于“上游拨号目标 + 本节点监听绑定”**。 \n 这样语义一致,且满足你“不要再监听 `[::]`”的要求。 \n 如果你只想它影响监听、不影响拨号,我会只改监听构建函数,不动 `selectTunnelDialHost`。",
|
||
"time": {
|
||
"start": 1772506147645,
|
||
"end": 1772506147645
|
||
},
|
||
"id": "prt_cb199375a001c2rUsfFQgGSWeZ",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb198b5a0002fAHj33hn0FdPBb"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "stop",
|
||
"snapshot": "1f46527d47bc0d8e99ad6a54a00e186f4c31c6ab",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 53533,
|
||
"input": 51512,
|
||
"output": 2021,
|
||
"reasoning": 1466,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb1996b3f001KuxkaV58yAzyIa",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb198b5a0002fAHj33hn0FdPBb"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "user",
|
||
"time": {
|
||
"created": 1772506197834
|
||
},
|
||
"summary": {
|
||
"diffs": []
|
||
},
|
||
"agent": "plan",
|
||
"model": {
|
||
"providerID": "api",
|
||
"modelID": "gpt-5.3-codex"
|
||
},
|
||
"variant": "xhigh",
|
||
"id": "msg_cb19a2f030013k52GIXIBjDcHG",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "text",
|
||
"text": "connectIp 同时用于“上游拨号目标 + 本节点监听绑定”",
|
||
"id": "prt_cb19a2f03002W6PfKZ2lL5ejDV",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19a2f030013k52GIXIBjDcHG"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506197845,
|
||
"completed": 1772506208417
|
||
},
|
||
"parentID": "msg_cb19a2f030013k52GIXIBjDcHG",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 52465,
|
||
"input": 52088,
|
||
"output": 377,
|
||
"reasoning": 212,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb19a2f55002A3TxCuV6o7lRSd",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "c58dbb5d75389b54b0dd0bd2f213e90fd813265e",
|
||
"id": "prt_cb19a5621001kha5QTICt05DrP",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19a2f55002A3TxCuV6o7lRSd"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Outlining IP parameter addition plan**",
|
||
"time": {
|
||
"start": 1772506207777,
|
||
"end": 1772506208335
|
||
},
|
||
"id": "prt_cb19a56210020PHK922pfANW0W",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19a2f55002A3TxCuV6o7lRSd"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_S4Va2wfpHFgICWuBrt4Ar7tN",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane_test.go",
|
||
"offset": 1,
|
||
"limit": 240
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane_test.go</path>\n<type>file</type>\n<content>1: package handler\n2: \n3: import (\n4: \t\"reflect\"\n5: \t\"testing\"\n6: )\n7: \n8: func TestBuildForwardControlServiceNamesPauseResume(t *testing.T) {\n9: \tbase := \"12_34_56\"\n10: \twant := []string{base + \"_tcp\", base + \"_udp\"}\n11: \n12: \tfor _, command := range []string{\"PauseService\", \"ResumeService\"} {\n13: \t\tgot := buildForwardControlServiceNames(base, command)\n14: \t\tif !reflect.DeepEqual(got, want) {\n15: \t\t\tt.Fatalf(\"command %s expected %v, got %v\", command, want, got)\n16: \t\t}\n17: \t}\n18: }\n19: \n20: func TestBuildForwardControlServiceNamesDelete(t *testing.T) {\n21: \tbase := \"12_34_56\"\n22: \twant := []string{base, base + \"_tcp\", base + \"_udp\"}\n23: \tgot := buildForwardControlServiceNames(base, \" DeleteService \")\n24: \tif !reflect.DeepEqual(got, want) {\n25: \t\tt.Fatalf(\"expected %v, got %v\", want, got)\n26: \t}\n27: }\n28: \n29: func TestBuildForwardServiceBaseCandidates(t *testing.T) {\n30: \tgot := buildForwardServiceBaseCandidates(12, 34, 56, []int64{56, 78, 90})\n31: \twant := []string{\"12_34_56\", \"12_34_78\", \"12_34_90\", \"12_34_0\"}\n32: \tif !reflect.DeepEqual(got, want) {\n33: \t\tt.Fatalf(\"expected %v, got %v\", want, got)\n34: \t}\n35: }\n36: \n37: func TestBuildForwardServiceBaseCandidatesWithZeroPreferred(t *testing.T) {\n38: \tgot := buildForwardServiceBaseCandidates(12, 34, 0, []int64{78, 0, 90})\n39: \twant := []string{\"12_34_0\", \"12_34_78\", \"12_34_90\"}\n40: \tif !reflect.DeepEqual(got, want) {\n41: \t\tt.Fatalf(\"expected %v, got %v\", want, got)\n42: \t}\n43: }\n44: \n45: func TestShouldTryLegacySingleService(t *testing.T) {\n46: \tif !shouldTryLegacySingleService(\"PauseService\") {\n47: \t\tt.Fatalf(\"PauseService should require legacy fallback\")\n48: \t}\n49: \tif !shouldTryLegacySingleService(\"resumeService\") {\n50: \t\tt.Fatalf(\"ResumeService should require legacy fallback\")\n51: \t}\n52: \tif shouldTryLegacySingleService(\"DeleteService\") {\n53: \t\tt.Fatalf(\"DeleteService should not require legacy fallback\")\n54: \t}\n55: }\n56: \n57: func TestIsAlreadyExistsMessage(t *testing.T) {\n58: \tif !isAlreadyExistsMessage(\"service demo already exists\") {\n59: \t\tt.Fatalf(\"expected already exists message to be tolerated\")\n60: \t}\n61: \tif !isAlreadyExistsMessage(\"服务已存在\") {\n62: \t\tt.Fatalf(\"expected Chinese already exists message to be tolerated\")\n63: \t}\n64: \tif isAlreadyExistsMessage(\"listen tcp [::]:10001: bind: address already in use\") {\n65: \t\tt.Fatalf(\"address already in use must not be treated as already exists\")\n66: \t}\n67: }\n\n(End of file - total 67 lines)\n</content>",
|
||
"title": "go-backend/internal/http/handler/control_plane_test.go",
|
||
"metadata": {
|
||
"preview": "package handler\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestBuildForwardControlServiceNamesPauseResume(t *testing.T) {\n\tbase := \"12_34_56\"\n\twant := []string{base + \"_tcp\", base + \"_udp\"}\n\n\tfor _, command := range []string{\"PauseService\", \"ResumeService\"} {\n\t\tgot := buildForwardControlServiceNames(base, command)\n\t\tif !reflect.DeepEqual(got, want) {\n\t\t\tt.Fatalf(\"command %s expected %v, got %v\", command, want, got)\n\t\t}\n\t}\n}\n\nfunc TestBuildForwardControlServiceNamesDelete(t *testing.T) {",
|
||
"truncated": false,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772506208116,
|
||
"end": 1772506208123
|
||
}
|
||
},
|
||
"id": "prt_cb19a5765001pIxp1b835k5VMJ",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19a2f55002A3TxCuV6o7lRSd"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_2TJYFMd4fcBTuEftkRusjClM",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go",
|
||
"offset": 1,
|
||
"limit": 220
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go</path>\n<type>file</type>\n<content>1: package handler\n2: \n3: import (\n4: \t\"testing\"\n5: )\n6: \n7: // ---------------------------------------------------------------------------\n8: // nodeSupportsV4 / nodeSupportsV6\n9: // ---------------------------------------------------------------------------\n10: \n11: func TestSelectTunnelDialHost_ConnectIpPriority(t *testing.T) {\n12: \tfrom := dualStackNode(\"from\", \"10.0.0.1\", \"2001:db8::1\")\n13: \tto := dualStackNode(\"to\", \"10.0.0.2\", \"2001:db8::2\")\n14: \n15: \t// Empty connectIp should be ignored, IP preference takes effect\n16: \thost, err := selectTunnelDialHost(from, to, \"\", \"\")\n17: \tif err != nil {\n18: \t\tt.Fatalf(\"unexpected error: %v\", err)\n19: \t}\n20: \tif host != \"10.0.0.2\" {\n21: \t\tt.Fatalf(\"empty connectIp should be ignored (v4 preference applies), got %q\", host)\n22: \t}\n23: \t// Non-empty connectIp should override IP preference\n24: \thost, err = selectTunnelDialHost(from, to, \"v6\", \"192.168.0.3\")\n25: \tif err != nil {\n26: \t\tt.Fatalf(\"unexpected error: %v\", err)\n27: \t}\n28: \tif host != \"192.168.0.3\" {\n29: \t\tt.Fatalf(\"connectIp should override v6 preference, got %q\", host)\n30: \t}\n31: }\n32: \n33: func TestNodeSupportsV6_Nil(t *testing.T) {\n34: \tif nodeSupportsV6(nil) {\n35: \t\tt.Fatal(\"nil node must not support v6\")\n36: \t}\n37: }\n38: \n39: func TestNodeSupportsV4_ExplicitV4(t *testing.T) {\n40: \tn := &nodeRecord{ServerIPv4: \"10.0.0.1\"}\n41: \tif !nodeSupportsV4(n) {\n42: \t\tt.Fatal(\"explicit server_ip_v4 needs support v4\")\n43: \t}\n44: }\n45: \n46: func TestNodeSupportsV6_ExplicitV6(t *testing.T) {\n47: \tn := &nodeRecord{ServerIPv6: \"2001:db8::1\"}\n48: \tif !nodeSupportsV6(n) {\n49: \t\tt.Fatal(\"explicit server_ip_v6 needs support v6\")\n50: \t}\n51: }\n52: \n53: func TestNodeSupportsV4_OnlyV6Set(t *testing.T) {\n54: \tn := &nodeRecord{ServerIPv6: \"2001:db8::1\"}\n55: \tif nodeSupportsV4(n) {\n56: \t\tt.Fatal(\"node with only v6 should not support v4\")\n57: \t}\n58: }\n59: \n60: func TestNodeSupportsV6_OnlyV4Set(t *testing.T) {\n61: \tn := &nodeRecord{ServerIPv4: \"10.0.0.1\"}\n62: \tif nodeSupportsV6(n) {\n63: \t\tt.Fatal(\"node with only v4 should not support v6\")\n64: \t}\n65: }\n66: \n67: func TestNodeSupportsV4_DualStack(t *testing.T) {\n68: \tn := &nodeRecord{ServerIPv4: \"10.0.0.1\", ServerIPv6: \"2001:db8::1\"}\n69: \tif !nodeSupportsV4(n) {\n70: \t\tt.Fatal(\"dual-stack node must support v4\")\n71: \t}\n72: }\n73: \n74: func TestNodeSupportsV6_DualStack(t *testing.T) {\n75: \tn := &nodeRecord{ServerIPv4: \"10.0.0.1\", ServerIPv6: \"2001:db8::1\"}\n76: \tif !nodeSupportsV6(n) {\n77: \t\tt.Fatal(\"dual-stack node must support v6\")\n78: \t}\n79: }\n80: \n81: func TestNodeSupportsV4_LegacyV4Only(t *testing.T) {\n82: \tn := &nodeRecord{ServerIP: \"192.168.1.1\"}\n83: \tif !nodeSupportsV4(n) {\n84: \t\tt.Fatal(\"legacy v4 ip in server_ip must support v4\")\n85: \t}\n86: \tif nodeSupportsV6(n) {\n87: \t\tt.Fatal(\"legacy v4 ip in server_ip should not support v6\")\n88: \t}\n89: }\n90: \n91: func TestNodeSupportsV6_LegacyV6Only(t *testing.T) {\n92: \tn := &nodeRecord{ServerIP: \"2001:db8::1\"}\n93: \tif !nodeSupportsV6(n) {\n94: \t\tt.Fatal(\"legacy v6 ip in server_ip must support v6\")\n95: \t}\n96: \tif nodeSupportsV4(n) {\n97: \t\tt.Fatal(\"legacy v6 ip in server_ip should not support v4\")\n98: \t}\n99: }\n100: \n101: func TestNodeSupportsV4_EmptyNode(t *testing.T) {\n102: \tn := &nodeRecord{}\n103: \tif nodeSupportsV4(n) {\n104: \t\tt.Fatal(\"empty node must not support v4\")\n105: \t}\n106: \tif nodeSupportsV6(n) {\n107: \t\tt.Fatal(\"empty node must not support v6\")\n108: \t}\n109: }\n110: \n111: func TestNodeSupportsV4_LegacyBracketed(t *testing.T) {\n112: \tn := &nodeRecord{ServerIP: \"[::1]\"}\n113: \tif nodeSupportsV4(n) {\n114: \t\tt.Fatal(\"bracketed ipv6 must not support v4\")\n115: \t}\n116: \tif !nodeSupportsV6(n) {\n117: \t\tt.Fatal(\"bracketed ipv6 must support v6\")\n118: \t}\n119: }\n120: \n121: // ---------------------------------------------------------------------------\n122: // pickNodeAddressV4 / pickNodeAddressV6\n123: // ---------------------------------------------------------------------------\n124: \n125: func TestPickNodeAddressV4_Nil(t *testing.T) {\n126: \tif pickNodeAddressV4(nil) != \"\" {\n127: \t\tt.Fatal(\"nil node must return empty\")\n128: \t}\n129: }\n130: \n131: func TestPickNodeAddressV6_Nil(t *testing.T) {\n132: \tif pickNodeAddressV6(nil) != \"\" {\n133: \t\tt.Fatal(\"nil node must return empty\")\n134: \t}\n135: }\n136: \n137: func TestPickNodeAddressV4_PreferExplicit(t *testing.T) {\n138: \tn := &nodeRecord{ServerIPv4: \"10.0.0.1\", ServerIP: \"192.168.0.1\"}\n139: \tgot := pickNodeAddressV4(n)\n140: \tif got != \"10.0.0.1\" {\n141: \t\tt.Fatalf(\"expected explicit v4 10.0.0.1, got %q\", got)\n142: \t}\n143: }\n144: \n145: func TestPickNodeAddressV4_FallbackLegacy(t *testing.T) {\n146: \tn := &nodeRecord{ServerIP: \"192.168.0.1\"}\n147: \tgot := pickNodeAddressV4(n)\n148: \tif got != \"192.168.0.1\" {\n149: \t\tt.Fatalf(\"expected legacy 192.168.0.1, got %q\", got)\n150: \t}\n151: }\n152: \n153: func TestPickNodeAddressV6_PreferExplicit(t *testing.T) {\n154: \tn := &nodeRecord{ServerIPv6: \"2001:db8::1\", ServerIP: \"::1\"}\n155: \tgot := pickNodeAddressV6(n)\n156: \tif got != \"2001:db8::1\" {\n157: \t\tt.Fatalf(\"expected explicit v6 2001:db8::1, got %q\", got)\n158: \t}\n159: }\n160: \n161: func TestPickNodeAddressV6_FallbackLegacy(t *testing.T) {\n162: \tn := &nodeRecord{ServerIP: \"::1\"}\n163: \tgot := pickNodeAddressV6(n)\n164: \tif got != \"::1\" {\n165: \t\tt.Fatalf(\"expected legacy ::1, got %q\", got)\n166: \t}\n167: }\n168: \n169: // ---------------------------------------------------------------------------\n170: // selectTunnelDialHost — core IP preference selection logic\n171: // ---------------------------------------------------------------------------\n172: \n173: func dualStackNode(name, v4, v6 string) *nodeRecord {\n174: \treturn &nodeRecord{\n175: \t\tName: name,\n176: \t\tServerIPv4: v4,\n177: \t\tServerIPv6: v6,\n178: \t}\n179: }\n180: \n181: func v4OnlyNode(name, v4 string) *nodeRecord {\n182: \treturn &nodeRecord{\n183: \t\tName: name,\n184: \t\tServerIPv4: v4,\n185: \t}\n186: }\n187: \n188: func v6OnlyNode(name, v6 string) *nodeRecord {\n189: \treturn &nodeRecord{\n190: \t\tName: name,\n191: \t\tServerIPv6: v6,\n192: \t}\n193: }\n194: \n195: func TestSelectTunnelDialHost_NilNodes(t *testing.T) {\n196: \t_, err := selectTunnelDialHost(nil, nil, \"\", \"\")\n197: \tif err == nil {\n198: \t\tt.Fatal(\"expected error for nil nodes\")\n199: \t}\n200: \t_, err = selectTunnelDialHost(dualStackNode(\"a\", \"1.1.1.1\", \"::1\"), nil, \"\", \"\")\n201: \tif err == nil {\n202: \t\tt.Fatal(\"expected error for nil toNode\")\n203: \t}\n204: \t_, err = selectTunnelDialHost(nil, dualStackNode(\"b\", \"1.1.1.1\", \"::1\"), \"\", \"\")\n205: \tif err == nil {\n206: \t\tt.Fatal(\"expected error for nil fromNode\")\n207: \t}\n208: }\n209: \n210: func TestSelectTunnelDialHost_DualStack_DefaultPreference(t *testing.T) {\n211: \tfrom := dualStackNode(\"from\", \"10.0.0.1\", \"2001:db8::1\")\n212: \tto := dualStackNode(\"to\", \"10.0.0.2\", \"2001:db8::2\")\n213: \thost, err := selectTunnelDialHost(from, to, \"\", \"\")\n214: \tif err != nil {\n215: \t\tt.Fatalf(\"unexpected error: %v\", err)\n216: \t}\n217: \t// Default prefers v4 when both available\n218: \tif host != \"10.0.0.2\" {\n219: \t\tt.Fatalf(\"default preference should pick v4, got %q\", host)\n220: \t}\n\n(Showing lines 1-220 of 380. Use offset=221 to continue.)\n</content>",
|
||
"title": "go-backend/internal/http/handler/dual_stack_test.go",
|
||
"metadata": {
|
||
"preview": "package handler\n\nimport (\n\t\"testing\"\n)\n\n// ---------------------------------------------------------------------------\n// nodeSupportsV4 / nodeSupportsV6\n// ---------------------------------------------------------------------------\n\nfunc 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\" {",
|
||
"truncated": true,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772506208265,
|
||
"end": 1772506208278
|
||
}
|
||
},
|
||
"id": "prt_cb19a57f9001H0ADLnIBQCj07i",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19a2f55002A3TxCuV6o7lRSd"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "c58dbb5d75389b54b0dd0bd2f213e90fd813265e",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 52465,
|
||
"input": 52088,
|
||
"output": 377,
|
||
"reasoning": 212,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb19a5852001Q2g66tEJOnI400",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19a2f55002A3TxCuV6o7lRSd"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506208419,
|
||
"completed": 1772506224241
|
||
},
|
||
"parentID": "msg_cb19a2f030013k52GIXIBjDcHG",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 56145,
|
||
"input": 55645,
|
||
"output": 500,
|
||
"reasoning": 424,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb19a58a3001J341UpO98HycxW",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "c58dbb5d75389b54b0dd0bd2f213e90fd813265e",
|
||
"id": "prt_cb19a941c001pvQg7eYC1COX2D",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19a58a3001J341UpO98HycxW"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Outlining implementation plan**",
|
||
"time": {
|
||
"start": 1772506223645,
|
||
"end": 1772506224152
|
||
},
|
||
"id": "prt_cb19a941d001qNwo3x2bitqlI7",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19a58a3001J341UpO98HycxW"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_lDw05qYn1N5hCXkwAmllu9qg",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go",
|
||
"offset": 1,
|
||
"limit": 60
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go</path>\n<type>file</type>\n<content>1: package handler\n2: \n3: import (\n4: \t\"context\"\n5: \t\"errors\"\n6: \t\"fmt\"\n7: \t\"net\"\n8: \t\"net/http\"\n9: \t\"sort\"\n10: \t\"strconv\"\n11: \t\"strings\"\n12: \t\"sync\"\n13: \t\"time\"\n14: \n15: \t\"go-backend/internal/http/client\"\n16: \t\"go-backend/internal/store/model\"\n17: \t\"go-backend/internal/ws\"\n18: )\n19: \n20: var errForwardNotFound = errors.New(\"forward not found\")\n21: \n22: type forwardRecord = model.ForwardRecord\n23: type tunnelRecord = model.TunnelRecord\n24: type forwardPortRecord = model.ForwardPortRecord\n25: type nodeRecord = model.NodeRecord\n26: \n27: type chainNodeRecord = model.ChainNodeRecord\n28: \n29: type diagnosisTarget struct {\n30: \tAddress string\n31: \tIP string\n32: \tPort int\n33: }\n34: \n35: type diagnosisWorkItem struct {\n36: \tfromNodeID int64\n37: \ttargetIP string\n38: \ttargetPort int\n39: \tdescription string\n40: \tmetadata map[string]interface{}\n41: \ttoNode chainNodeRecord\n42: \thasChainHop bool\n43: \tipPreference string\n44: }\n45: \n46: type diagnosisExecOptions struct {\n47: \tcommandTimeout time.Duration\n48: \tpingTimeoutMS int\n49: \ttimeoutMessage string\n50: }\n51: \n52: type diagnosisProgress struct {\n53: \tTotal int `json:\"total\"`\n54: \tCompleted int `json:\"completed\"`\n55: \tSuccess int `json:\"success\"`\n56: \tFailed int `json:\"failed\"`\n57: }\n58: \n59: type diagnosisItemEmitter func(index int, item map[string]interface{}, progress diagnosisProgress)\n60: \n\n(Showing lines 1-60 of 1458. Use offset=61 to continue.)\n</content>",
|
||
"title": "go-backend/internal/http/handler/control_plane.go",
|
||
"metadata": {
|
||
"preview": "package handler\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"go-backend/internal/http/client\"\n\t\"go-backend/internal/store/model\"\n\t\"go-backend/internal/ws\"\n)\n\nvar errForwardNotFound = errors.New(\"forward not found\")",
|
||
"truncated": true,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772506224022,
|
||
"end": 1772506224029
|
||
}
|
||
},
|
||
"id": "prt_cb19a9437001BYLzHYuhbGE02z",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19a58a3001J341UpO98HycxW"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "c58dbb5d75389b54b0dd0bd2f213e90fd813265e",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 56145,
|
||
"input": 55645,
|
||
"output": 500,
|
||
"reasoning": 424,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb19a961b001335xwFErLMtadk",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19a58a3001J341UpO98HycxW"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506224242,
|
||
"completed": 1772506237732
|
||
},
|
||
"parentID": "msg_cb19a2f030013k52GIXIBjDcHG",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 56787,
|
||
"input": 56252,
|
||
"output": 535,
|
||
"reasoning": 461,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb19a9672002tkP2JIKqo4bhZk",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "c58dbb5d75389b54b0dd0bd2f213e90fd813265e",
|
||
"id": "prt_cb19ac989001Lx6eGkIt4Fr8gP",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19a9672002tkP2JIKqo4bhZk"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Planning service address handling**",
|
||
"time": {
|
||
"start": 1772506237321,
|
||
"end": 1772506237641
|
||
},
|
||
"id": "prt_cb19ac989002VpkAZnw7CChxY8",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19a9672002tkP2JIKqo4bhZk"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_uy4gP41XoLwm6KBjRkrb10v0",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go",
|
||
"offset": 560,
|
||
"limit": 80
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go</path>\n<type>file</type>\n<content>560: \tOutFlow int64\n561: }\n562: \n563: // ExpiredUserTunnel holds minimal info for an expired user_tunnel row.\n564: type ExpiredUserTunnel struct {\n565: \tID int64\n566: \tUserID int64\n567: \tTunnelID int64\n568: }\n569: \n570: // UserTunnelDetail is a joined view of user_tunnel + tunnel + speed_limit.\n571: type UserTunnelDetail struct {\n572: \tID int64\n573: \tUserID int64\n574: \tTunnelID int64\n575: \tTunnelName string\n576: \tTunnelFlow int\n577: \tFlow int64\n578: \tInFlow int64\n579: \tOutFlow int64\n580: \tNum int\n581: \tFlowResetTime int64\n582: \tExpTime int64\n583: \tSpeedID sql.NullInt64\n584: \tSpeedLimit sql.NullString\n585: \tSpeed sql.NullInt64\n586: }\n587: \n588: // UserForwardDetail is a joined view of forward + tunnel.\n589: type UserForwardDetail struct {\n590: \tID int64\n591: \tName string\n592: \tTunnelID int64\n593: \tTunnelName string\n594: \tInIP string\n595: \tInPort sql.NullInt64\n596: \tRemoteAddr string\n597: \tInFlow int64\n598: \tOutFlow int64\n599: \tStatus int\n600: \tCreatedAt int64\n601: }\n\n(End of file - total 601 lines)\n</content>",
|
||
"title": "go-backend/internal/store/model/model.go",
|
||
"metadata": {
|
||
"preview": "\tOutFlow int64\n}\n\n// ExpiredUserTunnel holds minimal info for an expired user_tunnel row.\ntype ExpiredUserTunnel struct {\n\tID int64\n\tUserID int64\n\tTunnelID int64\n}\n\n// UserTunnelDetail is a joined view of user_tunnel + tunnel + speed_limit.\ntype UserTunnelDetail struct {\n\tID int64\n\tUserID int64\n\tTunnelID int64\n\tTunnelName string\n\tTunnelFlow int\n\tFlow int64\n\tInFlow int64\n\tOutFlow int64",
|
||
"truncated": false,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772506237523,
|
||
"end": 1772506237533
|
||
}
|
||
},
|
||
"id": "prt_cb19ac989003o52J0kOu4kOAGD",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19a9672002tkP2JIKqo4bhZk"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "c58dbb5d75389b54b0dd0bd2f213e90fd813265e",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 56787,
|
||
"input": 56252,
|
||
"output": 535,
|
||
"reasoning": 461,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb19acaca001HG1KfHx1EgmyxS",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19a9672002tkP2JIKqo4bhZk"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506237733,
|
||
"completed": 1772506249254
|
||
},
|
||
"parentID": "msg_cb19a2f030013k52GIXIBjDcHG",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "plan",
|
||
"agent": "plan",
|
||
"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": 57296,
|
||
"input": 56774,
|
||
"output": 522,
|
||
"reasoning": 55,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "stop",
|
||
"id": "msg_cb19acb25002PTD1acanXQ3LIH",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "c58dbb5d75389b54b0dd0bd2f213e90fd813265e",
|
||
"id": "prt_cb19ae41d001WqO1dsl5CfoIda",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19acb25002PTD1acanXQ3LIH"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Presenting implementation plan**",
|
||
"time": {
|
||
"start": 1772506244125,
|
||
"end": 1772506249156
|
||
},
|
||
"id": "prt_cb19ae41d002SMrMGUmOUTeDyE",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19acb25002PTD1acanXQ3LIH"
|
||
},
|
||
{
|
||
"type": "text",
|
||
"text": "收到,按你确认的语义执行:`connectIp` 同时用于“上游拨号目标 + 本节点监听绑定”。\n\n下面是实施计划(仅计划,不改代码):\n\n- 修改转发服务构建逻辑,让监听地址优先使用每条 `forward_port.in_ip`,不再固定落到 `node.TCPListenAddr/UDPListenAddr`。目标位置:`go-backend/internal/http/handler/control_plane.go:1306`。\n- 修改隧道链路服务构建逻辑,让 `chainNode.ConnectIP` 非空时优先作为本节点监听 host;为空时回退 `node.TCPListenAddr`。目标位置:`go-backend/internal/http/handler/mutations.go:2633`。\n- 保持现有拨号选择逻辑不变(你要求的“同时用于拨号”已具备):`selectTunnelDialHost` 继续对非空 `connectIp` 最高优先。位置:`go-backend/internal/http/handler/mutations.go:2661`。\n- 统一监听地址组装,走已有地址标准化(IPv6 自动加 `[]`),避免 `2001:db8::1:443` 这类格式错误。可复用 `processServerAddress`(`go-backend/internal/http/handler/control_plane.go:1371`)。\n- 增加单测覆盖(建议):\n - `buildForwardServiceConfigs`:指定 `inIp` 时监听地址应为指定 IP;未指定时回退默认监听地址。\n - `buildTunnelChainServiceConfig`:指定 `connectIp` 时监听地址绑定到该 IP;未指定回退 `TCPListenAddr`。\n - IPv4/IPv6 各一组,验证地址格式(尤其 IPv6 方括号)。\n 现有测试文件可扩展:`go-backend/internal/http/handler/control_plane_test.go`、`go-backend/internal/http/handler/dual_stack_test.go`。\n\n如果你同意,我下一步就按这个计划直接落地改动并跑 `go test ./internal/http/handler/...` 做回归验证。",
|
||
"time": {
|
||
"start": 1772506249159,
|
||
"end": 1772506249159
|
||
},
|
||
"id": "prt_cb19ae41e001550Ot8dIZvOCbs",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19acb25002PTD1acanXQ3LIH"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "stop",
|
||
"snapshot": "c58dbb5d75389b54b0dd0bd2f213e90fd813265e",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 57296,
|
||
"input": 56774,
|
||
"output": 522,
|
||
"reasoning": 55,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb19af7c9001iXO2XG1pSEkwuh",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19acb25002PTD1acanXQ3LIH"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "user",
|
||
"time": {
|
||
"created": 1772506290924
|
||
},
|
||
"summary": {
|
||
"diffs": [
|
||
{
|
||
"file": "go-backend/internal/http/handler/control_plane.go",
|
||
"before": "package handler\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"go-backend/internal/http/client\"\n\t\"go-backend/internal/store/model\"\n\t\"go-backend/internal/ws\"\n)\n\nvar errForwardNotFound = errors.New(\"forward not found\")\n\ntype forwardRecord = model.ForwardRecord\ntype tunnelRecord = model.TunnelRecord\ntype forwardPortRecord = model.ForwardPortRecord\ntype nodeRecord = model.NodeRecord\n\ntype chainNodeRecord = model.ChainNodeRecord\n\ntype diagnosisTarget struct {\n\tAddress string\n\tIP string\n\tPort int\n}\n\ntype diagnosisWorkItem struct {\n\tfromNodeID int64\n\ttargetIP string\n\ttargetPort int\n\tdescription string\n\tmetadata map[string]interface{}\n\ttoNode chainNodeRecord\n\thasChainHop bool\n\tipPreference string\n}\n\ntype diagnosisExecOptions struct {\n\tcommandTimeout time.Duration\n\tpingTimeoutMS int\n\ttimeoutMessage string\n}\n\ntype diagnosisProgress struct {\n\tTotal int `json:\"total\"`\n\tCompleted int `json:\"completed\"`\n\tSuccess int `json:\"success\"`\n\tFailed int `json:\"failed\"`\n}\n\ntype diagnosisItemEmitter func(index int, item map[string]interface{}, progress diagnosisProgress)\n\nfunc (h *Handler) buildDiagnosisStreamStartItems(workItems []diagnosisWorkItem) []map[string]interface{} {\n\tif len(workItems) == 0 {\n\t\treturn []map[string]interface{}{}\n\t}\n\n\tnodeCache := map[int64]*nodeRecord{}\n\titems := make([]map[string]interface{}, 0, len(workItems))\n\tfor _, workItem := range workItems {\n\t\ttargetIP := strings.TrimSpace(workItem.targetIP)\n\t\ttargetPort := workItem.targetPort\n\t\tif workItem.hasChainHop {\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\tif resolveErr == nil {\n\t\t\t\t\ttargetIP = resolvedIP\n\t\t\t\t\ttargetPort = resolvedPort\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif targetPort <= 0 {\n\t\t\ttargetPort = 443\n\t\t}\n\n\t\tnodeName := fmt.Sprintf(\"node_%d\", workItem.fromNodeID)\n\t\tif node, err := h.cachedNode(nodeCache, workItem.fromNodeID); err == nil && strings.TrimSpace(node.Name) != \"\" {\n\t\t\tnodeName = node.Name\n\t\t}\n\n\t\titem := map[string]interface{}{\n\t\t\t\"success\": false,\n\t\t\t\"diagnosing\": true,\n\t\t\t\"description\": workItem.description,\n\t\t\t\"nodeName\": nodeName,\n\t\t\t\"nodeId\": strconv.FormatInt(workItem.fromNodeID, 10),\n\t\t\t\"targetIp\": targetIP,\n\t\t\t\"targetPort\": targetPort,\n\t\t\t\"message\": \"诊断中...\",\n\t\t}\n\t\tfor key, value := range workItem.metadata {\n\t\t\titem[key] = value\n\t\t}\n\t\titems = append(items, item)\n\t}\n\n\treturn items\n}\n\nconst diagnosisMaxConcurrency = 8\n\nconst (\n\tdefaultNodeCommandTimeout = 6 * time.Second\n\tdiagnosisCommandTimeout = 30 * time.Second\n\tdiagnosisRequestTimeout = 2 * time.Minute\n\tdiagnosisCommandTimeoutMsg = \"诊断超时(30秒)\"\n\tdiagnosisRequestTimeoutMsg = \"诊断超时(2分钟)\"\n)\n\nfunc (h *Handler) resolveForwardAccess(r *http.Request, forwardID int64) (*forwardRecord, int64, int, error) {\n\tuserID, roleID, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\treturn nil, 0, 0, err\n\t}\n\tforward, err := h.ensureForwardAccessByActor(userID, roleID, forwardID)\n\tif err != nil {\n\t\treturn nil, userID, roleID, err\n\t}\n\treturn forward, userID, roleID, nil\n}\n\nfunc (h *Handler) ensureForwardAccessByActor(actorUserID int64, actorRole int, forwardID int64) (*forwardRecord, error) {\n\tforward, err := h.getForwardRecord(forwardID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif actorRole != 0 && forward.UserID != actorUserID {\n\t\treturn nil, errForwardNotFound\n\t}\n\treturn forward, nil\n}\n\nfunc (h *Handler) ensureTunnelPermission(userID int64, roleID int, tunnelID int64) error {\n\tif roleID == 0 {\n\t\treturn nil\n\t}\n\tok, err := h.repo.UserTunnelExistsByUserAndTunnel(userID, tunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !ok {\n\t\treturn errors.New(\"你没有该隧道的权限\")\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) getForwardRecord(forwardID int64) (*forwardRecord, error) {\n\tfr, err := h.repo.GetForwardRecord(forwardID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif fr == nil {\n\t\treturn nil, errForwardNotFound\n\t}\n\treturn fr, nil\n}\n\nfunc (h *Handler) getTunnelRecord(tunnelID int64) (*tunnelRecord, error) {\n\ttr, err := h.repo.GetTunnelRecord(tunnelID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif tr == nil {\n\t\treturn nil, errors.New(\"隧道不存在\")\n\t}\n\treturn tr, nil\n}\n\nfunc (h *Handler) listForwardsByTunnel(tunnelID int64) ([]forwardRecord, error) {\n\treturn h.repo.ListForwardsByTunnel(tunnelID)\n}\n\nfunc (h *Handler) listForwardPorts(forwardID int64) ([]forwardPortRecord, error) {\n\treturn h.repo.ListForwardPorts(forwardID)\n}\n\nfunc (h *Handler) isTunnelSelectedTLSProtocol(tunnelID int64) (bool, error) {\n\tprotocol, err := h.repo.GetTunnelOutProtocol(tunnelID)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn isTLSTunnelProtocol(protocol), nil\n}\n\nfunc (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {\n\tn, err := h.repo.GetNodeRecord(nodeID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif n == nil {\n\t\treturn nil, errors.New(\"节点不存在\")\n\t}\n\treturn n, nil\n}\n\nfunc (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *int64, *int, error) {\n\tinfo, err := h.repo.ResolveUserTunnelAndLimiter(userID, tunnelID)\n\tif err != nil {\n\t\treturn 0, nil, nil, err\n\t}\n\tif info == nil {\n\t\treturn 0, nil, nil, nil\n\t}\n\treturn info.UserTunnelID, info.LimiterID, info.Speed, nil\n}\n\nfunc (h *Handler) listUserTunnelIDs(userID, tunnelID int64) ([]int64, error) {\n\treturn h.repo.ListUserTunnelIDs(userID, tunnelID)\n}\n\nfunc (h *Handler) listUserTunnelIDsByUser(userID int64) ([]int64, error) {\n\treturn h.repo.ListUserTunnelIDsByUser(userID)\n}\n\nfunc (h *Handler) syncForwardServices(forward *forwardRecord, method string, allowFallbackAdd bool) error {\n\tif h == nil || forward == nil {\n\t\treturn errors.New(\"invalid forward sync context\")\n\t}\n\n\ttunnel, err := h.getTunnelRecord(forward.TunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tports, err := h.listForwardPorts(forward.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(ports) == 0 {\n\t\treturn errors.New(\"转发入口端口不存在\")\n\t}\n\n\t// Determine limiter from forward's SpeedID first, fallback to UserTunnel's limiter\n\tvar limiterID *int64\n\tvar speed *int\n\n\tif forward.SpeedID.Valid && forward.SpeedID.Int64 > 0 {\n\t\t// Forward has its own speed limit\n\t\tspeedVal, err := h.repo.GetSpeedLimitSpeed(forward.SpeedID.Int64)\n\t\tif err == nil && speedVal > 0 {\n\t\t\tlimiterID = &forward.SpeedID.Int64\n\t\t\tspeed = &speedVal\n\t\t}\n\t}\n\n\tif limiterID == nil {\n\t\t// Fall back to UserTunnel speed limit\n\t\tvar utLimiterID *int64\n\t\tvar utSpeed *int\n\t\t_, utLimiterID, utSpeed, err = h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tlimiterID = utLimiterID\n\t\tspeed = utSpeed\n\t}\n\n\tserviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)\n\ttunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, fp := range ports {\n\t\tif limiterID != nil && speed != nil {\n\t\t\tif err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\tnode, err := h.getNodeRecord(fp.NodeID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tservices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID, tunnelTLSProtocol)\n\t\t_, err = h.sendNodeCommand(node.ID, method, services, true, false)\n\t\tif err != nil && allowFallbackAdd && method == \"UpdateService\" {\n\t\t\t_, err = h.sendNodeCommand(node.ID, \"AddService\", services, true, false)\n\t\t}\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"节点 %s 下发失败: %w\", node.Name, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {\n\tif h == nil || forward == nil {\n\t\treturn errors.New(\"invalid forward control context\")\n\t}\n\tports, err := h.listForwardPorts(forward.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(ports) == 0 {\n\t\treturn nil\n\t}\n\tuserTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tuserTunnelIDs, err := h.listUserTunnelIDs(forward.UserID, forward.TunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tallUserTunnelIDs, err := h.listUserTunnelIDsByUser(forward.UserID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tcandidateTunnelIDs := make([]int64, 0, len(userTunnelIDs)+len(allUserTunnelIDs))\n\tcandidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)\n\tcandidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)\n\tbases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)\n\tseen := map[int64]struct{}{}\n\tfor _, fp := range ports {\n\t\tif _, ok := seen[fp.NodeID]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[fp.NodeID] = struct{}{}\n\n\t\tvar lastNotFoundErr error\n\t\tnodeHandled := false\n\n\t\tfor _, base := range bases {\n\t\t\tvariants := []string{base + \"_tcp\", base + \"_udp\"}\n\t\t\tif shouldTryLegacySingleService(commandType) || strings.EqualFold(strings.TrimSpace(commandType), \"DeleteService\") {\n\t\t\t\tvariants = append(variants, base)\n\t\t\t}\n\n\t\t\tcandidateHandled := false\n\t\t\tfor _, name := range variants {\n\t\t\t\tpayload := map[string]interface{}{\n\t\t\t\t\t\"services\": []string{name},\n\t\t\t\t}\n\t\t\t\t_, err := h.sendNodeCommand(fp.NodeID, commandType, payload, false, false)\n\t\t\t\tif err == nil {\n\t\t\t\t\tcandidateHandled = true\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif !isNotFoundError(err) {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tlastNotFoundErr = err\n\t\t\t}\n\n\t\t\tif candidateHandled {\n\t\t\t\tnodeHandled = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif nodeHandled {\n\t\t\tcontinue\n\t\t}\n\t\tif tolerateNotFound {\n\t\t\tcontinue\n\t\t}\n\t\tif lastNotFoundErr != nil {\n\t\t\treturn lastNotFoundErr\n\t\t}\n\t\treturn errors.New(\"service control failed\")\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) applyNodeProtocolChange(nodeID int64, httpVal, tlsVal, socksVal int) error {\n\t_, err := h.sendNodeCommand(nodeID, \"SetProtocol\", map[string]interface{}{\n\t\t\"http\": httpVal,\n\t\t\"tls\": tlsVal,\n\t\t\"socks\": socksVal,\n\t}, false, false)\n\treturn err\n}\n\nfunc (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interface{}, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {\n\treturn h.sendNodeCommandWithTimeout(nodeID, commandType, data, defaultNodeCommandTimeout, tolerateExists, tolerateNotFound)\n}\n\nfunc (h *Handler) sendNodeCommandWithTimeout(nodeID int64, commandType string, data interface{}, timeout time.Duration, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {\n\tvar (\n\t\tresult ws.CommandResult\n\t\terr error\n\t)\n\tif timeout <= 0 {\n\t\ttimeout = defaultNodeCommandTimeout\n\t}\n\n\tnode, nodeErr := h.getNodeRecord(nodeID)\n\tif nodeErr == nil && node != nil && node.IsRemote == 1 {\n\t\tresult, err = h.sendRemoteNodeCommandWithTimeout(node, commandType, data, timeout)\n\t} else {\n\t\tresult, err = h.wsServer.SendCommand(nodeID, commandType, data, timeout)\n\t}\n\tif err == nil {\n\t\treturn result, nil\n\t}\n\tmsg := strings.ToLower(strings.TrimSpace(err.Error()))\n\tif tolerateExists {\n\t\tif isAlreadyExistsMessage(msg) {\n\t\t\treturn result, nil\n\t\t}\n\t}\n\tif tolerateNotFound {\n\t\tif strings.Contains(msg, \"not found\") || strings.Contains(msg, \"不存在\") {\n\t\t\treturn result, nil\n\t\t}\n\t}\n\treturn result, err\n}\n\nfunc (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, data interface{}) (ws.CommandResult, error) {\n\treturn h.sendRemoteNodeCommandWithTimeout(node, commandType, data, 0)\n}\n\nfunc (h *Handler) sendRemoteNodeCommandWithTimeout(node *nodeRecord, commandType string, data interface{}, timeout time.Duration) (ws.CommandResult, error) {\n\tif node == nil {\n\t\treturn ws.CommandResult{}, errors.New(\"节点不存在\")\n\t}\n\tremoteURL := strings.TrimSpace(node.RemoteURL)\n\tremoteToken := strings.TrimSpace(node.RemoteToken)\n\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\treturn ws.CommandResult{}, errors.New(\"远程节点缺少共享配置\")\n\t}\n\n\tfc := client.NewFederationClient()\n\tif timeout > 0 {\n\t\tfc = client.NewFederationClientWithTimeout(timeout)\n\t}\n\tres, err := fc.Command(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeNodeCommandRequest{\n\t\tCommandType: commandType,\n\t\tData: data,\n\t})\n\tif err != nil {\n\t\treturn ws.CommandResult{}, err\n\t}\n\tif res == nil {\n\t\treturn ws.CommandResult{}, errors.New(\"远程节点未返回命令结果\")\n\t}\n\n\tresult := ws.CommandResult{\n\t\tType: res.Type,\n\t\tSuccess: res.Success,\n\t\tMessage: res.Message,\n\t\tData: res.Data,\n\t}\n\tif !result.Success {\n\t\tmsg := strings.TrimSpace(result.Message)\n\t\tif msg == \"\" {\n\t\t\tmsg = \"命令执行失败\"\n\t\t}\n\t\treturn result, errors.New(msg)\n\t}\n\treturn result, nil\n}\n\nfunc (h *Handler) diagnoseForwardRuntime(ctx context.Context, forward *forwardRecord) (map[string]interface{}, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tforwardName, workItems, err := h.prepareForwardDiagnosis(forward)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresults := h.runDiagnosisWorkItems(ctx, workItems, nil)\n\n\tpayload := map[string]interface{}{\n\t\t\"forwardName\": forwardName,\n\t\t\"timestamp\": time.Now().UnixMilli(),\n\t\t\"results\": results,\n\t}\n\treturn payload, nil\n}\n\nfunc (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []diagnosisWorkItem, error) {\n\tif forward == nil {\n\t\treturn \"\", nil, errForwardNotFound\n\t}\n\ttargets, err := resolveDiagnosisTargets(forward.RemoteAddr)\n\tif err != nil {\n\t\treturn \"\", nil, err\n\t}\n\n\ttunnel, err := h.getTunnelRecord(forward.TunnelID)\n\tif err != nil {\n\t\treturn \"\", nil, err\n\t}\n\n\tchainRows, err := h.listChainNodesForTunnel(forward.TunnelID)\n\tif err != nil {\n\t\treturn \"\", nil, err\n\t}\n\tif len(chainRows) == 0 {\n\t\treturn \"\", nil, errors.New(\"隧道配置不完整\")\n\t}\n\n\tipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)\n\n\tinNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)\n\tworkItems := make([]diagnosisWorkItem, 0, len(chainRows)*2+len(targets))\n\n\tswitch tunnel.Type {\n\tcase 1:\n\t\tfor _, inNode := range inNodes {\n\t\t\tfor _, target := range targets {\n\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->目标(%s)\", inNode.NodeName, target.Address)\n\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\ttargetIP: target.IP,\n\t\t\t\t\ttargetPort: target.Port,\n\t\t\t\t\tdescription: description,\n\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\tcase 2:\n\t\tfor _, inNode := range inNodes {\n\t\t\tif len(chainHops) > 0 {\n\t\t\t\tfor _, firstNode := range chainHops[0] {\n\t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->第1跳(%s)\", inNode.NodeName, firstNode.NodeName)\n\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\t\ttoNode: firstNode,\n\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t\t\t\"toChainType\": 2,\n\t\t\t\t\t\t\t\"toInx\": firstNode.Inx,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tfor _, outNode := range outNodes {\n\t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->出口(%s)\", inNode.NodeName, outNode.NodeName)\n\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\t\ttoNode: outNode,\n\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t\t\t\"toChainType\": 3,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor i, hop := range chainHops {\n\t\t\tfor _, currentNode := range hop {\n\t\t\t\tif i+1 < len(chainHops) {\n\t\t\t\t\tfor _, nextNode := range chainHops[i+1] {\n\t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->第%d跳(%s)\", i+1, currentNode.NodeName, i+2, nextNode.NodeName)\n\t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n\t\t\t\t\t\t\ttoNode: nextNode,\n\t\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\t\"fromChainType\": 2,\n\t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n\t\t\t\t\t\t\t\t\"toChainType\": 2,\n\t\t\t\t\t\t\t\t\"toInx\": nextNode.Inx,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tfor _, outNode := range outNodes {\n\t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->出口(%s)\", i+1, currentNode.NodeName, outNode.NodeName)\n\t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n\t\t\t\t\t\t\ttoNode: outNode,\n\t\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\t\"fromChainType\": 2,\n\t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n\t\t\t\t\t\t\t\t\"toChainType\": 3,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor _, outNode := range outNodes {\n\t\t\tfor _, target := range targets {\n\t\t\t\tdescription := fmt.Sprintf(\"出口(%s)->目标(%s)\", outNode.NodeName, target.Address)\n\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\tfromNodeID: outNode.NodeID,\n\t\t\t\t\ttargetIP: target.IP,\n\t\t\t\t\ttargetPort: target.Port,\n\t\t\t\t\tdescription: description,\n\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\"fromChainType\": 3,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\tdefault:\n\t\tfor _, inNode := range inNodes {\n\t\t\tfor _, target := range targets {\n\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->目标(%s)\", inNode.NodeName, target.Address)\n\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\ttargetIP: target.IP,\n\t\t\t\t\ttargetPort: target.Port,\n\t\t\t\t\tdescription: description,\n\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\treturn forward.Name, workItems, nil\n}\n\nfunc (h *Handler) diagnoseTunnelRuntime(ctx context.Context, tunnelID int64) (map[string]interface{}, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\ttunnelName, tunnelType, workItems, err := h.prepareTunnelDiagnosis(tunnelID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresults := h.runDiagnosisWorkItems(ctx, workItems, nil)\n\n\tpayload := map[string]interface{}{\n\t\t\"tunnelName\": tunnelName,\n\t\t\"tunnelType\": tunnelType,\n\t\t\"timestamp\": time.Now().UnixMilli(),\n\t\t\"results\": results,\n\t}\n\treturn payload, nil\n}\n\nfunc (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diagnosisWorkItem, error) {\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\treturn \"\", \"\", nil, err\n\t}\n\n\ttunnelName, err := h.repo.GetTunnelName(tunnelID)\n\tif err != nil {\n\t\treturn \"\", \"\", nil, err\n\t}\n\tif tunnelName == \"\" {\n\t\treturn \"\", \"\", nil, errors.New(\"隧道不存在\")\n\t}\n\n\tchainRows, err := h.listChainNodesForTunnel(tunnelID)\n\tif err != nil {\n\t\treturn \"\", \"\", nil, err\n\t}\n\tif len(chainRows) == 0 {\n\t\treturn \"\", \"\", nil, errors.New(\"隧道配置不完整\")\n\t}\n\n\tipPreference := h.repo.GetTunnelIPPreference(tunnelID)\n\tinNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)\n\tworkItems := make([]diagnosisWorkItem, 0, len(chainRows)*2)\n\n\tswitch tunnel.Type {\n\tcase 1:\n\t\tfor _, inNode := range inNodes {\n\t\t\tdescription := fmt.Sprintf(\"入口(%s)->外网\", inNode.NodeName)\n\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\ttargetIP: \"www.bing.com\",\n\t\t\t\ttargetPort: 443,\n\t\t\t\tdescription: description,\n\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\tcase 2:\n\t\tfor _, inNode := range inNodes {\n\t\t\tif len(chainHops) > 0 {\n\t\t\t\tfor _, firstNode := range chainHops[0] {\n\t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->第1跳(%s)\", inNode.NodeName, firstNode.NodeName)\n\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\t\ttoNode: firstNode,\n\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t\t\t\"toChainType\": 2,\n\t\t\t\t\t\t\t\"toInx\": firstNode.Inx,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tfor _, outNode := range outNodes {\n\t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->出口(%s)\", inNode.NodeName, outNode.NodeName)\n\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\t\ttoNode: outNode,\n\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t\t\t\"toChainType\": 3,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor i, hop := range chainHops {\n\t\t\tfor _, currentNode := range hop {\n\t\t\t\tif i+1 < len(chainHops) {\n\t\t\t\t\tfor _, nextNode := range chainHops[i+1] {\n\t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->第%d跳(%s)\", i+1, currentNode.NodeName, i+2, nextNode.NodeName)\n\t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n\t\t\t\t\t\t\ttoNode: nextNode,\n\t\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\t\"fromChainType\": 2,\n\t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n\t\t\t\t\t\t\t\t\"toChainType\": 2,\n\t\t\t\t\t\t\t\t\"toInx\": nextNode.Inx,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tfor _, outNode := range outNodes {\n\t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->出口(%s)\", i+1, currentNode.NodeName, outNode.NodeName)\n\t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n\t\t\t\t\t\t\ttoNode: outNode,\n\t\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\t\"fromChainType\": 2,\n\t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n\t\t\t\t\t\t\t\t\"toChainType\": 3,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor _, outNode := range outNodes {\n\t\t\tdescription := fmt.Sprintf(\"出口(%s)->外网\", outNode.NodeName)\n\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\tfromNodeID: outNode.NodeID,\n\t\t\t\ttargetIP: \"www.bing.com\",\n\t\t\t\ttargetPort: 443,\n\t\t\t\tdescription: description,\n\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\"fromChainType\": 3,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\tdefault:\n\t\tfor _, inNode := range inNodes {\n\t\t\tdescription := fmt.Sprintf(\"入口(%s)->外网\", inNode.NodeName)\n\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\ttargetIP: \"www.bing.com\",\n\t\t\t\ttargetPort: 443,\n\t\t\t\tdescription: description,\n\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\t}\n\n\ttunnelType := map[bool]string{true: \"端口转发\", false: \"隧道转发\"}[tunnel.Type == 1]\n\treturn tunnelName, tunnelType, workItems, nil\n}\n\nfunc splitChainNodeGroups(rows []chainNodeRecord) ([]chainNodeRecord, [][]chainNodeRecord, []chainNodeRecord) {\n\tinNodes := make([]chainNodeRecord, 0)\n\toutNodes := make([]chainNodeRecord, 0)\n\tchainByInx := map[int64][]chainNodeRecord{}\n\thopOrder := make([]int64, 0)\n\n\tfor _, row := range rows {\n\t\tswitch row.ChainType {\n\t\tcase 1:\n\t\t\tinNodes = append(inNodes, row)\n\t\tcase 2:\n\t\t\tif _, ok := chainByInx[row.Inx]; !ok {\n\t\t\t\thopOrder = append(hopOrder, row.Inx)\n\t\t\t}\n\t\t\tchainByInx[row.Inx] = append(chainByInx[row.Inx], row)\n\t\tcase 3:\n\t\t\toutNodes = append(outNodes, row)\n\t\t}\n\t}\n\n\tsort.Slice(hopOrder, func(i, j int) bool { return hopOrder[i] < hopOrder[j] })\n\tchainHops := make([][]chainNodeRecord, 0, len(hopOrder))\n\tfor _, inx := range hopOrder {\n\t\tchainHops = append(chainHops, chainByInx[inx])\n\t}\n\n\treturn inNodes, chainHops, outNodes\n}\n\nfunc resolveDiagnosisTargets(remoteAddr string) ([]diagnosisTarget, error) {\n\trawTargets := splitRemoteTargets(remoteAddr)\n\tif len(rawTargets) == 0 {\n\t\treturn nil, errors.New(\"目标地址不能为空\")\n\t}\n\n\ttargets := make([]diagnosisTarget, 0, len(rawTargets))\n\tfor _, raw := range rawTargets {\n\t\tip, port, err := parseTargetAddress(raw)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttargets = append(targets, diagnosisTarget{Address: raw, IP: ip, Port: port})\n\t}\n\tif len(targets) == 0 {\n\t\treturn nil, errors.New(\"目标地址格式错误\")\n\t}\n\treturn targets, nil\n}\n\nfunc diagnosisContextMessage(ctx context.Context) string {\n\tif ctx == nil {\n\t\treturn diagnosisRequestTimeoutMsg\n\t}\n\tswitch ctx.Err() {\n\tcase context.DeadlineExceeded:\n\t\treturn diagnosisRequestTimeoutMsg\n\tcase context.Canceled:\n\t\treturn \"诊断已取消\"\n\tdefault:\n\t\treturn diagnosisRequestTimeoutMsg\n\t}\n}\n\nfunc diagnosisExecOptionsFromContext(ctx context.Context) diagnosisExecOptions {\n\ttimeout := diagnosisCommandTimeout\n\tif ctx != nil {\n\t\tif deadline, ok := ctx.Deadline(); ok {\n\t\t\tremaining := time.Until(deadline)\n\t\t\tif remaining <= 0 {\n\t\t\t\tremaining = 100 * time.Millisecond\n\t\t\t}\n\t\t\tif remaining < timeout {\n\t\t\t\ttimeout = remaining\n\t\t\t}\n\t\t}\n\t}\n\tif timeout <= 0 {\n\t\ttimeout = 100 * time.Millisecond\n\t}\n\tpingTimeoutMS := int(timeout / time.Millisecond)\n\tif pingTimeoutMS <= 0 {\n\t\tpingTimeoutMS = 100\n\t}\n\treturn diagnosisExecOptions{\n\t\tcommandTimeout: timeout,\n\t\tpingTimeoutMS: pingTimeoutMS,\n\t\ttimeoutMessage: diagnosisContextMessage(ctx),\n\t}\n}\n\nfunc newDiagnosisTimeoutItem(workItem diagnosisWorkItem, message string) map[string]interface{} {\n\ttargetPort := workItem.targetPort\n\tif targetPort <= 0 {\n\t\ttargetPort = workItem.toNode.Port\n\t}\n\titem := newDiagnosisResultItem(workItem.fromNodeID, workItem.targetIP, targetPort, workItem.description, workItem.metadata)\n\titem[\"success\"] = false\n\tif strings.TrimSpace(message) == \"\" {\n\t\tmessage = diagnosisCommandTimeoutMsg\n\t}\n\titem[\"message\"] = message\n\treturn item\n}\n\nfunc (h *Handler) executeDiagnosisWorkItem(workItem diagnosisWorkItem, options diagnosisExecOptions) map[string]interface{} {\n\tsingle := make([]map[string]interface{}, 0, 1)\n\tnodeCache := map[int64]*nodeRecord{}\n\tif workItem.hasChainHop {\n\t\th.appendChainHopDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.toNode, workItem.description, workItem.metadata, workItem.ipPreference, options)\n\t} else {\n\t\th.appendPathDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.targetIP, workItem.targetPort, workItem.description, workItem.metadata, options)\n\t}\n\n\tif len(single) == 0 {\n\t\treturn newDiagnosisTimeoutItem(workItem, \"诊断任务未返回结果\")\n\t}\n\treturn single[0]\n}\n\nfunc (h *Handler) runDiagnosisWorkItems(ctx context.Context, workItems []diagnosisWorkItem, emitter diagnosisItemEmitter) []map[string]interface{} {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tresults := make([]map[string]interface{}, len(workItems))\n\tif len(workItems) == 0 {\n\t\treturn results\n\t}\n\n\tworkerLimit := diagnosisMaxConcurrency\n\tif workerLimit < 1 {\n\t\tworkerLimit = 1\n\t}\n\tif workerLimit > len(workItems) {\n\t\tworkerLimit = len(workItems)\n\t}\n\n\ttype diagnosisWorkResult struct {\n\t\tindex int\n\t\titem map[string]interface{}\n\t}\n\n\tjobs := make(chan int)\n\tresultCh := make(chan diagnosisWorkResult, len(workItems))\n\n\tvar wg sync.WaitGroup\n\tfor i := 0; i < workerLimit; i++ {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tfor index := range jobs {\n\t\t\t\tselect {\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\tresultCh <- diagnosisWorkResult{index: index, item: newDiagnosisTimeoutItem(workItems[index], diagnosisContextMessage(ctx))}\n\t\t\t\t\tcontinue\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\toptions := diagnosisExecOptionsFromContext(ctx)\n\t\t\t\tresultCh <- diagnosisWorkResult{index: index, item: h.executeDiagnosisWorkItem(workItems[index], options)}\n\t\t\t}\n\t\t}()\n\t}\n\nenqueueLoop:\n\tfor i := 0; i < len(workItems); i++ {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tmessage := diagnosisContextMessage(ctx)\n\t\t\tfor j := i; j < len(workItems); j++ {\n\t\t\t\tresultCh <- diagnosisWorkResult{index: j, item: newDiagnosisTimeoutItem(workItems[j], message)}\n\t\t\t}\n\t\t\tbreak enqueueLoop\n\t\tcase jobs <- i:\n\t\t}\n\t}\n\tclose(jobs)\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultCh)\n\t}()\n\n\tprogress := diagnosisProgress{Total: len(workItems)}\n\tfor result := range resultCh {\n\t\tresults[result.index] = result.item\n\t\tprogress.Completed++\n\t\tif asBool(result.item[\"success\"], false) {\n\t\t\tprogress.Success++\n\t\t} else {\n\t\t\tprogress.Failed++\n\t\t}\n\t\tif emitter != nil {\n\t\t\temitter(result.index, result.item, progress)\n\t\t}\n\t}\n\n\tfor i := range results {\n\t\tif results[i] == nil {\n\t\t\tresults[i] = newDiagnosisTimeoutItem(workItems[i], diagnosisCommandTimeoutMsg)\n\t\t}\n\t}\n\treturn results\n}\n\nfunc (h *Handler) cachedNode(nodeCache map[int64]*nodeRecord, nodeID int64) (*nodeRecord, error) {\n\tif node, ok := nodeCache[nodeID]; ok {\n\t\treturn node, nil\n\t}\n\tnode, err := h.getNodeRecord(nodeID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tnodeCache[nodeID] = node\n\treturn node, nil\n}\n\nfunc newDiagnosisResultItem(fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}) map[string]interface{} {\n\titem := map[string]interface{}{\n\t\t\"nodeName\": fmt.Sprintf(\"node_%d\", fromNodeID),\n\t\t\"nodeId\": strconv.FormatInt(fromNodeID, 10),\n\t\t\"targetIp\": targetIP,\n\t\t\"targetPort\": targetPort,\n\t\t\"description\": description,\n\t\t\"averageTime\": 0,\n\t\t\"packetLoss\": 100,\n\t}\n\tfor k, v := range metadata {\n\t\titem[k] = v\n\t}\n\treturn item\n}\n\nfunc (h *Handler) appendFailedDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}, message string) {\n\titem := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)\n\tif node, err := h.cachedNode(nodeCache, fromNodeID); err == nil {\n\t\titem[\"nodeName\"] = node.Name\n\t}\n\tif strings.TrimSpace(message) == \"\" {\n\t\tmessage = \"TCP连接失败\"\n\t}\n\titem[\"success\"] = false\n\titem[\"message\"] = message\n\t*results = append(*results, item)\n}\n\nfunc (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}, options diagnosisExecOptions) {\n\titem := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)\n\n\tfromNode, err := h.cachedNode(nodeCache, fromNodeID)\n\tif err != nil {\n\t\titem[\"success\"] = false\n\t\titem[\"message\"] = err.Error()\n\t\t*results = append(*results, item)\n\t\treturn\n\t}\n\titem[\"nodeName\"] = fromNode.Name\n\n\tvar (\n\t\tpingData map[string]interface{}\n\t\tpingErr error\n\t)\n\tif fromNode.IsRemote == 1 {\n\t\tpingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort, options)\n\t} else {\n\t\tpingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort, options)\n\t}\n\tif pingErr != nil {\n\t\titem[\"success\"] = false\n\t\titem[\"message\"] = pingErr.Error()\n\t\t*results = append(*results, item)\n\t\treturn\n\t}\n\n\tsuccess := asBool(pingData[\"success\"], false)\n\titem[\"success\"] = success\n\titem[\"averageTime\"] = asFloat(pingData[\"averageTime\"], 0)\n\titem[\"packetLoss\"] = asFloat(pingData[\"packetLoss\"], 100)\n\n\tmessage := strings.TrimSpace(asString(pingData[\"message\"]))\n\tif success {\n\t\tif message == \"\" {\n\t\t\tmessage = \"TCP连接成功\"\n\t\t}\n\t} else {\n\t\tif message == \"\" {\n\t\t\tmessage = strings.TrimSpace(asString(pingData[\"errorMessage\"]))\n\t\t}\n\t\tif message == \"\" {\n\t\t\tmessage = \"TCP连接失败\"\n\t\t}\n\t}\n\titem[\"message\"] = message\n\t*results = append(*results, item)\n}\n\nfunc (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, toNode chainNodeRecord, description string, metadata map[string]interface{}, ipPreference string, options diagnosisExecOptions) {\n\tfromNode, _ := h.cachedNode(nodeCache, fromNodeID)\n\ttargetNode, err := h.cachedNode(nodeCache, toNode.NodeID)\n\tif err != nil {\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\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\t}\n\th.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata, options)\n}\n\nfunc 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, connectIp)\n\tif err != nil {\n\t\thost = strings.Trim(strings.TrimSpace(targetNode.ServerIP), \"[]\")\n\t}\n\tif host == \"\" {\n\t\treturn \"\", 0, errors.New(\"目标节点地址为空\")\n\t}\n\tport := preferredPort\n\tif port <= 0 {\n\t\tport = firstPortFromRange(targetNode.PortRange)\n\t}\n\tif port <= 0 {\n\t\tport = 443\n\t}\n\treturn host, port, nil\n}\n\nfunc firstPortFromRange(portRange string) int {\n\tportRange = strings.TrimSpace(portRange)\n\tif portRange == \"\" {\n\t\treturn 0\n\t}\n\tfirst := strings.Split(portRange, \",\")[0]\n\tfirst = strings.TrimSpace(first)\n\tif strings.Contains(first, \"-\") {\n\t\tparts := strings.SplitN(first, \"-\", 2)\n\t\tif len(parts) != 2 {\n\t\t\treturn 0\n\t\t}\n\t\tp, err := strconv.Atoi(strings.TrimSpace(parts[0]))\n\t\tif err != nil || p <= 0 {\n\t\t\treturn 0\n\t\t}\n\t\treturn p\n\t}\n\tp, err := strconv.Atoi(first)\n\tif err != nil || p <= 0 {\n\t\treturn 0\n\t}\n\treturn p\n}\n\nfunc (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, error) {\n\treturn h.repo.ListChainNodesForTunnel(tunnelID)\n}\n\nfunc (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {\n\tif options.commandTimeout <= 0 {\n\t\toptions.commandTimeout = diagnosisCommandTimeout\n\t}\n\tif options.pingTimeoutMS <= 0 {\n\t\toptions.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)\n\t}\n\tres, err := h.sendNodeCommandWithTimeout(nodeID, \"TcpPing\", map[string]interface{}{\n\t\t\"ip\": ip,\n\t\t\"port\": port,\n\t\t\"count\": 4,\n\t\t\"timeout\": options.pingTimeoutMS,\n\t}, options.commandTimeout, false, false)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif res.Data == nil {\n\t\treturn nil, errors.New(\"节点未返回诊断数据\")\n\t}\n\treturn res.Data, nil\n}\n\nfunc (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {\n\tif node == nil {\n\t\treturn nil, errors.New(\"节点不存在\")\n\t}\n\tremoteURL := strings.TrimSpace(node.RemoteURL)\n\tremoteToken := strings.TrimSpace(node.RemoteToken)\n\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\treturn nil, errors.New(\"远程节点缺少共享配置\")\n\t}\n\tif options.commandTimeout <= 0 {\n\t\toptions.commandTimeout = diagnosisCommandTimeout\n\t}\n\tif options.pingTimeoutMS <= 0 {\n\t\toptions.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)\n\t}\n\n\tfc := client.NewFederationClientWithTimeout(options.commandTimeout)\n\treturn fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{\n\t\tIP: strings.TrimSpace(ip),\n\t\tPort: port,\n\t\tCount: 4,\n\t\tTimeout: options.pingTimeoutMS,\n\t})\n}\n\nfunc splitRemoteTargets(remoteAddr string) []string {\n\tparts := strings.Split(remoteAddr, \",\")\n\tout := make([]string, 0, len(parts))\n\tfor _, part := range parts {\n\t\tpart = strings.TrimSpace(part)\n\t\tif part == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, processServerAddress(part))\n\t}\n\treturn out\n}\n\nfunc parseTargetAddress(addr string) (string, int, error) {\n\taddr = strings.TrimSpace(addr)\n\tif addr == \"\" {\n\t\treturn \"\", 0, errors.New(\"empty address\")\n\t}\n\thost, portStr, err := net.SplitHostPort(addr)\n\tif err != nil {\n\t\tidx := strings.LastIndex(addr, \":\")\n\t\tif idx <= 0 || idx >= len(addr)-1 {\n\t\t\treturn \"\", 0, err\n\t\t}\n\t\thost = strings.TrimSpace(addr[:idx])\n\t\tportStr = strings.TrimSpace(addr[idx+1:])\n\t}\n\tport, err := strconv.Atoi(strings.TrimSpace(portStr))\n\tif err != nil || port <= 0 || port > 65535 {\n\t\treturn \"\", 0, errors.New(\"invalid port\")\n\t}\n\thost = strings.Trim(strings.TrimSpace(host), \"[]\")\n\tif host == \"\" {\n\t\treturn \"\", 0, errors.New(\"invalid host\")\n\t}\n\treturn host, port, nil\n}\n\nfunc buildForwardServiceBase(forwardID, userID, userTunnelID int64) string {\n\treturn fmt.Sprintf(\"%d_%d_%d\", forwardID, userID, userTunnelID)\n}\n\nfunc buildForwardServiceBaseCandidates(forwardID, userID, preferredUserTunnelID int64, userTunnelIDs []int64) []string {\n\torderedIDs := make([]int64, 0, len(userTunnelIDs)+2)\n\tseen := make(map[int64]struct{}, len(userTunnelIDs)+2)\n\n\tappendID := func(id int64) {\n\t\tif _, ok := seen[id]; ok {\n\t\t\treturn\n\t\t}\n\t\tseen[id] = struct{}{}\n\t\torderedIDs = append(orderedIDs, id)\n\t}\n\n\tappendID(preferredUserTunnelID)\n\tfor _, id := range userTunnelIDs {\n\t\tappendID(id)\n\t}\n\tappendID(0)\n\n\tbases := make([]string, 0, len(orderedIDs))\n\tfor _, id := range orderedIDs {\n\t\tbases = append(bases, buildForwardServiceBase(forwardID, userID, id))\n\t}\n\treturn bases\n}\n\nfunc buildForwardControlServiceNames(base, commandType string) []string {\n\tnames := []string{base + \"_tcp\", base + \"_udp\"}\n\tif strings.EqualFold(strings.TrimSpace(commandType), \"DeleteService\") {\n\t\treturn append([]string{base}, names...)\n\t}\n\treturn names\n}\n\nfunc shouldTryLegacySingleService(commandType string) bool {\n\tcmd := strings.ToLower(strings.TrimSpace(commandType))\n\treturn cmd == \"pauseservice\" || cmd == \"resumeservice\"\n}\n\nfunc isNotFoundError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tmsg := strings.ToLower(strings.TrimSpace(err.Error()))\n\treturn strings.Contains(msg, \"not found\") || strings.Contains(msg, \"不存在\")\n}\n\nfunc isAlreadyExistsMessage(message string) bool {\n\tmsg := strings.ToLower(strings.TrimSpace(message))\n\tif msg == \"\" {\n\t\treturn false\n\t}\n\tif strings.Contains(msg, \"address already in use\") {\n\t\treturn false\n\t}\n\treturn strings.Contains(msg, \"already exists\") || strings.Contains(msg, \"已存在\")\n}\n\nfunc buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {\n\tprotocols := []string{\"tcp\", \"udp\"}\n\tservices := make([]map[string]interface{}, 0, 2)\n\ttargets := splitRemoteTargets(forward.RemoteAddr)\n\tstrategy := strings.TrimSpace(forward.Strategy)\n\tif strategy == \"\" {\n\t\tstrategy = \"fifo\"\n\t}\n\n\tfor _, protocol := range protocols {\n\t\tlistenerAddr := node.TCPListenAddr\n\t\tif protocol == \"udp\" {\n\t\t\tlistenerAddr = node.UDPListenAddr\n\t\t}\n\t\tservice := map[string]interface{}{\n\t\t\t\"name\": fmt.Sprintf(\"%s_%s\", baseName, protocol),\n\t\t\t\"addr\": fmt.Sprintf(\"%s:%d\", listenerAddr, port),\n\t\t\t\"handler\": map[string]interface{}{\n\t\t\t\t\"type\": protocol,\n\t\t\t},\n\t\t\t\"listener\": map[string]interface{}{\n\t\t\t\t\"type\": protocol,\n\t\t\t},\n\t\t\t\"forwarder\": map[string]interface{}{\n\t\t\t\t\"nodes\": buildForwarderNodes(targets),\n\t\t\t\t\"selector\": map[string]interface{}{\n\t\t\t\t\t\"strategy\": strategy,\n\t\t\t\t\t\"maxFails\": 1,\n\t\t\t\t\t\"failTimeout\": \"600s\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tif protocol == \"udp\" {\n\t\t\tlistenerMetadata := map[string]interface{}{\"keepAlive\": true}\n\t\t\tif tunnelTLSProtocol {\n\t\t\t\tlistenerMetadata[\"ttl\"] = \"10s\"\n\t\t\t}\n\t\t\tservice[\"listener\"].(map[string]interface{})[\"metadata\"] = listenerMetadata\n\t\t}\n\t\tif tunnel != nil && tunnel.Type == 2 {\n\t\t\tservice[\"handler\"].(map[string]interface{})[\"chain\"] = fmt.Sprintf(\"chains_%d\", forward.TunnelID)\n\t\t}\n\t\tif tunnel != nil && tunnel.Type == 1 && strings.TrimSpace(node.InterfaceName) != \"\" {\n\t\t\tservice[\"metadata\"] = map[string]interface{}{\"interface\": node.InterfaceName}\n\t\t}\n\t\tif limiterID != nil && *limiterID > 0 {\n\t\t\tservice[\"limiter\"] = strconv.FormatInt(*limiterID, 10)\n\t\t}\n\t\tservices = append(services, service)\n\t}\n\n\treturn services\n}\n\nfunc buildForwarderNodes(targets []string) []map[string]interface{} {\n\tnodes := make([]map[string]interface{}, 0, len(targets))\n\tfor i, addr := range targets {\n\t\tnodes = append(nodes, map[string]interface{}{\n\t\t\t\"name\": fmt.Sprintf(\"node_%d\", i+1),\n\t\t\t\"addr\": addr,\n\t\t})\n\t}\n\treturn nodes\n}\n\nfunc processServerAddress(serverAddr string) string {\n\tserverAddr = strings.TrimSpace(serverAddr)\n\tif serverAddr == \"\" {\n\t\treturn serverAddr\n\t}\n\tif strings.HasPrefix(serverAddr, \"[\") {\n\t\treturn serverAddr\n\t}\n\tidx := strings.LastIndex(serverAddr, \":\")\n\tif idx < 0 {\n\t\tif looksLikeIPv6(serverAddr) {\n\t\t\treturn \"[\" + serverAddr + \"]\"\n\t\t}\n\t\treturn serverAddr\n\t}\n\thost := strings.TrimSpace(serverAddr[:idx])\n\tport := strings.TrimSpace(serverAddr[idx+1:])\n\tif host == \"\" || port == \"\" {\n\t\treturn serverAddr\n\t}\n\tif looksLikeIPv6(host) {\n\t\treturn \"[\" + host + \"]:\" + port\n\t}\n\treturn serverAddr\n}\n\nfunc looksLikeIPv6(address string) bool {\n\treturn strings.Count(address, \":\") >= 2\n}\n\nfunc asBool(v interface{}, def bool) bool {\n\ts := strings.TrimSpace(strings.ToLower(asString(v)))\n\tif s == \"\" {\n\t\treturn def\n\t}\n\tswitch s {\n\tcase \"1\", \"t\", \"true\", \"yes\", \"y\":\n\t\treturn true\n\tcase \"0\", \"f\", \"false\", \"no\", \"n\":\n\t\treturn false\n\tdefault:\n\t\treturn def\n\t}\n}\n\nfunc (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) error {\n\tif err := h.upsertLimiterOnNode(nodeID, limiterID, speed); err != nil {\n\t\treturn fmt.Errorf(\"限速规则下发失败: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc buildLimiterAddPayload(limiterID int64, speed int) (string, map[string]interface{}) {\n\trate := float64(speed) / 8.0\n\tlimitStr := fmt.Sprintf(\"$ %.1fMB %.1fMB\", rate, rate)\n\tname := strconv.FormatInt(limiterID, 10)\n\n\treturn name, map[string]interface{}{\n\t\t\"name\": name,\n\t\t\"limits\": []string{limitStr},\n\t}\n}\n\nfunc buildLimiterUpdatePayload(name string, data map[string]interface{}) map[string]interface{} {\n\treturn map[string]interface{}{\n\t\t\"limiter\": name,\n\t\t\"data\": data,\n\t}\n}\n\nfunc (h *Handler) upsertLimiterOnNode(nodeID int64, limiterID int64, speed int) error {\n\tname, addPayload := buildLimiterAddPayload(limiterID, speed)\n\tif _, err := h.sendNodeCommand(nodeID, \"AddLimiters\", addPayload, false, false); err != nil {\n\t\tif !isAlreadyExistsMessage(err.Error()) {\n\t\t\treturn err\n\t\t}\n\t\tpayload := map[string]interface{}{\n\t\t\t\"name\": name,\n\t\t\t\"limits\": addPayload[\"limits\"],\n\t\t}\n\t\tif _, updateErr := h.sendNodeCommand(nodeID, \"UpdateLimiters\", buildLimiterUpdatePayload(name, payload), false, false); updateErr != nil {\n\t\t\treturn updateErr\n\t\t}\n\t}\n\n\treturn nil\n}\n",
|
||
"after": "package handler\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"go-backend/internal/http/client\"\n\t\"go-backend/internal/store/model\"\n\t\"go-backend/internal/ws\"\n)\n\nvar errForwardNotFound = errors.New(\"forward not found\")\n\ntype forwardRecord = model.ForwardRecord\ntype tunnelRecord = model.TunnelRecord\ntype forwardPortRecord = model.ForwardPortRecord\ntype nodeRecord = model.NodeRecord\n\ntype chainNodeRecord = model.ChainNodeRecord\n\ntype diagnosisTarget struct {\n\tAddress string\n\tIP string\n\tPort int\n}\n\ntype diagnosisWorkItem struct {\n\tfromNodeID int64\n\ttargetIP string\n\ttargetPort int\n\tdescription string\n\tmetadata map[string]interface{}\n\ttoNode chainNodeRecord\n\thasChainHop bool\n\tipPreference string\n}\n\ntype diagnosisExecOptions struct {\n\tcommandTimeout time.Duration\n\tpingTimeoutMS int\n\ttimeoutMessage string\n}\n\ntype diagnosisProgress struct {\n\tTotal int `json:\"total\"`\n\tCompleted int `json:\"completed\"`\n\tSuccess int `json:\"success\"`\n\tFailed int `json:\"failed\"`\n}\n\ntype diagnosisItemEmitter func(index int, item map[string]interface{}, progress diagnosisProgress)\n\nfunc (h *Handler) buildDiagnosisStreamStartItems(workItems []diagnosisWorkItem) []map[string]interface{} {\n\tif len(workItems) == 0 {\n\t\treturn []map[string]interface{}{}\n\t}\n\n\tnodeCache := map[int64]*nodeRecord{}\n\titems := make([]map[string]interface{}, 0, len(workItems))\n\tfor _, workItem := range workItems {\n\t\ttargetIP := strings.TrimSpace(workItem.targetIP)\n\t\ttargetPort := workItem.targetPort\n\t\tif workItem.hasChainHop {\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\tif resolveErr == nil {\n\t\t\t\t\ttargetIP = resolvedIP\n\t\t\t\t\ttargetPort = resolvedPort\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif targetPort <= 0 {\n\t\t\ttargetPort = 443\n\t\t}\n\n\t\tnodeName := fmt.Sprintf(\"node_%d\", workItem.fromNodeID)\n\t\tif node, err := h.cachedNode(nodeCache, workItem.fromNodeID); err == nil && strings.TrimSpace(node.Name) != \"\" {\n\t\t\tnodeName = node.Name\n\t\t}\n\n\t\titem := map[string]interface{}{\n\t\t\t\"success\": false,\n\t\t\t\"diagnosing\": true,\n\t\t\t\"description\": workItem.description,\n\t\t\t\"nodeName\": nodeName,\n\t\t\t\"nodeId\": strconv.FormatInt(workItem.fromNodeID, 10),\n\t\t\t\"targetIp\": targetIP,\n\t\t\t\"targetPort\": targetPort,\n\t\t\t\"message\": \"诊断中...\",\n\t\t}\n\t\tfor key, value := range workItem.metadata {\n\t\t\titem[key] = value\n\t\t}\n\t\titems = append(items, item)\n\t}\n\n\treturn items\n}\n\nconst diagnosisMaxConcurrency = 8\n\nconst (\n\tdefaultNodeCommandTimeout = 6 * time.Second\n\tdiagnosisCommandTimeout = 30 * time.Second\n\tdiagnosisRequestTimeout = 2 * time.Minute\n\tdiagnosisCommandTimeoutMsg = \"诊断超时(30秒)\"\n\tdiagnosisRequestTimeoutMsg = \"诊断超时(2分钟)\"\n)\n\nfunc (h *Handler) resolveForwardAccess(r *http.Request, forwardID int64) (*forwardRecord, int64, int, error) {\n\tuserID, roleID, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\treturn nil, 0, 0, err\n\t}\n\tforward, err := h.ensureForwardAccessByActor(userID, roleID, forwardID)\n\tif err != nil {\n\t\treturn nil, userID, roleID, err\n\t}\n\treturn forward, userID, roleID, nil\n}\n\nfunc (h *Handler) ensureForwardAccessByActor(actorUserID int64, actorRole int, forwardID int64) (*forwardRecord, error) {\n\tforward, err := h.getForwardRecord(forwardID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif actorRole != 0 && forward.UserID != actorUserID {\n\t\treturn nil, errForwardNotFound\n\t}\n\treturn forward, nil\n}\n\nfunc (h *Handler) ensureTunnelPermission(userID int64, roleID int, tunnelID int64) error {\n\tif roleID == 0 {\n\t\treturn nil\n\t}\n\tok, err := h.repo.UserTunnelExistsByUserAndTunnel(userID, tunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !ok {\n\t\treturn errors.New(\"你没有该隧道的权限\")\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) getForwardRecord(forwardID int64) (*forwardRecord, error) {\n\tfr, err := h.repo.GetForwardRecord(forwardID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif fr == nil {\n\t\treturn nil, errForwardNotFound\n\t}\n\treturn fr, nil\n}\n\nfunc (h *Handler) getTunnelRecord(tunnelID int64) (*tunnelRecord, error) {\n\ttr, err := h.repo.GetTunnelRecord(tunnelID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif tr == nil {\n\t\treturn nil, errors.New(\"隧道不存在\")\n\t}\n\treturn tr, nil\n}\n\nfunc (h *Handler) listForwardsByTunnel(tunnelID int64) ([]forwardRecord, error) {\n\treturn h.repo.ListForwardsByTunnel(tunnelID)\n}\n\nfunc (h *Handler) listForwardPorts(forwardID int64) ([]forwardPortRecord, error) {\n\treturn h.repo.ListForwardPorts(forwardID)\n}\n\nfunc (h *Handler) isTunnelSelectedTLSProtocol(tunnelID int64) (bool, error) {\n\tprotocol, err := h.repo.GetTunnelOutProtocol(tunnelID)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn isTLSTunnelProtocol(protocol), nil\n}\n\nfunc (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {\n\tn, err := h.repo.GetNodeRecord(nodeID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif n == nil {\n\t\treturn nil, errors.New(\"节点不存在\")\n\t}\n\treturn n, nil\n}\n\nfunc (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *int64, *int, error) {\n\tinfo, err := h.repo.ResolveUserTunnelAndLimiter(userID, tunnelID)\n\tif err != nil {\n\t\treturn 0, nil, nil, err\n\t}\n\tif info == nil {\n\t\treturn 0, nil, nil, nil\n\t}\n\treturn info.UserTunnelID, info.LimiterID, info.Speed, nil\n}\n\nfunc (h *Handler) listUserTunnelIDs(userID, tunnelID int64) ([]int64, error) {\n\treturn h.repo.ListUserTunnelIDs(userID, tunnelID)\n}\n\nfunc (h *Handler) listUserTunnelIDsByUser(userID int64) ([]int64, error) {\n\treturn h.repo.ListUserTunnelIDsByUser(userID)\n}\n\nfunc (h *Handler) syncForwardServices(forward *forwardRecord, method string, allowFallbackAdd bool) error {\n\tif h == nil || forward == nil {\n\t\treturn errors.New(\"invalid forward sync context\")\n\t}\n\n\ttunnel, err := h.getTunnelRecord(forward.TunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tports, err := h.listForwardPorts(forward.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(ports) == 0 {\n\t\treturn errors.New(\"转发入口端口不存在\")\n\t}\n\n\t// Determine limiter from forward's SpeedID first, fallback to UserTunnel's limiter\n\tvar limiterID *int64\n\tvar speed *int\n\n\tif forward.SpeedID.Valid && forward.SpeedID.Int64 > 0 {\n\t\t// Forward has its own speed limit\n\t\tspeedVal, err := h.repo.GetSpeedLimitSpeed(forward.SpeedID.Int64)\n\t\tif err == nil && speedVal > 0 {\n\t\t\tlimiterID = &forward.SpeedID.Int64\n\t\t\tspeed = &speedVal\n\t\t}\n\t}\n\n\tif limiterID == nil {\n\t\t// Fall back to UserTunnel speed limit\n\t\tvar utLimiterID *int64\n\t\tvar utSpeed *int\n\t\t_, utLimiterID, utSpeed, err = h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tlimiterID = utLimiterID\n\t\tspeed = utSpeed\n\t}\n\n\tserviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)\n\ttunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, fp := range ports {\n\t\tif limiterID != nil && speed != nil {\n\t\t\tif err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\tnode, err := h.getNodeRecord(fp.NodeID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tservices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol)\n\t\t_, err = h.sendNodeCommand(node.ID, method, services, true, false)\n\t\tif err != nil && allowFallbackAdd && method == \"UpdateService\" {\n\t\t\t_, err = h.sendNodeCommand(node.ID, \"AddService\", services, true, false)\n\t\t}\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"节点 %s 下发失败: %w\", node.Name, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {\n\tif h == nil || forward == nil {\n\t\treturn errors.New(\"invalid forward control context\")\n\t}\n\tports, err := h.listForwardPorts(forward.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(ports) == 0 {\n\t\treturn nil\n\t}\n\tuserTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tuserTunnelIDs, err := h.listUserTunnelIDs(forward.UserID, forward.TunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tallUserTunnelIDs, err := h.listUserTunnelIDsByUser(forward.UserID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tcandidateTunnelIDs := make([]int64, 0, len(userTunnelIDs)+len(allUserTunnelIDs))\n\tcandidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)\n\tcandidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)\n\tbases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)\n\tseen := map[int64]struct{}{}\n\tfor _, fp := range ports {\n\t\tif _, ok := seen[fp.NodeID]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[fp.NodeID] = struct{}{}\n\n\t\tvar lastNotFoundErr error\n\t\tnodeHandled := false\n\n\t\tfor _, base := range bases {\n\t\t\tvariants := []string{base + \"_tcp\", base + \"_udp\"}\n\t\t\tif shouldTryLegacySingleService(commandType) || strings.EqualFold(strings.TrimSpace(commandType), \"DeleteService\") {\n\t\t\t\tvariants = append(variants, base)\n\t\t\t}\n\n\t\t\tcandidateHandled := false\n\t\t\tfor _, name := range variants {\n\t\t\t\tpayload := map[string]interface{}{\n\t\t\t\t\t\"services\": []string{name},\n\t\t\t\t}\n\t\t\t\t_, err := h.sendNodeCommand(fp.NodeID, commandType, payload, false, false)\n\t\t\t\tif err == nil {\n\t\t\t\t\tcandidateHandled = true\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif !isNotFoundError(err) {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tlastNotFoundErr = err\n\t\t\t}\n\n\t\t\tif candidateHandled {\n\t\t\t\tnodeHandled = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif nodeHandled {\n\t\t\tcontinue\n\t\t}\n\t\tif tolerateNotFound {\n\t\t\tcontinue\n\t\t}\n\t\tif lastNotFoundErr != nil {\n\t\t\treturn lastNotFoundErr\n\t\t}\n\t\treturn errors.New(\"service control failed\")\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) applyNodeProtocolChange(nodeID int64, httpVal, tlsVal, socksVal int) error {\n\t_, err := h.sendNodeCommand(nodeID, \"SetProtocol\", map[string]interface{}{\n\t\t\"http\": httpVal,\n\t\t\"tls\": tlsVal,\n\t\t\"socks\": socksVal,\n\t}, false, false)\n\treturn err\n}\n\nfunc (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interface{}, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {\n\treturn h.sendNodeCommandWithTimeout(nodeID, commandType, data, defaultNodeCommandTimeout, tolerateExists, tolerateNotFound)\n}\n\nfunc (h *Handler) sendNodeCommandWithTimeout(nodeID int64, commandType string, data interface{}, timeout time.Duration, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {\n\tvar (\n\t\tresult ws.CommandResult\n\t\terr error\n\t)\n\tif timeout <= 0 {\n\t\ttimeout = defaultNodeCommandTimeout\n\t}\n\n\tnode, nodeErr := h.getNodeRecord(nodeID)\n\tif nodeErr == nil && node != nil && node.IsRemote == 1 {\n\t\tresult, err = h.sendRemoteNodeCommandWithTimeout(node, commandType, data, timeout)\n\t} else {\n\t\tresult, err = h.wsServer.SendCommand(nodeID, commandType, data, timeout)\n\t}\n\tif err == nil {\n\t\treturn result, nil\n\t}\n\tmsg := strings.ToLower(strings.TrimSpace(err.Error()))\n\tif tolerateExists {\n\t\tif isAlreadyExistsMessage(msg) {\n\t\t\treturn result, nil\n\t\t}\n\t}\n\tif tolerateNotFound {\n\t\tif strings.Contains(msg, \"not found\") || strings.Contains(msg, \"不存在\") {\n\t\t\treturn result, nil\n\t\t}\n\t}\n\treturn result, err\n}\n\nfunc (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, data interface{}) (ws.CommandResult, error) {\n\treturn h.sendRemoteNodeCommandWithTimeout(node, commandType, data, 0)\n}\n\nfunc (h *Handler) sendRemoteNodeCommandWithTimeout(node *nodeRecord, commandType string, data interface{}, timeout time.Duration) (ws.CommandResult, error) {\n\tif node == nil {\n\t\treturn ws.CommandResult{}, errors.New(\"节点不存在\")\n\t}\n\tremoteURL := strings.TrimSpace(node.RemoteURL)\n\tremoteToken := strings.TrimSpace(node.RemoteToken)\n\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\treturn ws.CommandResult{}, errors.New(\"远程节点缺少共享配置\")\n\t}\n\n\tfc := client.NewFederationClient()\n\tif timeout > 0 {\n\t\tfc = client.NewFederationClientWithTimeout(timeout)\n\t}\n\tres, err := fc.Command(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeNodeCommandRequest{\n\t\tCommandType: commandType,\n\t\tData: data,\n\t})\n\tif err != nil {\n\t\treturn ws.CommandResult{}, err\n\t}\n\tif res == nil {\n\t\treturn ws.CommandResult{}, errors.New(\"远程节点未返回命令结果\")\n\t}\n\n\tresult := ws.CommandResult{\n\t\tType: res.Type,\n\t\tSuccess: res.Success,\n\t\tMessage: res.Message,\n\t\tData: res.Data,\n\t}\n\tif !result.Success {\n\t\tmsg := strings.TrimSpace(result.Message)\n\t\tif msg == \"\" {\n\t\t\tmsg = \"命令执行失败\"\n\t\t}\n\t\treturn result, errors.New(msg)\n\t}\n\treturn result, nil\n}\n\nfunc (h *Handler) diagnoseForwardRuntime(ctx context.Context, forward *forwardRecord) (map[string]interface{}, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tforwardName, workItems, err := h.prepareForwardDiagnosis(forward)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresults := h.runDiagnosisWorkItems(ctx, workItems, nil)\n\n\tpayload := map[string]interface{}{\n\t\t\"forwardName\": forwardName,\n\t\t\"timestamp\": time.Now().UnixMilli(),\n\t\t\"results\": results,\n\t}\n\treturn payload, nil\n}\n\nfunc (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []diagnosisWorkItem, error) {\n\tif forward == nil {\n\t\treturn \"\", nil, errForwardNotFound\n\t}\n\ttargets, err := resolveDiagnosisTargets(forward.RemoteAddr)\n\tif err != nil {\n\t\treturn \"\", nil, err\n\t}\n\n\ttunnel, err := h.getTunnelRecord(forward.TunnelID)\n\tif err != nil {\n\t\treturn \"\", nil, err\n\t}\n\n\tchainRows, err := h.listChainNodesForTunnel(forward.TunnelID)\n\tif err != nil {\n\t\treturn \"\", nil, err\n\t}\n\tif len(chainRows) == 0 {\n\t\treturn \"\", nil, errors.New(\"隧道配置不完整\")\n\t}\n\n\tipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)\n\n\tinNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)\n\tworkItems := make([]diagnosisWorkItem, 0, len(chainRows)*2+len(targets))\n\n\tswitch tunnel.Type {\n\tcase 1:\n\t\tfor _, inNode := range inNodes {\n\t\t\tfor _, target := range targets {\n\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->目标(%s)\", inNode.NodeName, target.Address)\n\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\ttargetIP: target.IP,\n\t\t\t\t\ttargetPort: target.Port,\n\t\t\t\t\tdescription: description,\n\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\tcase 2:\n\t\tfor _, inNode := range inNodes {\n\t\t\tif len(chainHops) > 0 {\n\t\t\t\tfor _, firstNode := range chainHops[0] {\n\t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->第1跳(%s)\", inNode.NodeName, firstNode.NodeName)\n\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\t\ttoNode: firstNode,\n\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t\t\t\"toChainType\": 2,\n\t\t\t\t\t\t\t\"toInx\": firstNode.Inx,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tfor _, outNode := range outNodes {\n\t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->出口(%s)\", inNode.NodeName, outNode.NodeName)\n\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\t\ttoNode: outNode,\n\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t\t\t\"toChainType\": 3,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor i, hop := range chainHops {\n\t\t\tfor _, currentNode := range hop {\n\t\t\t\tif i+1 < len(chainHops) {\n\t\t\t\t\tfor _, nextNode := range chainHops[i+1] {\n\t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->第%d跳(%s)\", i+1, currentNode.NodeName, i+2, nextNode.NodeName)\n\t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n\t\t\t\t\t\t\ttoNode: nextNode,\n\t\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\t\"fromChainType\": 2,\n\t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n\t\t\t\t\t\t\t\t\"toChainType\": 2,\n\t\t\t\t\t\t\t\t\"toInx\": nextNode.Inx,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tfor _, outNode := range outNodes {\n\t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->出口(%s)\", i+1, currentNode.NodeName, outNode.NodeName)\n\t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n\t\t\t\t\t\t\ttoNode: outNode,\n\t\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\t\"fromChainType\": 2,\n\t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n\t\t\t\t\t\t\t\t\"toChainType\": 3,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor _, outNode := range outNodes {\n\t\t\tfor _, target := range targets {\n\t\t\t\tdescription := fmt.Sprintf(\"出口(%s)->目标(%s)\", outNode.NodeName, target.Address)\n\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\tfromNodeID: outNode.NodeID,\n\t\t\t\t\ttargetIP: target.IP,\n\t\t\t\t\ttargetPort: target.Port,\n\t\t\t\t\tdescription: description,\n\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\"fromChainType\": 3,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\tdefault:\n\t\tfor _, inNode := range inNodes {\n\t\t\tfor _, target := range targets {\n\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->目标(%s)\", inNode.NodeName, target.Address)\n\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\ttargetIP: target.IP,\n\t\t\t\t\ttargetPort: target.Port,\n\t\t\t\t\tdescription: description,\n\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\treturn forward.Name, workItems, nil\n}\n\nfunc (h *Handler) diagnoseTunnelRuntime(ctx context.Context, tunnelID int64) (map[string]interface{}, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\ttunnelName, tunnelType, workItems, err := h.prepareTunnelDiagnosis(tunnelID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresults := h.runDiagnosisWorkItems(ctx, workItems, nil)\n\n\tpayload := map[string]interface{}{\n\t\t\"tunnelName\": tunnelName,\n\t\t\"tunnelType\": tunnelType,\n\t\t\"timestamp\": time.Now().UnixMilli(),\n\t\t\"results\": results,\n\t}\n\treturn payload, nil\n}\n\nfunc (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diagnosisWorkItem, error) {\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\treturn \"\", \"\", nil, err\n\t}\n\n\ttunnelName, err := h.repo.GetTunnelName(tunnelID)\n\tif err != nil {\n\t\treturn \"\", \"\", nil, err\n\t}\n\tif tunnelName == \"\" {\n\t\treturn \"\", \"\", nil, errors.New(\"隧道不存在\")\n\t}\n\n\tchainRows, err := h.listChainNodesForTunnel(tunnelID)\n\tif err != nil {\n\t\treturn \"\", \"\", nil, err\n\t}\n\tif len(chainRows) == 0 {\n\t\treturn \"\", \"\", nil, errors.New(\"隧道配置不完整\")\n\t}\n\n\tipPreference := h.repo.GetTunnelIPPreference(tunnelID)\n\tinNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)\n\tworkItems := make([]diagnosisWorkItem, 0, len(chainRows)*2)\n\n\tswitch tunnel.Type {\n\tcase 1:\n\t\tfor _, inNode := range inNodes {\n\t\t\tdescription := fmt.Sprintf(\"入口(%s)->外网\", inNode.NodeName)\n\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\ttargetIP: \"www.bing.com\",\n\t\t\t\ttargetPort: 443,\n\t\t\t\tdescription: description,\n\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\tcase 2:\n\t\tfor _, inNode := range inNodes {\n\t\t\tif len(chainHops) > 0 {\n\t\t\t\tfor _, firstNode := range chainHops[0] {\n\t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->第1跳(%s)\", inNode.NodeName, firstNode.NodeName)\n\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\t\ttoNode: firstNode,\n\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t\t\t\"toChainType\": 2,\n\t\t\t\t\t\t\t\"toInx\": firstNode.Inx,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tfor _, outNode := range outNodes {\n\t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->出口(%s)\", inNode.NodeName, outNode.NodeName)\n\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\t\ttoNode: outNode,\n\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t\t\t\"toChainType\": 3,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor i, hop := range chainHops {\n\t\t\tfor _, currentNode := range hop {\n\t\t\t\tif i+1 < len(chainHops) {\n\t\t\t\t\tfor _, nextNode := range chainHops[i+1] {\n\t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->第%d跳(%s)\", i+1, currentNode.NodeName, i+2, nextNode.NodeName)\n\t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n\t\t\t\t\t\t\ttoNode: nextNode,\n\t\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\t\"fromChainType\": 2,\n\t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n\t\t\t\t\t\t\t\t\"toChainType\": 2,\n\t\t\t\t\t\t\t\t\"toInx\": nextNode.Inx,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tfor _, outNode := range outNodes {\n\t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->出口(%s)\", i+1, currentNode.NodeName, outNode.NodeName)\n\t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n\t\t\t\t\t\t\ttoNode: outNode,\n\t\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\t\"fromChainType\": 2,\n\t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n\t\t\t\t\t\t\t\t\"toChainType\": 3,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor _, outNode := range outNodes {\n\t\t\tdescription := fmt.Sprintf(\"出口(%s)->外网\", outNode.NodeName)\n\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\tfromNodeID: outNode.NodeID,\n\t\t\t\ttargetIP: \"www.bing.com\",\n\t\t\t\ttargetPort: 443,\n\t\t\t\tdescription: description,\n\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\"fromChainType\": 3,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\tdefault:\n\t\tfor _, inNode := range inNodes {\n\t\t\tdescription := fmt.Sprintf(\"入口(%s)->外网\", inNode.NodeName)\n\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\ttargetIP: \"www.bing.com\",\n\t\t\t\ttargetPort: 443,\n\t\t\t\tdescription: description,\n\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\t}\n\n\ttunnelType := map[bool]string{true: \"端口转发\", false: \"隧道转发\"}[tunnel.Type == 1]\n\treturn tunnelName, tunnelType, workItems, nil\n}\n\nfunc splitChainNodeGroups(rows []chainNodeRecord) ([]chainNodeRecord, [][]chainNodeRecord, []chainNodeRecord) {\n\tinNodes := make([]chainNodeRecord, 0)\n\toutNodes := make([]chainNodeRecord, 0)\n\tchainByInx := map[int64][]chainNodeRecord{}\n\thopOrder := make([]int64, 0)\n\n\tfor _, row := range rows {\n\t\tswitch row.ChainType {\n\t\tcase 1:\n\t\t\tinNodes = append(inNodes, row)\n\t\tcase 2:\n\t\t\tif _, ok := chainByInx[row.Inx]; !ok {\n\t\t\t\thopOrder = append(hopOrder, row.Inx)\n\t\t\t}\n\t\t\tchainByInx[row.Inx] = append(chainByInx[row.Inx], row)\n\t\tcase 3:\n\t\t\toutNodes = append(outNodes, row)\n\t\t}\n\t}\n\n\tsort.Slice(hopOrder, func(i, j int) bool { return hopOrder[i] < hopOrder[j] })\n\tchainHops := make([][]chainNodeRecord, 0, len(hopOrder))\n\tfor _, inx := range hopOrder {\n\t\tchainHops = append(chainHops, chainByInx[inx])\n\t}\n\n\treturn inNodes, chainHops, outNodes\n}\n\nfunc resolveDiagnosisTargets(remoteAddr string) ([]diagnosisTarget, error) {\n\trawTargets := splitRemoteTargets(remoteAddr)\n\tif len(rawTargets) == 0 {\n\t\treturn nil, errors.New(\"目标地址不能为空\")\n\t}\n\n\ttargets := make([]diagnosisTarget, 0, len(rawTargets))\n\tfor _, raw := range rawTargets {\n\t\tip, port, err := parseTargetAddress(raw)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttargets = append(targets, diagnosisTarget{Address: raw, IP: ip, Port: port})\n\t}\n\tif len(targets) == 0 {\n\t\treturn nil, errors.New(\"目标地址格式错误\")\n\t}\n\treturn targets, nil\n}\n\nfunc diagnosisContextMessage(ctx context.Context) string {\n\tif ctx == nil {\n\t\treturn diagnosisRequestTimeoutMsg\n\t}\n\tswitch ctx.Err() {\n\tcase context.DeadlineExceeded:\n\t\treturn diagnosisRequestTimeoutMsg\n\tcase context.Canceled:\n\t\treturn \"诊断已取消\"\n\tdefault:\n\t\treturn diagnosisRequestTimeoutMsg\n\t}\n}\n\nfunc diagnosisExecOptionsFromContext(ctx context.Context) diagnosisExecOptions {\n\ttimeout := diagnosisCommandTimeout\n\tif ctx != nil {\n\t\tif deadline, ok := ctx.Deadline(); ok {\n\t\t\tremaining := time.Until(deadline)\n\t\t\tif remaining <= 0 {\n\t\t\t\tremaining = 100 * time.Millisecond\n\t\t\t}\n\t\t\tif remaining < timeout {\n\t\t\t\ttimeout = remaining\n\t\t\t}\n\t\t}\n\t}\n\tif timeout <= 0 {\n\t\ttimeout = 100 * time.Millisecond\n\t}\n\tpingTimeoutMS := int(timeout / time.Millisecond)\n\tif pingTimeoutMS <= 0 {\n\t\tpingTimeoutMS = 100\n\t}\n\treturn diagnosisExecOptions{\n\t\tcommandTimeout: timeout,\n\t\tpingTimeoutMS: pingTimeoutMS,\n\t\ttimeoutMessage: diagnosisContextMessage(ctx),\n\t}\n}\n\nfunc newDiagnosisTimeoutItem(workItem diagnosisWorkItem, message string) map[string]interface{} {\n\ttargetPort := workItem.targetPort\n\tif targetPort <= 0 {\n\t\ttargetPort = workItem.toNode.Port\n\t}\n\titem := newDiagnosisResultItem(workItem.fromNodeID, workItem.targetIP, targetPort, workItem.description, workItem.metadata)\n\titem[\"success\"] = false\n\tif strings.TrimSpace(message) == \"\" {\n\t\tmessage = diagnosisCommandTimeoutMsg\n\t}\n\titem[\"message\"] = message\n\treturn item\n}\n\nfunc (h *Handler) executeDiagnosisWorkItem(workItem diagnosisWorkItem, options diagnosisExecOptions) map[string]interface{} {\n\tsingle := make([]map[string]interface{}, 0, 1)\n\tnodeCache := map[int64]*nodeRecord{}\n\tif workItem.hasChainHop {\n\t\th.appendChainHopDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.toNode, workItem.description, workItem.metadata, workItem.ipPreference, options)\n\t} else {\n\t\th.appendPathDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.targetIP, workItem.targetPort, workItem.description, workItem.metadata, options)\n\t}\n\n\tif len(single) == 0 {\n\t\treturn newDiagnosisTimeoutItem(workItem, \"诊断任务未返回结果\")\n\t}\n\treturn single[0]\n}\n\nfunc (h *Handler) runDiagnosisWorkItems(ctx context.Context, workItems []diagnosisWorkItem, emitter diagnosisItemEmitter) []map[string]interface{} {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tresults := make([]map[string]interface{}, len(workItems))\n\tif len(workItems) == 0 {\n\t\treturn results\n\t}\n\n\tworkerLimit := diagnosisMaxConcurrency\n\tif workerLimit < 1 {\n\t\tworkerLimit = 1\n\t}\n\tif workerLimit > len(workItems) {\n\t\tworkerLimit = len(workItems)\n\t}\n\n\ttype diagnosisWorkResult struct {\n\t\tindex int\n\t\titem map[string]interface{}\n\t}\n\n\tjobs := make(chan int)\n\tresultCh := make(chan diagnosisWorkResult, len(workItems))\n\n\tvar wg sync.WaitGroup\n\tfor i := 0; i < workerLimit; i++ {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tfor index := range jobs {\n\t\t\t\tselect {\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\tresultCh <- diagnosisWorkResult{index: index, item: newDiagnosisTimeoutItem(workItems[index], diagnosisContextMessage(ctx))}\n\t\t\t\t\tcontinue\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\toptions := diagnosisExecOptionsFromContext(ctx)\n\t\t\t\tresultCh <- diagnosisWorkResult{index: index, item: h.executeDiagnosisWorkItem(workItems[index], options)}\n\t\t\t}\n\t\t}()\n\t}\n\nenqueueLoop:\n\tfor i := 0; i < len(workItems); i++ {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tmessage := diagnosisContextMessage(ctx)\n\t\t\tfor j := i; j < len(workItems); j++ {\n\t\t\t\tresultCh <- diagnosisWorkResult{index: j, item: newDiagnosisTimeoutItem(workItems[j], message)}\n\t\t\t}\n\t\t\tbreak enqueueLoop\n\t\tcase jobs <- i:\n\t\t}\n\t}\n\tclose(jobs)\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultCh)\n\t}()\n\n\tprogress := diagnosisProgress{Total: len(workItems)}\n\tfor result := range resultCh {\n\t\tresults[result.index] = result.item\n\t\tprogress.Completed++\n\t\tif asBool(result.item[\"success\"], false) {\n\t\t\tprogress.Success++\n\t\t} else {\n\t\t\tprogress.Failed++\n\t\t}\n\t\tif emitter != nil {\n\t\t\temitter(result.index, result.item, progress)\n\t\t}\n\t}\n\n\tfor i := range results {\n\t\tif results[i] == nil {\n\t\t\tresults[i] = newDiagnosisTimeoutItem(workItems[i], diagnosisCommandTimeoutMsg)\n\t\t}\n\t}\n\treturn results\n}\n\nfunc (h *Handler) cachedNode(nodeCache map[int64]*nodeRecord, nodeID int64) (*nodeRecord, error) {\n\tif node, ok := nodeCache[nodeID]; ok {\n\t\treturn node, nil\n\t}\n\tnode, err := h.getNodeRecord(nodeID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tnodeCache[nodeID] = node\n\treturn node, nil\n}\n\nfunc newDiagnosisResultItem(fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}) map[string]interface{} {\n\titem := map[string]interface{}{\n\t\t\"nodeName\": fmt.Sprintf(\"node_%d\", fromNodeID),\n\t\t\"nodeId\": strconv.FormatInt(fromNodeID, 10),\n\t\t\"targetIp\": targetIP,\n\t\t\"targetPort\": targetPort,\n\t\t\"description\": description,\n\t\t\"averageTime\": 0,\n\t\t\"packetLoss\": 100,\n\t}\n\tfor k, v := range metadata {\n\t\titem[k] = v\n\t}\n\treturn item\n}\n\nfunc (h *Handler) appendFailedDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}, message string) {\n\titem := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)\n\tif node, err := h.cachedNode(nodeCache, fromNodeID); err == nil {\n\t\titem[\"nodeName\"] = node.Name\n\t}\n\tif strings.TrimSpace(message) == \"\" {\n\t\tmessage = \"TCP连接失败\"\n\t}\n\titem[\"success\"] = false\n\titem[\"message\"] = message\n\t*results = append(*results, item)\n}\n\nfunc (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}, options diagnosisExecOptions) {\n\titem := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)\n\n\tfromNode, err := h.cachedNode(nodeCache, fromNodeID)\n\tif err != nil {\n\t\titem[\"success\"] = false\n\t\titem[\"message\"] = err.Error()\n\t\t*results = append(*results, item)\n\t\treturn\n\t}\n\titem[\"nodeName\"] = fromNode.Name\n\n\tvar (\n\t\tpingData map[string]interface{}\n\t\tpingErr error\n\t)\n\tif fromNode.IsRemote == 1 {\n\t\tpingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort, options)\n\t} else {\n\t\tpingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort, options)\n\t}\n\tif pingErr != nil {\n\t\titem[\"success\"] = false\n\t\titem[\"message\"] = pingErr.Error()\n\t\t*results = append(*results, item)\n\t\treturn\n\t}\n\n\tsuccess := asBool(pingData[\"success\"], false)\n\titem[\"success\"] = success\n\titem[\"averageTime\"] = asFloat(pingData[\"averageTime\"], 0)\n\titem[\"packetLoss\"] = asFloat(pingData[\"packetLoss\"], 100)\n\n\tmessage := strings.TrimSpace(asString(pingData[\"message\"]))\n\tif success {\n\t\tif message == \"\" {\n\t\t\tmessage = \"TCP连接成功\"\n\t\t}\n\t} else {\n\t\tif message == \"\" {\n\t\t\tmessage = strings.TrimSpace(asString(pingData[\"errorMessage\"]))\n\t\t}\n\t\tif message == \"\" {\n\t\t\tmessage = \"TCP连接失败\"\n\t\t}\n\t}\n\titem[\"message\"] = message\n\t*results = append(*results, item)\n}\n\nfunc (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, toNode chainNodeRecord, description string, metadata map[string]interface{}, ipPreference string, options diagnosisExecOptions) {\n\tfromNode, _ := h.cachedNode(nodeCache, fromNodeID)\n\ttargetNode, err := h.cachedNode(nodeCache, toNode.NodeID)\n\tif err != nil {\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\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\t}\n\th.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata, options)\n}\n\nfunc 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, connectIp)\n\tif err != nil {\n\t\thost = strings.Trim(strings.TrimSpace(targetNode.ServerIP), \"[]\")\n\t}\n\tif host == \"\" {\n\t\treturn \"\", 0, errors.New(\"目标节点地址为空\")\n\t}\n\tport := preferredPort\n\tif port <= 0 {\n\t\tport = firstPortFromRange(targetNode.PortRange)\n\t}\n\tif port <= 0 {\n\t\tport = 443\n\t}\n\treturn host, port, nil\n}\n\nfunc firstPortFromRange(portRange string) int {\n\tportRange = strings.TrimSpace(portRange)\n\tif portRange == \"\" {\n\t\treturn 0\n\t}\n\tfirst := strings.Split(portRange, \",\")[0]\n\tfirst = strings.TrimSpace(first)\n\tif strings.Contains(first, \"-\") {\n\t\tparts := strings.SplitN(first, \"-\", 2)\n\t\tif len(parts) != 2 {\n\t\t\treturn 0\n\t\t}\n\t\tp, err := strconv.Atoi(strings.TrimSpace(parts[0]))\n\t\tif err != nil || p <= 0 {\n\t\t\treturn 0\n\t\t}\n\t\treturn p\n\t}\n\tp, err := strconv.Atoi(first)\n\tif err != nil || p <= 0 {\n\t\treturn 0\n\t}\n\treturn p\n}\n\nfunc (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, error) {\n\treturn h.repo.ListChainNodesForTunnel(tunnelID)\n}\n\nfunc (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {\n\tif options.commandTimeout <= 0 {\n\t\toptions.commandTimeout = diagnosisCommandTimeout\n\t}\n\tif options.pingTimeoutMS <= 0 {\n\t\toptions.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)\n\t}\n\tres, err := h.sendNodeCommandWithTimeout(nodeID, \"TcpPing\", map[string]interface{}{\n\t\t\"ip\": ip,\n\t\t\"port\": port,\n\t\t\"count\": 4,\n\t\t\"timeout\": options.pingTimeoutMS,\n\t}, options.commandTimeout, false, false)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif res.Data == nil {\n\t\treturn nil, errors.New(\"节点未返回诊断数据\")\n\t}\n\treturn res.Data, nil\n}\n\nfunc (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {\n\tif node == nil {\n\t\treturn nil, errors.New(\"节点不存在\")\n\t}\n\tremoteURL := strings.TrimSpace(node.RemoteURL)\n\tremoteToken := strings.TrimSpace(node.RemoteToken)\n\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\treturn nil, errors.New(\"远程节点缺少共享配置\")\n\t}\n\tif options.commandTimeout <= 0 {\n\t\toptions.commandTimeout = diagnosisCommandTimeout\n\t}\n\tif options.pingTimeoutMS <= 0 {\n\t\toptions.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)\n\t}\n\n\tfc := client.NewFederationClientWithTimeout(options.commandTimeout)\n\treturn fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{\n\t\tIP: strings.TrimSpace(ip),\n\t\tPort: port,\n\t\tCount: 4,\n\t\tTimeout: options.pingTimeoutMS,\n\t})\n}\n\nfunc splitRemoteTargets(remoteAddr string) []string {\n\tparts := strings.Split(remoteAddr, \",\")\n\tout := make([]string, 0, len(parts))\n\tfor _, part := range parts {\n\t\tpart = strings.TrimSpace(part)\n\t\tif part == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, processServerAddress(part))\n\t}\n\treturn out\n}\n\nfunc parseTargetAddress(addr string) (string, int, error) {\n\taddr = strings.TrimSpace(addr)\n\tif addr == \"\" {\n\t\treturn \"\", 0, errors.New(\"empty address\")\n\t}\n\thost, portStr, err := net.SplitHostPort(addr)\n\tif err != nil {\n\t\tidx := strings.LastIndex(addr, \":\")\n\t\tif idx <= 0 || idx >= len(addr)-1 {\n\t\t\treturn \"\", 0, err\n\t\t}\n\t\thost = strings.TrimSpace(addr[:idx])\n\t\tportStr = strings.TrimSpace(addr[idx+1:])\n\t}\n\tport, err := strconv.Atoi(strings.TrimSpace(portStr))\n\tif err != nil || port <= 0 || port > 65535 {\n\t\treturn \"\", 0, errors.New(\"invalid port\")\n\t}\n\thost = strings.Trim(strings.TrimSpace(host), \"[]\")\n\tif host == \"\" {\n\t\treturn \"\", 0, errors.New(\"invalid host\")\n\t}\n\treturn host, port, nil\n}\n\nfunc buildForwardServiceBase(forwardID, userID, userTunnelID int64) string {\n\treturn fmt.Sprintf(\"%d_%d_%d\", forwardID, userID, userTunnelID)\n}\n\nfunc buildForwardServiceBaseCandidates(forwardID, userID, preferredUserTunnelID int64, userTunnelIDs []int64) []string {\n\torderedIDs := make([]int64, 0, len(userTunnelIDs)+2)\n\tseen := make(map[int64]struct{}, len(userTunnelIDs)+2)\n\n\tappendID := func(id int64) {\n\t\tif _, ok := seen[id]; ok {\n\t\t\treturn\n\t\t}\n\t\tseen[id] = struct{}{}\n\t\torderedIDs = append(orderedIDs, id)\n\t}\n\n\tappendID(preferredUserTunnelID)\n\tfor _, id := range userTunnelIDs {\n\t\tappendID(id)\n\t}\n\tappendID(0)\n\n\tbases := make([]string, 0, len(orderedIDs))\n\tfor _, id := range orderedIDs {\n\t\tbases = append(bases, buildForwardServiceBase(forwardID, userID, id))\n\t}\n\treturn bases\n}\n\nfunc buildForwardControlServiceNames(base, commandType string) []string {\n\tnames := []string{base + \"_tcp\", base + \"_udp\"}\n\tif strings.EqualFold(strings.TrimSpace(commandType), \"DeleteService\") {\n\t\treturn append([]string{base}, names...)\n\t}\n\treturn names\n}\n\nfunc shouldTryLegacySingleService(commandType string) bool {\n\tcmd := strings.ToLower(strings.TrimSpace(commandType))\n\treturn cmd == \"pauseservice\" || cmd == \"resumeservice\"\n}\n\nfunc isNotFoundError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tmsg := strings.ToLower(strings.TrimSpace(err.Error()))\n\treturn strings.Contains(msg, \"not found\") || strings.Contains(msg, \"不存在\")\n}\n\nfunc isAlreadyExistsMessage(message string) bool {\n\tmsg := strings.ToLower(strings.TrimSpace(message))\n\tif msg == \"\" {\n\t\treturn false\n\t}\n\tif strings.Contains(msg, \"address already in use\") {\n\t\treturn false\n\t}\n\treturn strings.Contains(msg, \"already exists\") || strings.Contains(msg, \"已存在\")\n}\n\nfunc buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {\n\tprotocols := []string{\"tcp\", \"udp\"}\n\tservices := make([]map[string]interface{}, 0, 2)\n\ttargets := splitRemoteTargets(forward.RemoteAddr)\n\tstrategy := strings.TrimSpace(forward.Strategy)\n\tif strategy == \"\" {\n\t\tstrategy = \"fifo\"\n\t}\n\n\tfor _, protocol := range protocols {\n\t\tlistenerAddr := node.TCPListenAddr\n\t\tif protocol == \"udp\" {\n\t\t\tlistenerAddr = node.UDPListenAddr\n\t\t}\n\t\tif bindIP != \"\" {\n\t\t\tlistenerAddr = bindIP\n\t\t}\n\t\tservice := map[string]interface{}{\n\t\t\t\"name\": fmt.Sprintf(\"%s_%s\", baseName, protocol),\n\t\t\t\"addr\": processServerAddress(fmt.Sprintf(\"%s:%d\", listenerAddr, port)),\n\t\t\t\"handler\": map[string]interface{}{\n\t\t\t\t\"type\": protocol,\n\t\t\t},\n\t\t\t\"listener\": map[string]interface{}{\n\t\t\t\t\"type\": protocol,\n\t\t\t},\n\t\t\t\"forwarder\": map[string]interface{}{\n\t\t\t\t\"nodes\": buildForwarderNodes(targets),\n\t\t\t\t\"selector\": map[string]interface{}{\n\t\t\t\t\t\"strategy\": strategy,\n\t\t\t\t\t\"maxFails\": 1,\n\t\t\t\t\t\"failTimeout\": \"600s\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tif protocol == \"udp\" {\n\t\t\tlistenerMetadata := map[string]interface{}{\"keepAlive\": true}\n\t\t\tif tunnelTLSProtocol {\n\t\t\t\tlistenerMetadata[\"ttl\"] = \"10s\"\n\t\t\t}\n\t\t\tservice[\"listener\"].(map[string]interface{})[\"metadata\"] = listenerMetadata\n\t\t}\n\t\tif tunnel != nil && tunnel.Type == 2 {\n\t\t\tservice[\"handler\"].(map[string]interface{})[\"chain\"] = fmt.Sprintf(\"chains_%d\", forward.TunnelID)\n\t\t}\n\t\tif tunnel != nil && tunnel.Type == 1 && strings.TrimSpace(node.InterfaceName) != \"\" {\n\t\t\tservice[\"metadata\"] = map[string]interface{}{\"interface\": node.InterfaceName}\n\t\t}\n\t\tif limiterID != nil && *limiterID > 0 {\n\t\t\tservice[\"limiter\"] = strconv.FormatInt(*limiterID, 10)\n\t\t}\n\t\tservices = append(services, service)\n\t}\n\n\treturn services\n}\n\nfunc buildForwarderNodes(targets []string) []map[string]interface{} {\n\tnodes := make([]map[string]interface{}, 0, len(targets))\n\tfor i, addr := range targets {\n\t\tnodes = append(nodes, map[string]interface{}{\n\t\t\t\"name\": fmt.Sprintf(\"node_%d\", i+1),\n\t\t\t\"addr\": addr,\n\t\t})\n\t}\n\treturn nodes\n}\n\nfunc processServerAddress(serverAddr string) string {\n\tserverAddr = strings.TrimSpace(serverAddr)\n\tif serverAddr == \"\" {\n\t\treturn serverAddr\n\t}\n\tif strings.HasPrefix(serverAddr, \"[\") {\n\t\treturn serverAddr\n\t}\n\tidx := strings.LastIndex(serverAddr, \":\")\n\tif idx < 0 {\n\t\tif looksLikeIPv6(serverAddr) {\n\t\t\treturn \"[\" + serverAddr + \"]\"\n\t\t}\n\t\treturn serverAddr\n\t}\n\thost := strings.TrimSpace(serverAddr[:idx])\n\tport := strings.TrimSpace(serverAddr[idx+1:])\n\tif host == \"\" || port == \"\" {\n\t\treturn serverAddr\n\t}\n\tif looksLikeIPv6(host) {\n\t\treturn \"[\" + host + \"]:\" + port\n\t}\n\treturn serverAddr\n}\n\nfunc looksLikeIPv6(address string) bool {\n\treturn strings.Count(address, \":\") >= 2\n}\n\nfunc asBool(v interface{}, def bool) bool {\n\ts := strings.TrimSpace(strings.ToLower(asString(v)))\n\tif s == \"\" {\n\t\treturn def\n\t}\n\tswitch s {\n\tcase \"1\", \"t\", \"true\", \"yes\", \"y\":\n\t\treturn true\n\tcase \"0\", \"f\", \"false\", \"no\", \"n\":\n\t\treturn false\n\tdefault:\n\t\treturn def\n\t}\n}\n\nfunc (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) error {\n\tif err := h.upsertLimiterOnNode(nodeID, limiterID, speed); err != nil {\n\t\treturn fmt.Errorf(\"限速规则下发失败: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc buildLimiterAddPayload(limiterID int64, speed int) (string, map[string]interface{}) {\n\trate := float64(speed) / 8.0\n\tlimitStr := fmt.Sprintf(\"$ %.1fMB %.1fMB\", rate, rate)\n\tname := strconv.FormatInt(limiterID, 10)\n\n\treturn name, map[string]interface{}{\n\t\t\"name\": name,\n\t\t\"limits\": []string{limitStr},\n\t}\n}\n\nfunc buildLimiterUpdatePayload(name string, data map[string]interface{}) map[string]interface{} {\n\treturn map[string]interface{}{\n\t\t\"limiter\": name,\n\t\t\"data\": data,\n\t}\n}\n\nfunc (h *Handler) upsertLimiterOnNode(nodeID int64, limiterID int64, speed int) error {\n\tname, addPayload := buildLimiterAddPayload(limiterID, speed)\n\tif _, err := h.sendNodeCommand(nodeID, \"AddLimiters\", addPayload, false, false); err != nil {\n\t\tif !isAlreadyExistsMessage(err.Error()) {\n\t\t\treturn err\n\t\t}\n\t\tpayload := map[string]interface{}{\n\t\t\t\"name\": name,\n\t\t\t\"limits\": addPayload[\"limits\"],\n\t\t}\n\t\tif _, updateErr := h.sendNodeCommand(nodeID, \"UpdateLimiters\", buildLimiterUpdatePayload(name, payload), false, false); updateErr != nil {\n\t\t\treturn updateErr\n\t\t}\n\t}\n\n\treturn nil\n}\n",
|
||
"additions": 6,
|
||
"deletions": 3,
|
||
"status": "modified"
|
||
},
|
||
{
|
||
"file": "go-backend/internal/http/handler/control_plane_test.go",
|
||
"before": "package handler\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestBuildForwardControlServiceNamesPauseResume(t *testing.T) {\n\tbase := \"12_34_56\"\n\twant := []string{base + \"_tcp\", base + \"_udp\"}\n\n\tfor _, command := range []string{\"PauseService\", \"ResumeService\"} {\n\t\tgot := buildForwardControlServiceNames(base, command)\n\t\tif !reflect.DeepEqual(got, want) {\n\t\t\tt.Fatalf(\"command %s expected %v, got %v\", command, want, got)\n\t\t}\n\t}\n}\n\nfunc TestBuildForwardControlServiceNamesDelete(t *testing.T) {\n\tbase := \"12_34_56\"\n\twant := []string{base, base + \"_tcp\", base + \"_udp\"}\n\tgot := buildForwardControlServiceNames(base, \" DeleteService \")\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"expected %v, got %v\", want, got)\n\t}\n}\n\nfunc TestBuildForwardServiceBaseCandidates(t *testing.T) {\n\tgot := buildForwardServiceBaseCandidates(12, 34, 56, []int64{56, 78, 90})\n\twant := []string{\"12_34_56\", \"12_34_78\", \"12_34_90\", \"12_34_0\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"expected %v, got %v\", want, got)\n\t}\n}\n\nfunc TestBuildForwardServiceBaseCandidatesWithZeroPreferred(t *testing.T) {\n\tgot := buildForwardServiceBaseCandidates(12, 34, 0, []int64{78, 0, 90})\n\twant := []string{\"12_34_0\", \"12_34_78\", \"12_34_90\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"expected %v, got %v\", want, got)\n\t}\n}\n\nfunc TestShouldTryLegacySingleService(t *testing.T) {\n\tif !shouldTryLegacySingleService(\"PauseService\") {\n\t\tt.Fatalf(\"PauseService should require legacy fallback\")\n\t}\n\tif !shouldTryLegacySingleService(\"resumeService\") {\n\t\tt.Fatalf(\"ResumeService should require legacy fallback\")\n\t}\n\tif shouldTryLegacySingleService(\"DeleteService\") {\n\t\tt.Fatalf(\"DeleteService should not require legacy fallback\")\n\t}\n}\n\nfunc TestIsAlreadyExistsMessage(t *testing.T) {\n\tif !isAlreadyExistsMessage(\"service demo already exists\") {\n\t\tt.Fatalf(\"expected already exists message to be tolerated\")\n\t}\n\tif !isAlreadyExistsMessage(\"服务已存在\") {\n\t\tt.Fatalf(\"expected Chinese already exists message to be tolerated\")\n\t}\n\tif isAlreadyExistsMessage(\"listen tcp [::]:10001: bind: address already in use\") {\n\t\tt.Fatalf(\"address already in use must not be treated as already exists\")\n\t}\n}\n",
|
||
"after": "package handler\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestBuildForwardControlServiceNamesPauseResume(t *testing.T) {\n\tbase := \"12_34_56\"\n\twant := []string{base + \"_tcp\", base + \"_udp\"}\n\n\tfor _, command := range []string{\"PauseService\", \"ResumeService\"} {\n\t\tgot := buildForwardControlServiceNames(base, command)\n\t\tif !reflect.DeepEqual(got, want) {\n\t\t\tt.Fatalf(\"command %s expected %v, got %v\", command, want, got)\n\t\t}\n\t}\n}\n\nfunc TestBuildForwardControlServiceNamesDelete(t *testing.T) {\n\tbase := \"12_34_56\"\n\twant := []string{base, base + \"_tcp\", base + \"_udp\"}\n\tgot := buildForwardControlServiceNames(base, \" DeleteService \")\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"expected %v, got %v\", want, got)\n\t}\n}\n\nfunc TestBuildForwardServiceBaseCandidates(t *testing.T) {\n\tgot := buildForwardServiceBaseCandidates(12, 34, 56, []int64{56, 78, 90})\n\twant := []string{\"12_34_56\", \"12_34_78\", \"12_34_90\", \"12_34_0\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"expected %v, got %v\", want, got)\n\t}\n}\n\nfunc TestBuildForwardServiceBaseCandidatesWithZeroPreferred(t *testing.T) {\n\tgot := buildForwardServiceBaseCandidates(12, 34, 0, []int64{78, 0, 90})\n\twant := []string{\"12_34_0\", \"12_34_78\", \"12_34_90\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"expected %v, got %v\", want, got)\n\t}\n}\n\nfunc TestShouldTryLegacySingleService(t *testing.T) {\n\tif !shouldTryLegacySingleService(\"PauseService\") {\n\t\tt.Fatalf(\"PauseService should require legacy fallback\")\n\t}\n\tif !shouldTryLegacySingleService(\"resumeService\") {\n\t\tt.Fatalf(\"ResumeService should require legacy fallback\")\n\t}\n\tif shouldTryLegacySingleService(\"DeleteService\") {\n\t\tt.Fatalf(\"DeleteService should not require legacy fallback\")\n\t}\n}\n\nfunc TestIsAlreadyExistsMessage(t *testing.T) {\n\tif !isAlreadyExistsMessage(\"service demo already exists\") {\n\t\tt.Fatalf(\"expected already exists message to be tolerated\")\n\t}\n\tif !isAlreadyExistsMessage(\"服务已存在\") {\n\t\tt.Fatalf(\"expected Chinese already exists message to be tolerated\")\n\t}\n\tif isAlreadyExistsMessage(\"listen tcp [::]:10001: bind: address already in use\") {\n\t\tt.Fatalf(\"address already in use must not be treated as already exists\")\n\t}\n}\n\nfunc TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {\n\tforward := &forwardRecord{RemoteAddr: \"1.2.3.4:80\", Strategy: \"fifo\", TunnelID: 7}\n\tnode := &nodeRecord{TCPListenAddr: \"[::]\", UDPListenAddr: \"[::]\"}\n\tservices := buildForwardServiceConfigs(\"1_2_0\", forward, nil, node, 22000, \"10.9.8.7\", nil, false)\n\tif len(services) != 2 {\n\t\tt.Fatalf(\"expected 2 services, got %d\", len(services))\n\t}\n\tfor _, svc := range services {\n\t\taddr, _ := svc[\"addr\"].(string)\n\t\tif addr != \"10.9.8.7:22000\" {\n\t\t\tt.Fatalf(\"expected bind IP address 10.9.8.7:22000, got %q\", addr)\n\t\t}\n\t}\n}\n\nfunc TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.T) {\n\tforward := &forwardRecord{RemoteAddr: \"1.2.3.4:80\", Strategy: \"fifo\", TunnelID: 7}\n\tnode := &nodeRecord{TCPListenAddr: \"0.0.0.0\", UDPListenAddr: \"[::]\"}\n\tservices := buildForwardServiceConfigs(\"1_2_0\", forward, nil, node, 22001, \"\", nil, false)\n\tif len(services) != 2 {\n\t\tt.Fatalf(\"expected 2 services, got %d\", len(services))\n\t}\n\ttcpAddr, _ := services[0][\"addr\"].(string)\n\tudpAddr, _ := services[1][\"addr\"].(string)\n\tif tcpAddr != \"0.0.0.0:22001\" {\n\t\tt.Fatalf(\"expected tcp addr 0.0.0.0:22001, got %q\", tcpAddr)\n\t}\n\tif udpAddr != \"[::]:22001\" {\n\t\tt.Fatalf(\"expected udp addr [::]:22001, got %q\", udpAddr)\n\t}\n}\n",
|
||
"additions": 32,
|
||
"deletions": 0,
|
||
"status": "modified"
|
||
},
|
||
{
|
||
"file": "go-backend/internal/http/handler/dual_stack_test.go",
|
||
"before": "package handler\n\nimport (\n\t\"testing\"\n)\n\n// ---------------------------------------------------------------------------\n// nodeSupportsV4 / nodeSupportsV6\n// ---------------------------------------------------------------------------\n\nfunc 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\nfunc TestNodeSupportsV6_Nil(t *testing.T) {\n\tif nodeSupportsV6(nil) {\n\t\tt.Fatal(\"nil node must not support v6\")\n\t}\n}\n\nfunc 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 needs support v4\")\n\t}\n}\n\nfunc TestNodeSupportsV6_ExplicitV6(t *testing.T) {\n\tn := &nodeRecord{ServerIPv6: \"2001:db8::1\"}\n\tif !nodeSupportsV6(n) {\n\t\tt.Fatal(\"explicit server_ip_v6 needs support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV4_OnlyV6Set(t *testing.T) {\n\tn := &nodeRecord{ServerIPv6: \"2001:db8::1\"}\n\tif nodeSupportsV4(n) {\n\t\tt.Fatal(\"node with only v6 should not support v4\")\n\t}\n}\n\nfunc TestNodeSupportsV6_OnlyV4Set(t *testing.T) {\n\tn := &nodeRecord{ServerIPv4: \"10.0.0.1\"}\n\tif nodeSupportsV6(n) {\n\t\tt.Fatal(\"node with only v4 should not support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV4_DualStack(t *testing.T) {\n\tn := &nodeRecord{ServerIPv4: \"10.0.0.1\", ServerIPv6: \"2001:db8::1\"}\n\tif !nodeSupportsV4(n) {\n\t\tt.Fatal(\"dual-stack node must support v4\")\n\t}\n}\n\nfunc TestNodeSupportsV6_DualStack(t *testing.T) {\n\tn := &nodeRecord{ServerIPv4: \"10.0.0.1\", ServerIPv6: \"2001:db8::1\"}\n\tif !nodeSupportsV6(n) {\n\t\tt.Fatal(\"dual-stack node must support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV4_LegacyV4Only(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"192.168.1.1\"}\n\tif !nodeSupportsV4(n) {\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 should not support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV6_LegacyV6Only(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"2001:db8::1\"}\n\tif !nodeSupportsV6(n) {\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 should not support v4\")\n\t}\n}\n\nfunc TestNodeSupportsV4_EmptyNode(t *testing.T) {\n\tn := &nodeRecord{}\n\tif nodeSupportsV4(n) {\n\t\tt.Fatal(\"empty node must not support v4\")\n\t}\n\tif nodeSupportsV6(n) {\n\t\tt.Fatal(\"empty node must not support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV4_LegacyBracketed(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"[::1]\"}\n\tif nodeSupportsV4(n) {\n\t\tt.Fatal(\"bracketed ipv6 must not support v4\")\n\t}\n\tif !nodeSupportsV6(n) {\n\t\tt.Fatal(\"bracketed ipv6 must support v6\")\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// pickNodeAddressV4 / pickNodeAddressV6\n// ---------------------------------------------------------------------------\n\nfunc TestPickNodeAddressV4_Nil(t *testing.T) {\n\tif pickNodeAddressV4(nil) != \"\" {\n\t\tt.Fatal(\"nil node must return empty\")\n\t}\n}\n\nfunc TestPickNodeAddressV6_Nil(t *testing.T) {\n\tif pickNodeAddressV6(nil) != \"\" {\n\t\tt.Fatal(\"nil node must return empty\")\n\t}\n}\n\nfunc TestPickNodeAddressV4_PreferExplicit(t *testing.T) {\n\tn := &nodeRecord{ServerIPv4: \"10.0.0.1\", ServerIP: \"192.168.0.1\"}\n\tgot := pickNodeAddressV4(n)\n\tif got != \"10.0.0.1\" {\n\t\tt.Fatalf(\"expected explicit v4 10.0.0.1, got %q\", got)\n\t}\n}\n\nfunc TestPickNodeAddressV4_FallbackLegacy(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"192.168.0.1\"}\n\tgot := pickNodeAddressV4(n)\n\tif got != \"192.168.0.1\" {\n\t\tt.Fatalf(\"expected legacy 192.168.0.1, got %q\", got)\n\t}\n}\n\nfunc TestPickNodeAddressV6_PreferExplicit(t *testing.T) {\n\tn := &nodeRecord{ServerIPv6: \"2001:db8::1\", ServerIP: \"::1\"}\n\tgot := pickNodeAddressV6(n)\n\tif got != \"2001:db8::1\" {\n\t\tt.Fatalf(\"expected explicit v6 2001:db8::1, got %q\", got)\n\t}\n}\n\nfunc TestPickNodeAddressV6_FallbackLegacy(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"::1\"}\n\tgot := pickNodeAddressV6(n)\n\tif got != \"::1\" {\n\t\tt.Fatalf(\"expected legacy ::1, got %q\", got)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// selectTunnelDialHost — core IP preference selection logic\n// ---------------------------------------------------------------------------\n\nfunc dualStackNode(name, v4, v6 string) *nodeRecord {\n\treturn &nodeRecord{\n\t\tName: name,\n\t\tServerIPv4: v4,\n\t\tServerIPv6: v6,\n\t}\n}\n\nfunc v4OnlyNode(name, v4 string) *nodeRecord {\n\treturn &nodeRecord{\n\t\tName: name,\n\t\tServerIPv4: v4,\n\t}\n}\n\nfunc v6OnlyNode(name, v6 string) *nodeRecord {\n\treturn &nodeRecord{\n\t\tName: name,\n\t\tServerIPv6: v6,\n\t}\n}\n\nfunc TestSelectTunnelDialHost_NilNodes(t *testing.T) {\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\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\tif err == nil {\n\t\tt.Fatal(\"expected error for nil fromNode\")\n\t}\n}\n\nfunc 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\thost, err := selectTunnelDialHost(from, to, \"\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\t// Default prefers v4 when both available\n\tif host != \"10.0.0.2\" {\n\t\tt.Fatalf(\"default preference should pick v4, got %q\", host)\n\t}\n}\n\nfunc 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\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\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(\"v4 preference should pick v4 address, got %q\", host)\n\t}\n}\n\nfunc 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\thost, err := selectTunnelDialHost(from, to, \"v6\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"v6 preference should pick v6 address, got %q\", host)\n\t}\n}\n\nfunc TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {\n\tfrom := v4OnlyNode(\"from\", \"10.0.0.1\")\n\tto := v4OnlyNode(\"to\", \"10.0.0.2\")\n\t// User prefers v6, but both nodes are v4-only — should fallback to v4\n\thost, err := selectTunnelDialHost(from, to, \"v6\", \"\")\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(\"v6 preference on v4-only nodes should fallback to v4, got %q\", host)\n\t}\n}\n\nfunc TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {\n\tfrom := v6OnlyNode(\"from\", \"2001:db8::1\")\n\tto := v6OnlyNode(\"to\", \"2001:db8::2\")\n\t// User prefers v4, but both nodes are v6-only — should fallback to v6\n\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"v4 preference on v6-only nodes should fallback to v6, got %q\", host)\n\t}\n}\n\nfunc TestSelectTunnelDialHost_Incompatible(t *testing.T) {\n\tfrom := v4OnlyNode(\"from\", \"10.0.0.1\")\n\tto := v6OnlyNode(\"to\", \"2001:db8::2\")\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}\n\nfunc TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {\n\tfrom := v6OnlyNode(\"from\", \"2001:db8::1\")\n\tto := v4OnlyNode(\"to\", \"10.0.0.2\")\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}\n\nfunc 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\t// Whitespace should be trimmed, treated as \"v6\"\n\thost, err := selectTunnelDialHost(from, to, \" v6 \", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"trimmed v6 preference should pick v6 address, got %q\", host)\n\t}\n}\n\nfunc 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\t// v6 preferred, but target only has v4 — should succeed with v4\n\thost, err := selectTunnelDialHost(from, to, \"v6\", \"\")\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(\"should fallback to v4 when target is v4-only, got %q\", host)\n\t}\n}\n\nfunc 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\t// v4 preferred, but target only has v6 — should succeed with v6\n\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"should fallback to v6 when target is v6-only, got %q\", host)\n\t}\n}\n\nfunc 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\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\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"10.0.0.2\" {\n\t\tt.Fatalf(\"should use v4 when from is v4-only, got %q\", host)\n\t}\n}\n\nfunc 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\t// v4 preferred, but from only has v6 — should use v6\n\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"should use v6 when from is v6-only, got %q\", host)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// nodeDisplayName\n// ---------------------------------------------------------------------------\n\nfunc TestNodeDisplayName_Nil(t *testing.T) {\n\tgot := nodeDisplayName(nil)\n\tif got != \"node\" {\n\t\tt.Fatalf(\"nil node display name should be 'node', got %q\", got)\n\t}\n}\n\nfunc TestNodeDisplayName_Named(t *testing.T) {\n\tn := &nodeRecord{ID: 42, Name: \"hk-node\"}\n\tgot := nodeDisplayName(n)\n\tif got != \"hk-node\" {\n\t\tt.Fatalf(\"expected 'hk-node', got %q\", got)\n\t}\n}\nfunc TestNodeDisplayName_Unnamed(t *testing.T) {\n\tn := &nodeRecord{ID: 42}\n\tgot := nodeDisplayName(n)\n\tif got != \"node_42\" {\n\t\tt.Fatalf(\"expected 'node_42', got %q\", got)\n\t}\n}\n",
|
||
"after": "package handler\n\nimport (\n\t\"testing\"\n)\n\n// ---------------------------------------------------------------------------\n// nodeSupportsV4 / nodeSupportsV6\n// ---------------------------------------------------------------------------\n\nfunc 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\nfunc TestBuildTunnelChainServiceConfig_UsesConnectIPForListen(t *testing.T) {\n\tnode := &nodeRecord{TCPListenAddr: \"[::]\"}\n\tchain := tunnelRuntimeNode{Protocol: \"tls\", Port: 21000, ConnectIP: \"2001:db8::88\"}\n\tservices := buildTunnelChainServiceConfig(99, chain, node)\n\tif len(services) != 1 {\n\t\tt.Fatalf(\"expected 1 service, got %d\", len(services))\n\t}\n\taddr, _ := services[0][\"addr\"].(string)\n\tif addr != \"[2001:db8::88]:21000\" {\n\t\tt.Fatalf(\"expected connectIp listen [2001:db8::88]:21000, got %q\", addr)\n\t}\n}\n\nfunc TestBuildTunnelChainServiceConfig_DefaultListenAddrWhenConnectIPEmpty(t *testing.T) {\n\tnode := &nodeRecord{TCPListenAddr: \"[::]\"}\n\tchain := tunnelRuntimeNode{Protocol: \"tls\", Port: 21001}\n\tservices := buildTunnelChainServiceConfig(99, chain, node)\n\tif len(services) != 1 {\n\t\tt.Fatalf(\"expected 1 service, got %d\", len(services))\n\t}\n\taddr, _ := services[0][\"addr\"].(string)\n\tif addr != \"[::]:21001\" {\n\t\tt.Fatalf(\"expected default listen [::]:21001, got %q\", addr)\n\t}\n}\n\nfunc TestNodeSupportsV6_Nil(t *testing.T) {\n\tif nodeSupportsV6(nil) {\n\t\tt.Fatal(\"nil node must not support v6\")\n\t}\n}\n\nfunc 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 needs support v4\")\n\t}\n}\n\nfunc TestNodeSupportsV6_ExplicitV6(t *testing.T) {\n\tn := &nodeRecord{ServerIPv6: \"2001:db8::1\"}\n\tif !nodeSupportsV6(n) {\n\t\tt.Fatal(\"explicit server_ip_v6 needs support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV4_OnlyV6Set(t *testing.T) {\n\tn := &nodeRecord{ServerIPv6: \"2001:db8::1\"}\n\tif nodeSupportsV4(n) {\n\t\tt.Fatal(\"node with only v6 should not support v4\")\n\t}\n}\n\nfunc TestNodeSupportsV6_OnlyV4Set(t *testing.T) {\n\tn := &nodeRecord{ServerIPv4: \"10.0.0.1\"}\n\tif nodeSupportsV6(n) {\n\t\tt.Fatal(\"node with only v4 should not support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV4_DualStack(t *testing.T) {\n\tn := &nodeRecord{ServerIPv4: \"10.0.0.1\", ServerIPv6: \"2001:db8::1\"}\n\tif !nodeSupportsV4(n) {\n\t\tt.Fatal(\"dual-stack node must support v4\")\n\t}\n}\n\nfunc TestNodeSupportsV6_DualStack(t *testing.T) {\n\tn := &nodeRecord{ServerIPv4: \"10.0.0.1\", ServerIPv6: \"2001:db8::1\"}\n\tif !nodeSupportsV6(n) {\n\t\tt.Fatal(\"dual-stack node must support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV4_LegacyV4Only(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"192.168.1.1\"}\n\tif !nodeSupportsV4(n) {\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 should not support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV6_LegacyV6Only(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"2001:db8::1\"}\n\tif !nodeSupportsV6(n) {\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 should not support v4\")\n\t}\n}\n\nfunc TestNodeSupportsV4_EmptyNode(t *testing.T) {\n\tn := &nodeRecord{}\n\tif nodeSupportsV4(n) {\n\t\tt.Fatal(\"empty node must not support v4\")\n\t}\n\tif nodeSupportsV6(n) {\n\t\tt.Fatal(\"empty node must not support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV4_LegacyBracketed(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"[::1]\"}\n\tif nodeSupportsV4(n) {\n\t\tt.Fatal(\"bracketed ipv6 must not support v4\")\n\t}\n\tif !nodeSupportsV6(n) {\n\t\tt.Fatal(\"bracketed ipv6 must support v6\")\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// pickNodeAddressV4 / pickNodeAddressV6\n// ---------------------------------------------------------------------------\n\nfunc TestPickNodeAddressV4_Nil(t *testing.T) {\n\tif pickNodeAddressV4(nil) != \"\" {\n\t\tt.Fatal(\"nil node must return empty\")\n\t}\n}\n\nfunc TestPickNodeAddressV6_Nil(t *testing.T) {\n\tif pickNodeAddressV6(nil) != \"\" {\n\t\tt.Fatal(\"nil node must return empty\")\n\t}\n}\n\nfunc TestPickNodeAddressV4_PreferExplicit(t *testing.T) {\n\tn := &nodeRecord{ServerIPv4: \"10.0.0.1\", ServerIP: \"192.168.0.1\"}\n\tgot := pickNodeAddressV4(n)\n\tif got != \"10.0.0.1\" {\n\t\tt.Fatalf(\"expected explicit v4 10.0.0.1, got %q\", got)\n\t}\n}\n\nfunc TestPickNodeAddressV4_FallbackLegacy(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"192.168.0.1\"}\n\tgot := pickNodeAddressV4(n)\n\tif got != \"192.168.0.1\" {\n\t\tt.Fatalf(\"expected legacy 192.168.0.1, got %q\", got)\n\t}\n}\n\nfunc TestPickNodeAddressV6_PreferExplicit(t *testing.T) {\n\tn := &nodeRecord{ServerIPv6: \"2001:db8::1\", ServerIP: \"::1\"}\n\tgot := pickNodeAddressV6(n)\n\tif got != \"2001:db8::1\" {\n\t\tt.Fatalf(\"expected explicit v6 2001:db8::1, got %q\", got)\n\t}\n}\n\nfunc TestPickNodeAddressV6_FallbackLegacy(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"::1\"}\n\tgot := pickNodeAddressV6(n)\n\tif got != \"::1\" {\n\t\tt.Fatalf(\"expected legacy ::1, got %q\", got)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// selectTunnelDialHost — core IP preference selection logic\n// ---------------------------------------------------------------------------\n\nfunc dualStackNode(name, v4, v6 string) *nodeRecord {\n\treturn &nodeRecord{\n\t\tName: name,\n\t\tServerIPv4: v4,\n\t\tServerIPv6: v6,\n\t}\n}\n\nfunc v4OnlyNode(name, v4 string) *nodeRecord {\n\treturn &nodeRecord{\n\t\tName: name,\n\t\tServerIPv4: v4,\n\t}\n}\n\nfunc v6OnlyNode(name, v6 string) *nodeRecord {\n\treturn &nodeRecord{\n\t\tName: name,\n\t\tServerIPv6: v6,\n\t}\n}\n\nfunc TestSelectTunnelDialHost_NilNodes(t *testing.T) {\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\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\tif err == nil {\n\t\tt.Fatal(\"expected error for nil fromNode\")\n\t}\n}\n\nfunc 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\thost, err := selectTunnelDialHost(from, to, \"\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\t// Default prefers v4 when both available\n\tif host != \"10.0.0.2\" {\n\t\tt.Fatalf(\"default preference should pick v4, got %q\", host)\n\t}\n}\n\nfunc 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\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\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(\"v4 preference should pick v4 address, got %q\", host)\n\t}\n}\n\nfunc 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\thost, err := selectTunnelDialHost(from, to, \"v6\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"v6 preference should pick v6 address, got %q\", host)\n\t}\n}\n\nfunc TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {\n\tfrom := v4OnlyNode(\"from\", \"10.0.0.1\")\n\tto := v4OnlyNode(\"to\", \"10.0.0.2\")\n\t// User prefers v6, but both nodes are v4-only — should fallback to v4\n\thost, err := selectTunnelDialHost(from, to, \"v6\", \"\")\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(\"v6 preference on v4-only nodes should fallback to v4, got %q\", host)\n\t}\n}\n\nfunc TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {\n\tfrom := v6OnlyNode(\"from\", \"2001:db8::1\")\n\tto := v6OnlyNode(\"to\", \"2001:db8::2\")\n\t// User prefers v4, but both nodes are v6-only — should fallback to v6\n\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"v4 preference on v6-only nodes should fallback to v6, got %q\", host)\n\t}\n}\n\nfunc TestSelectTunnelDialHost_Incompatible(t *testing.T) {\n\tfrom := v4OnlyNode(\"from\", \"10.0.0.1\")\n\tto := v6OnlyNode(\"to\", \"2001:db8::2\")\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}\n\nfunc TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {\n\tfrom := v6OnlyNode(\"from\", \"2001:db8::1\")\n\tto := v4OnlyNode(\"to\", \"10.0.0.2\")\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}\n\nfunc 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\t// Whitespace should be trimmed, treated as \"v6\"\n\thost, err := selectTunnelDialHost(from, to, \" v6 \", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"trimmed v6 preference should pick v6 address, got %q\", host)\n\t}\n}\n\nfunc 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\t// v6 preferred, but target only has v4 — should succeed with v4\n\thost, err := selectTunnelDialHost(from, to, \"v6\", \"\")\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(\"should fallback to v4 when target is v4-only, got %q\", host)\n\t}\n}\n\nfunc 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\t// v4 preferred, but target only has v6 — should succeed with v6\n\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"should fallback to v6 when target is v6-only, got %q\", host)\n\t}\n}\n\nfunc 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\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\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"10.0.0.2\" {\n\t\tt.Fatalf(\"should use v4 when from is v4-only, got %q\", host)\n\t}\n}\n\nfunc 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\t// v4 preferred, but from only has v6 — should use v6\n\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"should use v6 when from is v6-only, got %q\", host)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// nodeDisplayName\n// ---------------------------------------------------------------------------\n\nfunc TestNodeDisplayName_Nil(t *testing.T) {\n\tgot := nodeDisplayName(nil)\n\tif got != \"node\" {\n\t\tt.Fatalf(\"nil node display name should be 'node', got %q\", got)\n\t}\n}\n\nfunc TestNodeDisplayName_Named(t *testing.T) {\n\tn := &nodeRecord{ID: 42, Name: \"hk-node\"}\n\tgot := nodeDisplayName(n)\n\tif got != \"hk-node\" {\n\t\tt.Fatalf(\"expected 'hk-node', got %q\", got)\n\t}\n}\nfunc TestNodeDisplayName_Unnamed(t *testing.T) {\n\tn := &nodeRecord{ID: 42}\n\tgot := nodeDisplayName(n)\n\tif got != \"node_42\" {\n\t\tt.Fatalf(\"expected 'node_42', got %q\", got)\n\t}\n}\n",
|
||
"additions": 26,
|
||
"deletions": 0,
|
||
"status": "modified"
|
||
},
|
||
{
|
||
"file": "go-backend/internal/http/handler/mutations.go",
|
||
"before": "package handler\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"database/sql\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math/big\"\n\t\"net\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"go-backend/internal/http/client\"\n\t\"go-backend/internal/http/response\"\n\t\"go-backend/internal/security\"\n\t\"go-backend/internal/store/model\"\n\t\"go-backend/internal/store/repo\"\n\n\t\"gorm.io/gorm\"\n)\n\nfunc (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\n\tusername := asString(req[\"user\"])\n\tpwd := asString(req[\"pwd\"])\n\tif username == \"\" || pwd == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户名或密码不能为空\"))\n\t\treturn\n\t}\n\n\texists, err := h.repo.UserExists(username)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif exists {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户名已存在\"))\n\t\treturn\n\t}\n\n\tstatus := asInt(req[\"status\"], 1)\n\tflow := asInt64(req[\"flow\"], 100)\n\tnum := asInt(req[\"num\"], 10)\n\texpTime := asInt64(req[\"expTime\"], time.Now().Add(365*24*time.Hour).UnixMilli())\n\tflowResetTime := asInt64(req[\"flowResetTime\"], 1)\n\troleID := 1\n\tnow := time.Now().UnixMilli()\n\n\tuserID, err := h.repo.CreateUser(username, security.MD5(pwd), roleID, expTime, flow, flowResetTime, num, status, now)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tgroupIDs := asInt64Slice(req[\"groupIds\"])\n\tif len(groupIDs) > 0 {\n\t\tif addErr := h.repo.AddUserToGroups(userID, groupIDs, now); addErr == nil {\n\t\t\tfor _, gid := range groupIDs {\n\t\t\t\t_ = h.syncPermissionsByUserGroup(gid)\n\t\t\t}\n\t\t}\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户ID不能为空\"))\n\t\treturn\n\t}\n\tusername := asString(req[\"user\"])\n\tif username == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户名不能为空\"))\n\t\treturn\n\t}\n\n\troleID, err := h.repo.GetUserRoleID(id)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif roleID == 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请不要作死\"))\n\t\treturn\n\t}\n\n\tdup, err := h.repo.UserExistsExcluding(username, id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif dup {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户名已存在\"))\n\t\treturn\n\t}\n\n\tflow := asInt64(req[\"flow\"], 100)\n\tnum := asInt(req[\"num\"], 10)\n\texpTime := asInt64(req[\"expTime\"], time.Now().Add(365*24*time.Hour).UnixMilli())\n\tflowResetTime := asInt64(req[\"flowResetTime\"], 1)\n\tstatus := asInt(req[\"status\"], 1)\n\tnow := time.Now().UnixMilli()\n\n\tpwd := asString(req[\"pwd\"])\n\tif strings.TrimSpace(pwd) == \"\" {\n\t\tif err := h.repo.UpdateUserWithoutPassword(id, username, flow, num, expTime, flowResetTime, status, now); err != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tif err := h.repo.UpdateUserWithPassword(id, username, security.MD5(pwd), flow, num, expTime, flowResetTime, status, now); err != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\n\th.repo.PropagateUserFlowToTunnels(id, flow, num, expTime, flowResetTime)\n\n\tif groupIDsRaw, ok := req[\"groupIds\"]; ok {\n\t\tnewGroupIDs := asInt64Slice(groupIDsRaw)\n\t\tif affected, replaceErr := h.repo.ReplaceUserGroupsByUserID(id, newGroupIDs, now); replaceErr == nil {\n\t\t\tfor _, gid := range affected {\n\t\t\t\t_ = h.syncPermissionsByUserGroup(gid)\n\t\t\t}\n\t\t}\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userGroups(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tids, err := h.repo.GetUserGroupIDsByUserID(id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OK(ids))\n}\n\nfunc (h *Handler) userDelete(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\n\troleID, err := h.repo.GetUserRoleID(id)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif roleID == 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请不要作死\"))\n\t\treturn\n\t}\n\n\tif err := h.repo.DeleteUserCascade(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userResetFlow(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\ttypeVal := asInt(req[\"type\"], 0)\n\tif id <= 0 || (typeVal != 1 && typeVal != 2) {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\n\tif typeVal == 1 {\n\t\th.repo.ResetUserFlowByUser(id, time.Now().UnixMilli())\n\t} else {\n\t\th.repo.ResetUserFlowByUserTunnel(id)\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tname := asString(req[\"name\"])\n\tserverIP := asString(req[\"serverIp\"])\n\tif name == \"\" || serverIP == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"节点名称和地址不能为空\"))\n\t\treturn\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tinx := h.repo.NextIndex(\"node\")\n\tif err := h.repo.CreateNode(\n\t\tname,\n\t\trandomToken(16),\n\t\tserverIP,\n\t\tnullableText(asString(req[\"serverIpV4\"])),\n\t\tnullableText(asString(req[\"serverIpV6\"])),\n\t\tdefaultString(asString(req[\"port\"]), \"1000-65535\"),\n\t\tnullableText(asString(req[\"interfaceName\"])),\n\t\tnullableText(\"\"),\n\t\tasInt(req[\"http\"], 0),\n\t\tasInt(req[\"tls\"], 0),\n\t\tasInt(req[\"socks\"], 0),\n\t\tnow,\n\t\t0,\n\t\tdefaultString(asString(req[\"tcpListenAddr\"]), \"[::]\"),\n\t\tdefaultString(asString(req[\"udpListenAddr\"]), \"[::]\"),\n\t\tinx,\n\t\tasInt(req[\"isRemote\"], 0),\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\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"节点ID不能为空\"))\n\t\treturn\n\t}\n\n\tcurrentStatus, currentHTTP, currentTLS, currentSocks, err := h.repo.GetNodeStatusFields(id)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"节点不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tnewHTTP := asInt(req[\"http\"], currentHTTP)\n\tnewTLS := asInt(req[\"tls\"], currentTLS)\n\tnewSocks := asInt(req[\"socks\"], currentSocks)\n\tif currentStatus == 1 && (newHTTP != currentHTTP || newTLS != currentTLS || newSocks != currentSocks) {\n\t\tif err := h.applyNodeProtocolChange(id, newHTTP, newTLS, newSocks); err != nil {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tif err := h.repo.UpdateNode(id,\n\t\tasString(req[\"name\"]),\n\t\tasString(req[\"serverIp\"]),\n\t\tnullableText(asString(req[\"serverIpV4\"])),\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\t\tdefaultString(asString(req[\"tcpListenAddr\"]), \"[::]\"),\n\t\tdefaultString(asString(req[\"udpListenAddr\"]), \"[::]\"),\n\t\tnow,\n\t); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeDelete(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tif err := h.deleteNodeByID(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\n\tvar req struct {\n\t\tID int64 `json:\"id\"`\n\t\tChannel string `json:\"channel\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tif req.ID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"参数错误\"))\n\t\treturn\n\t}\n\n\tchannel := normalizeReleaseChannel(req.Channel)\n\tversion, err := resolveLatestReleaseByChannel(channel)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, fmt.Sprintf(\"获取最新%s失败: %v\", releaseChannelLabel(channel), err)))\n\t\treturn\n\t}\n\n\tsecret, err := h.repo.GetNodeSecret(req.ID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"节点不存在\"))\n\t\treturn\n\t}\n\tpanelAddr, err := h.repo.GetViteConfigValue(\"ip\")\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"请先前往网站配置中设置ip\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tcmd := fmt.Sprintf(\"curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && VERSION=%s ./install.sh -a %s -s %s\", version, version, processServerAddress(panelAddr), secret)\n\tresponse.WriteJSON(w, response.OK(cmd))\n}\n\nfunc (h *Handler) nodeUpdateOrder(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req struct {\n\t\tNodes []struct {\n\t\t\tID int64 `json:\"id\"`\n\t\t\tInx int `json:\"inx\"`\n\t\t} `json:\"nodes\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tfor _, n := range req.Nodes {\n\t\th.repo.UpdateNodeOrder(n.ID, n.Inx, time.Now().UnixMilli())\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeBatchDelete(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tfor _, id := range ids {\n\t\t_ = h.deleteNodeByID(id)\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeCheckStatus(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\titems, err := h.repo.ListNodes()\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OK(items))\n}\n\nfunc (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tname := asString(req[\"name\"])\n\tif name == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道名称不能为空\"))\n\t\treturn\n\t}\n\tnameDup, err := h.repo.TunnelNameExists(name)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif nameDup {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道名称重复\"))\n\t\treturn\n\t}\n\n\ttypeVal := asInt(req[\"type\"], 1)\n\tflow := asInt64(req[\"flow\"], 1)\n\tstatus := asInt(req[\"status\"], 1)\n\ttrafficRatio := asFloat(req[\"trafficRatio\"], 1.0)\n\tinIP := asString(req[\"inIp\"])\n\tipPreference := asString(req[\"ipPreference\"])\n\tnow := time.Now().UnixMilli()\n\tinx := h.repo.NextIndex(\"tunnel\")\n\tlocalDomain := h.federationLocalDomain()\n\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\n\truntimeState, err := h.prepareTunnelCreateState(tx, req, typeVal, 0)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\truntimeState.IPPreference = ipPreference\n\tif strings.TrimSpace(inIP) == \"\" {\n\t\tinIP = buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes, ipPreference)\n\t}\n\n\tif len(runtimeState.InNodes) > 0 {\n\t\tfirstNodeID := runtimeState.InNodes[0].NodeID\n\t\tisRemote, rUrl, rToken, _ := h.repo.GetNodeRemoteFieldsTx(tx, firstNodeID)\n\t\tif isRemote == 1 {\n\t\t\tfc := client.NewFederationClient()\n\n\t\t\ttargetProto := \"tcp\"\n\t\t\ttargetPort := 0\n\t\t\ttargetAddr := \"\"\n\n\t\t\tif typeVal == 1 {\n\t\t\t\tif len(runtimeState.OutNodes) > 0 {\n\t\t\t\t\toutNode := runtimeState.OutNodes[0]\n\t\t\t\t\toutNodeRec := runtimeState.Nodes[outNode.NodeID]\n\t\t\t\t\ttargetAddr = processServerAddress(outNodeRec.ServerIP)\n\t\t\t\t\tif outNode.Port > 0 {\n\t\t\t\t\t\ttargetAddr = fmt.Sprintf(\"%s:%d\", targetAddr, outNode.Port)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif len(runtimeState.InNodes) > 0 {\n\t\t\t\t\tinNodesRaw := asMapSlice(req[\"inNodeId\"])\n\t\t\t\t\tif len(inNodesRaw) > 0 {\n\t\t\t\t\t\ttargetPort = asInt(inNodesRaw[0][\"port\"], 0)\n\t\t\t\t\t\ttargetProto = defaultString(asString(inNodesRaw[0][\"protocol\"]), \"tcp\")\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif targetPort > 0 && targetAddr != \"\" {\n\t\t\t\t\tinNodeRec := runtimeState.Nodes[firstNodeID]\n\t\t\t\t\tif err := validateRemoteNodePort(inNodeRec, targetPort); err != nil {\n\t\t\t\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tdomainCfg, _ := h.repo.GetConfigByName(\"panel_domain\")\n\t\t\t\t\tlocalDomain := \"\"\n\t\t\t\t\tif domainCfg != nil {\n\t\t\t\t\t\tlocalDomain = domainCfg.Value\n\t\t\t\t\t}\n\t\t\t\t\t_, err := fc.CreateTunnel(rUrl.String, rToken.String, localDomain, targetProto, targetPort, targetAddr)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"Remote tunnel creation failed: \"+err.Error()))\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tvar tunnelInIP sql.NullString\n\tif trimmed := strings.TrimSpace(inIP); trimmed != \"\" {\n\t\ttunnelInIP = sql.NullString{String: trimmed, Valid: true}\n\t}\n\ttunnel := model.Tunnel{\n\t\tName: name,\n\t\tTrafficRatio: trafficRatio,\n\t\tType: typeVal,\n\t\tProtocol: \"tls\",\n\t\tFlow: flow,\n\t\tCreatedTime: now,\n\t\tUpdatedTime: now,\n\t\tStatus: status,\n\t\tInIP: tunnelInIP,\n\t\tInx: inx,\n\t\tIPPreference: ipPreference,\n\t}\n\tif err := tx.Create(&tunnel).Error; err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\ttunnelID := tunnel.ID\n\truntimeState.TunnelID = tunnelID\n\tvar federationBindings []repo.FederationTunnelBinding\n\tvar federationReleaseRefs []federationRuntimeReleaseRef\n\tfederationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState, localDomain)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tapplyTunnelPortsToRequest(req, runtimeState)\n\tif err := h.replaceTunnelChainsTx(tx, tunnelID, req); err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := tx.Commit().Error; err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif typeVal == 2 {\n\t\tcreatedChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)\n\t\tif applyErr != nil {\n\t\t\th.rollbackTunnelRuntime(createdChains, createdServices, tunnelID)\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\t_ = h.deleteTunnelByID(tunnelID)\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(applyErr.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) cleanupTunnelRuntime(tunnelID int64) {\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil || tunnel.Type != 2 {\n\t\treturn\n\t}\n\tchainRows, err := h.listChainNodesForTunnel(tunnelID)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tserviceName := fmt.Sprintf(\"%d_tls\", tunnelID)\n\tchainName := fmt.Sprintf(\"chains_%d\", tunnelID)\n\n\tfor _, row := range chainRows {\n\t\tif row.ChainType == 1 {\n\t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteChains\", map[string]interface{}{\"chain\": chainName}, false, true)\n\t\t} else if row.ChainType == 2 {\n\t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteChains\", map[string]interface{}{\"chain\": chainName}, false, true)\n\t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteService\", map[string]interface{}{\"services\": []string{serviceName}}, false, true)\n\t\t} else if row.ChainType == 3 {\n\t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteService\", map[string]interface{}{\"services\": []string{serviceName}}, false, true)\n\t\t}\n\t}\n}\n\nfunc (h *Handler) tunnelGet(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\titems, err := h.repo.ListTunnels()\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tfor _, it := range items {\n\t\tif asInt64(it[\"id\"], 0) == id {\n\t\t\tresponse.WriteJSON(w, response.OK(it))\n\t\t\treturn\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.ErrDefault(\"隧道不存在\"))\n}\n\nfunc (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道ID不能为空\"))\n\t\treturn\n\t}\n\n\th.cleanupTunnelRuntime(id)\n\th.cleanupFederationRuntime(id)\n\n\tnow := time.Now().UnixMilli()\n\ttypeVal := asInt(req[\"type\"], 1)\n\tipPreference := asString(req[\"ipPreference\"])\n\tlocalDomain := h.federationLocalDomain()\n\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\n\truntimeState, err := h.prepareTunnelCreateState(tx, req, typeVal, id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\truntimeState.TunnelID = id\n\truntimeState.IPPreference = ipPreference\n\n\tinIp := buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes, ipPreference)\n\n\tvar federationBindings []repo.FederationTunnelBinding\n\tvar federationReleaseRefs []federationRuntimeReleaseRef\n\tfederationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState, localDomain)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tapplyTunnelPortsToRequest(req, runtimeState)\n\n\tif err := h.repo.UpdateTunnelTx(\n\t\ttx,\n\t\tid,\n\t\tasString(req[\"name\"]),\n\t\ttypeVal,\n\t\tasInt64(req[\"flow\"], 1),\n\t\tasFloat(req[\"trafficRatio\"], 1.0),\n\t\tasInt(req[\"status\"], 1),\n\t\tinIp,\n\t\tipPreference,\n\t\tnow,\n\t); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tif err := h.repo.DeleteChainTunnelsByTunnelTx(tx, id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.replaceTunnelChainsTx(tx, id, req); err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.repo.ReplaceFederationTunnelBindingsTx(tx, id, federationBindings); err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := tx.Commit().Error; err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tif typeVal == 2 {\n\t\tcreatedChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)\n\t\tif applyErr != nil {\n\t\t\th.rollbackTunnelRuntime(createdChains, createdServices, id)\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\t_ = h.repo.DeleteFederationTunnelBindingsByTunnel(id)\n\t\t\tif len(federationReleaseRefs) == 0 && shouldDeferTunnelRuntimeApplyError(applyErr) {\n\t\t\t\tresponse.WriteJSON(w, response.OKEmpty())\n\t\t\t\treturn\n\t\t\t}\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(applyErr.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\n\tif forwards, fwdErr := h.listForwardsByTunnel(id); fwdErr == nil {\n\t\tfor i := range forwards {\n\t\t\t_ = h.syncForwardServices(&forwards[i], \"UpdateService\", true)\n\t\t}\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\th.cleanupTunnelRuntime(id)\n\th.cleanupFederationRuntime(id)\n\tif err := h.deleteTunnelByID(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) tunnelDiagnose(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := asInt64FromBodyKey(r, w, \"tunnelId\")\n\tif id <= 0 {\n\t\treturn\n\t}\n\tctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)\n\tdefer cancel()\n\tresult, err := h.diagnoseTunnelRuntime(ctx, id)\n\tif err != nil {\n\t\tif strings.Contains(err.Error(), \"不存在\") || strings.Contains(err.Error(), \"不完整\") {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OK(result))\n}\n\nfunc (h *Handler) tunnelUpdateOrder(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req struct {\n\t\tTunnels []struct {\n\t\t\tID int64 `json:\"id\"`\n\t\t\tInx int `json:\"inx\"`\n\t\t} `json:\"tunnels\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tfor _, t := range req.Tunnels {\n\t\th.repo.UpdateTunnelOrder(t.ID, t.Inx, time.Now().UnixMilli())\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tsuccess := 0\n\tfail := 0\n\tfor _, id := range ids {\n\t\th.cleanupTunnelRuntime(id)\n\t\th.cleanupFederationRuntime(id)\n\t\tif err := h.deleteTunnelByID(id); err != nil {\n\t\t\tfail++\n\t\t} else {\n\t\t\tsuccess++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": success, \"failCount\": fail}))\n}\n\nfunc (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, error) {\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tchainRows, err := h.listChainNodesForTunnel(tunnelID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tipPreference := h.repo.GetTunnelIPPreference(tunnelID)\n\n\tstate := &tunnelCreateState{\n\t\tTunnelID: tunnelID,\n\t\tType: tunnel.Type,\n\t\tIPPreference: ipPreference,\n\t\tInNodes: make([]tunnelRuntimeNode, 0),\n\t\tChainHops: make([][]tunnelRuntimeNode, 0),\n\t\tOutNodes: make([]tunnelRuntimeNode, 0),\n\t\tNodes: make(map[int64]*nodeRecord),\n\t\tNodeIDList: make([]int64, 0),\n\t}\n\n\tinNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)\n\n\tfor _, r := range inNodes {\n\t\tstate.InNodes = append(state.InNodes, tunnelRuntimeNode{\n\t\t\tNodeID: r.NodeID,\n\t\t\tProtocol: r.Protocol,\n\t\t\tStrategy: r.Strategy,\n\t\t\tChainType: 1,\n\t\t})\n\t\tstate.NodeIDList = append(state.NodeIDList, r.NodeID)\n\t}\n\n\tfor _, r := range outNodes {\n\t\tstate.OutNodes = append(state.OutNodes, tunnelRuntimeNode{\n\t\t\tNodeID: r.NodeID,\n\t\t\tProtocol: r.Protocol,\n\t\t\tStrategy: r.Strategy,\n\t\t\tChainType: 3,\n\t\t\tPort: r.Port,\n\t\t})\n\t\tstate.NodeIDList = append(state.NodeIDList, r.NodeID)\n\t}\n\n\tfor _, hop := range chainHops {\n\t\tstateHop := make([]tunnelRuntimeNode, 0)\n\t\tfor _, r := range hop {\n\t\t\tstateHop = append(stateHop, tunnelRuntimeNode{\n\t\t\t\tNodeID: r.NodeID,\n\t\t\t\tProtocol: r.Protocol,\n\t\t\t\tStrategy: r.Strategy,\n\t\t\t\tChainType: 2,\n\t\t\t\tInx: int(r.Inx),\n\t\t\t\tPort: r.Port,\n\t\t\t})\n\t\t\tstate.NodeIDList = append(state.NodeIDList, r.NodeID)\n\t\t}\n\t\tstate.ChainHops = append(state.ChainHops, stateHop)\n\t}\n\n\tseen := make(map[int64]struct{})\n\tfor _, id := range state.NodeIDList {\n\t\tif _, ok := seen[id]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[id] = struct{}{}\n\t\tnode, err := h.getNodeRecord(id)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tstate.Nodes[id] = node\n\t}\n\n\treturn state, nil\n}\n\nfunc (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif tunnel.Type == 2 {\n\t\th.cleanupTunnelRuntime(tunnelID)\n\t\th.cleanupFederationRuntime(tunnelID)\n\t\tstate, err := h.reconstructTunnelState(tunnelID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfederationBindings, federationReleaseRefs, fedErr := h.applyFederationRuntime(state, h.federationLocalDomain())\n\t\tif fedErr != nil {\n\t\t\treturn fedErr\n\t\t}\n\t\ttx := h.repo.BeginTx()\n\t\tif tx.Error != nil {\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\treturn tx.Error\n\t\t}\n\t\tif replaceErr := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); replaceErr != nil {\n\t\t\ttx.Rollback()\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\treturn replaceErr\n\t\t}\n\t\tif commitErr := tx.Commit().Error; commitErr != nil {\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\treturn commitErr\n\t\t}\n\t\t_, _, applyErr := h.applyTunnelRuntime(state)\n\t\tif applyErr != nil {\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\t_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)\n\t\t\treturn applyErr\n\t\t}\n\t}\n\n\tforwards, err := h.listForwardsByTunnel(tunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor i := range forwards {\n\t\tif err := h.syncForwardServices(&forwards[i], \"UpdateService\", true); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tsuccess := 0\n\tfail := 0\n\tfor _, tunnelID := range ids {\n\t\tif err := h.redeployTunnelAndForwards(tunnelID); err != nil {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tsuccess++\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": success, \"failCount\": fail}))\n}\n\nfunc (h *Handler) userTunnelAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tif err := h.upsertUserTunnel(req); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userTunnelBatchAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tUserID int64 `json:\"userId\"`\n\t\tTunnels []struct {\n\t\t\tTunnelID int64 `json:\"tunnelId\"`\n\t\t\tSpeedID *int64 `json:\"speedId\"`\n\t\t} `json:\"tunnels\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.UserID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tfor _, t := range req.Tunnels {\n\t\tm := map[string]interface{}{\"userId\": req.UserID, \"tunnelId\": t.TunnelID}\n\t\tif t.SpeedID != nil {\n\t\t\tm[\"speedId\"] = *t.SpeedID\n\t\t}\n\t\tif err := h.upsertUserTunnel(m); err != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userTunnelRemove(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tif err := h.repo.DeleteUserTunnel(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userTunnelUpdate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"权限ID不能为空\"))\n\t\treturn\n\t}\n\n\tspeedID := asAnyToInt64Ptr(req[\"speedId\"])\n\tif err := h.validateSpeedLimitReference(speedID); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tuserID, tunnelID, utErr := h.repo.GetUserTunnelUserAndTunnel(id)\n\tif utErr != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, utErr.Error()))\n\t\treturn\n\t}\n\n\t_, oldFlow, oldNum, oldExpTime, oldFlowReset, oldSpeedID, oldStatus, oldErr :=\n\t\th.repo.GetExistingUserTunnel(userID, tunnelID)\n\tif oldErr != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, oldErr.Error()))\n\t\treturn\n\t}\n\n\tif err := h.repo.UpdateUserTunnel(id,\n\t\tasInt64(req[\"flow\"], 0),\n\t\tasInt(req[\"num\"], 0),\n\t\tasInt64(req[\"expTime\"], time.Now().Add(365*24*time.Hour).UnixMilli()),\n\t\tasInt64(req[\"flowResetTime\"], 1),\n\t\tnullableInt(speedID),\n\t\tasInt(req[\"status\"], 1),\n\t); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tif syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {\n\t\trollbackErr := h.repo.UpdateUserTunnel(\n\t\t\tid,\n\t\t\toldFlow,\n\t\t\tint(oldNum),\n\t\t\toldExpTime,\n\t\t\toldFlowReset,\n\t\t\toldSpeedID,\n\t\t\toldStatus,\n\t\t)\n\t\tif rollbackErr != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, fmt.Sprintf(\"下发失败且回滚失败: %v; 回滚错误: %v\", syncErr, rollbackErr)))\n\t\t\treturn\n\t\t}\n\n\t\tresponse.WriteJSON(w, response.Err(-2, fmt.Sprintf(\"下发失败,已回滚: %v\", syncErr)))\n\t\treturn\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tuserID, roleID, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ttunnelID := asInt64(req[\"tunnelId\"], 0)\n\tif tunnelID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道ID不能为空\"))\n\t\treturn\n\t}\n\tif err := h.ensureTunnelPermission(userID, roleID, tunnelID); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道不存在\"))\n\t\treturn\n\t}\n\tif tunnel.Status != 1 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道已禁用,无法创建转发\"))\n\t\treturn\n\t}\n\tname := asString(req[\"name\"])\n\tremoteAddr := asString(req[\"remoteAddr\"])\n\tif name == \"\" || remoteAddr == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发名称和目标地址不能为空\"))\n\t\treturn\n\t}\n\tspeedID := asAnyToInt64Ptr(req[\"speedId\"])\n\tif speedID != nil {\n\t\texists, speedErr := h.repo.SpeedLimitExists(*speedID)\n\t\tif speedErr != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, speedErr.Error()))\n\t\t\treturn\n\t\t}\n\t\tif !exists {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"限速规则不存在\"))\n\t\t\treturn\n\t\t}\n\t}\n\tport := asInt(req[\"inPort\"], 0)\n\tif port <= 0 {\n\t\tport = h.pickTunnelPort(tunnelID)\n\t}\n\tif port <= 0 {\n\t\tport = 10000\n\t}\n\tentryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)\n\tfor _, nodeID := range entryNodes {\n\t\tnode, nodeErr := h.getNodeRecord(nodeID)\n\t\tif nodeErr != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif err := validateRemoteNodePort(node, port); err != nil {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tnow := time.Now().UnixMilli()\n\tinx := h.repo.NextIndex(\"forward\")\n\tuserName := h.repo.GetUsernameByID(userID)\n\tif userName == \"\" {\n\t\tuserName = \"user\"\n\t}\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\t}\n\tcreatedForward, err := h.getForwardRecord(forwardID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.syncForwardServices(createdForward, \"UpdateService\", true); err != nil {\n\t\t_ = h.deleteForwardByID(forwardID)\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发ID不能为空\"))\n\t\treturn\n\t}\n\tforward, actorUserID, actorRole, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\toldPorts, err := h.listForwardPorts(id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\ttunnelID := asInt64(req[\"tunnelId\"], forward.TunnelID)\n\tif tunnelID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道ID不能为空\"))\n\t\treturn\n\t}\n\tif err := h.ensureTunnelPermission(actorUserID, actorRole, tunnelID); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道不存在\"))\n\t\treturn\n\t}\n\tif tunnel.Status != 1 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道已禁用,无法更新转发\"))\n\t\treturn\n\t}\n\n\tname := strings.TrimSpace(asString(req[\"name\"]))\n\tif name == \"\" {\n\t\tname = forward.Name\n\t}\n\tremoteAddr := strings.TrimSpace(asString(req[\"remoteAddr\"]))\n\tif remoteAddr == \"\" {\n\t\tremoteAddr = forward.RemoteAddr\n\t}\n\tstrategy := strings.TrimSpace(asString(req[\"strategy\"]))\n\tif strategy == \"\" {\n\t\tstrategy = forward.Strategy\n\t}\n\tspeedID := asAnyToInt64Ptr(req[\"speedId\"])\n\tif speedID != nil {\n\t\texists, speedErr := h.repo.SpeedLimitExists(*speedID)\n\t\tif speedErr != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, speedErr.Error()))\n\t\t\treturn\n\t\t}\n\t\tif !exists {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"限速规则不存在\"))\n\t\t\treturn\n\t\t}\n\t}\n\tnewSpeedID := forward.SpeedID\n\tif speedID != nil {\n\t\tnewSpeedID = sql.NullInt64{Int64: *speedID, Valid: true}\n\t} else if _, ok := req[\"speedId\"]; ok {\n\t\tnewSpeedID = sql.NullInt64{Valid: false}\n\t}\n\n\tport := asInt(req[\"inPort\"], 0)\n\tif port <= 0 {\n\t\tminPort := h.repo.GetMinForwardPort(id)\n\t\tif minPort.Valid {\n\t\t\tport = int(minPort.Int64)\n\t\t}\n\t\tif port <= 0 {\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\t\tif nodeErr != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif err := validateRemoteNodePort(node, port); err != nil {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tnow := time.Now().UnixMilli()\n\tif err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\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\t}\n\tupdatedForward, err := h.getForwardRecord(id)\n\tif err != nil {\n\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.syncForwardServices(updatedForward, \"UpdateService\", true); err != nil {\n\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardDelete(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tforward, _, _, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.controlForwardServices(forward, \"DeleteService\", true); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tif err := h.deleteForwardByID(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardForceDelete(w http.ResponseWriter, r *http.Request) {\n\th.forwardDelete(w, r)\n}\n\nfunc (h *Handler) forwardPause(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tforward, _, _, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.controlForwardServices(forward, \"PauseService\", false); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\t_ = h.repo.UpdateForwardStatus(id, 0, time.Now().UnixMilli())\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardResume(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tforward, _, _, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.controlForwardServices(forward, \"ResumeService\", false); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\t_ = h.repo.UpdateForwardStatus(id, 1, time.Now().UnixMilli())\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardDiagnose(w http.ResponseWriter, r *http.Request) {\n\tid := asInt64FromBodyKey(r, w, \"forwardId\")\n\tif id <= 0 {\n\t\treturn\n\t}\n\tforward, _, _, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)\n\tdefer cancel()\n\tpayload, err := h.diagnoseForwardRuntime(ctx, forward)\n\tif err != nil {\n\t\tif strings.Contains(err.Error(), \"不存在\") || strings.Contains(err.Error(), \"不能为空\") || strings.Contains(err.Error(), \"错误\") {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OK(payload))\n}\n\nfunc (h *Handler) forwardUpdateOrder(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tForwards []struct {\n\t\t\tID int64 `json:\"id\"`\n\t\t\tInx int `json:\"inx\"`\n\t\t} `json:\"forwards\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tfor _, f := range req.Forwards {\n\t\th.repo.UpdateForwardOrder(f.ID, f.Inx, time.Now().UnixMilli())\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardBatchDelete(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.controlForwardServices(forward, \"DeleteService\", true); err != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.deleteForwardByID(id); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": s, \"failCount\": f}))\n}\n\nfunc (h *Handler) forwardBatchPause(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.controlForwardServices(forward, \"PauseService\", false); err != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.repo.UpdateForwardStatus(id, 0, time.Now().UnixMilli()); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": s, \"failCount\": f}))\n}\n\nfunc (h *Handler) forwardBatchResume(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.controlForwardServices(forward, \"ResumeService\", false); err != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.repo.UpdateForwardStatus(id, 1, time.Now().UnixMilli()); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": s, \"failCount\": f}))\n}\n\nfunc (h *Handler) forwardBatchRedeploy(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.syncForwardServices(forward, \"UpdateService\", true); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": s, \"failCount\": f}))\n}\n\nfunc (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tForwardIDs []int64 `json:\"forwardIds\"`\n\t\tTargetTunnelID int64 `json:\"targetTunnelId\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.TargetTunnelID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\tif err := h.ensureTunnelPermission(actorUserID, actorRole, req.TargetTunnelID); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\ttargetTunnel, err := h.getTunnelRecord(req.TargetTunnelID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"目标隧道不存在\"))\n\t\treturn\n\t}\n\tif targetTunnel.Status != 1 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"目标隧道已禁用\"))\n\t\treturn\n\t}\n\tsuccess := 0\n\tfail := 0\n\tfor _, id := range req.ForwardIDs {\n\t\tif id <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tif forward.TunnelID == req.TargetTunnelID {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\toldPorts, listPortsErr := h.listForwardPorts(id)\n\t\tif listPortsErr != nil {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tport := h.repo.GetMinForwardPort(id)\n\t\tif err := h.repo.UpdateForwardTunnel(id, req.TargetTunnelID, time.Now().UnixMilli()); err != nil {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tp := 0\n\t\tif port.Valid {\n\t\t\tp = int(port.Int64)\n\t\t}\n\t\tif p <= 0 {\n\t\t\tp = h.pickTunnelPort(req.TargetTunnelID)\n\t\t}\n\t\tbctEntryNodes, _ := h.tunnelEntryNodeIDs(req.TargetTunnelID)\n\t\tportRangeOk := true\n\t\tfor _, nid := range bctEntryNodes {\n\t\t\tnd, ndErr := h.getNodeRecord(nid)\n\t\t\tif ndErr != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif validateRemoteNodePort(nd, p) != nil {\n\t\t\t\tportRangeOk = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !portRangeOk {\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\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tupdatedForward, fetchErr := h.getForwardRecord(id)\n\t\tif fetchErr != nil {\n\t\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.syncForwardServices(updatedForward, \"UpdateService\", true); err != nil {\n\t\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tsuccess++\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": success, \"failCount\": fail}))\n}\n\nfunc (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\n\tname := asString(req[\"name\"])\n\tif name == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"名称不能为空\"))\n\t\treturn\n\t}\n\n\tspeed := asInt(req[\"speed\"], 100)\n\n\tnow := time.Now().UnixMilli()\n\t_, err := h.repo.CreateSpeedLimit(name, speed, now, asInt(req[\"status\"], 1))\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) speedLimitUpdate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"限速规则ID不能为空\"))\n\t\treturn\n\t}\n\n\tname := asString(req[\"name\"])\n\tif name == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"名称不能为空\"))\n\t\treturn\n\t}\n\n\tspeed := asInt(req[\"speed\"], 100)\n\n\tif err := h.repo.UpdateSpeedLimit(id, name, speed, asInt(req[\"status\"], 1), time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) speedLimitDelete(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\n\tif err := h.repo.DeleteSpeedLimit(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupTunnelCreate(w http.ResponseWriter, r *http.Request) {\n\th.groupCreate(w, r, \"tunnel_group\")\n}\n\nfunc (h *Handler) groupTunnelUpdate(w http.ResponseWriter, r *http.Request) {\n\th.groupUpdate(w, r, \"tunnel_group\")\n}\n\nfunc (h *Handler) groupTunnelDelete(w http.ResponseWriter, r *http.Request) {\n\th.groupDelete(w, r, \"tunnel_group\")\n}\n\nfunc (h *Handler) groupUserCreate(w http.ResponseWriter, r *http.Request) {\n\th.groupCreate(w, r, \"user_group\")\n}\n\nfunc (h *Handler) groupUserUpdate(w http.ResponseWriter, r *http.Request) {\n\th.groupUpdate(w, r, \"user_group\")\n}\n\nfunc (h *Handler) groupUserDelete(w http.ResponseWriter, r *http.Request) {\n\th.groupDelete(w, r, \"user_group\")\n}\n\nfunc (h *Handler) groupTunnelAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tGroupID int64 `json:\"groupId\"`\n\t\tTunnelIDs []int64 `json:\"tunnelIds\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.GroupID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\tif err := h.repo.ReplaceTunnelGroupMembersTx(tx, req.GroupID, req.TunnelIDs, time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := tx.Commit().Error; err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\t_ = h.syncPermissionsByTunnelGroup(req.GroupID)\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupUserAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tGroupID int64 `json:\"groupId\"`\n\t\tUserIDs []int64 `json:\"userIds\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.GroupID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\tpreviousUserIDs, err := h.repo.ListUserIDsByUserGroupTx(tx, req.GroupID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.repo.ReplaceUserGroupMembersTx(tx, req.GroupID, req.UserIDs, time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.repo.RevokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := tx.Commit().Error; err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\t_ = h.syncPermissionsByUserGroup(req.GroupID)\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupPermissionAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tUserGroupID int64 `json:\"userGroupId\"`\n\t\tTunnelGroupID int64 `json:\"tunnelGroupId\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.UserGroupID <= 0 || req.TunnelGroupID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tif err := h.repo.InsertGroupPermission(req.UserGroupID, req.TunnelGroupID, time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\t_ = h.applyGroupPermission(req.UserGroupID, req.TunnelGroupID)\n\tresponse.WriteJSON(w, response.OK(\"权限分配成功\"))\n}\n\nfunc (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\n\tug, tg, exists, err := h.repo.GetGroupPermissionPairByIDTx(tx, id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tif err := h.repo.DeleteGroupPermissionByIDTx(tx, id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif exists {\n\t\tif err := h.repo.RevokeGroupPermissionPairTx(tx, ug, tg); err != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\n\tif err := tx.Commit().Error; err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupCreate(w http.ResponseWriter, r *http.Request, table string) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tname := asString(req[\"name\"])\n\tif name == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"分组名称不能为空\"))\n\t\treturn\n\t}\n\tnow := time.Now().UnixMilli()\n\tif err := h.repo.GroupCreate(table, name, asInt(req[\"status\"], 1), now); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupUpdate(w http.ResponseWriter, r *http.Request, table string) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"分组ID不能为空\"))\n\t\treturn\n\t}\n\tif err := h.repo.GroupUpdate(table, id, asString(req[\"name\"]), asInt(req[\"status\"], 1), time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupDelete(w http.ResponseWriter, r *http.Request, table string) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tif err := h.repo.GroupDeleteCascade(table, id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) applyGroupPermission(userGroupID, tunnelGroupID int64) error {\n\tuserIDs, _ := h.repo.ListUserIDsByUserGroup(userGroupID)\n\ttunnelIDs, _ := h.repo.ListTunnelIDsByTunnelGroup(tunnelGroupID)\n\tfor _, uid := range userIDs {\n\t\tfor _, tid := range tunnelIDs {\n\t\t\tutID, created, err := h.repo.EnsureUserTunnelGrant(uid, tid)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcreatedByGroup := 0\n\t\t\tif created {\n\t\t\t\tcreatedByGroup = 1\n\t\t\t}\n\t\t\th.repo.InsertGroupPermissionGrant(userGroupID, tunnelGroupID, utID, createdByGroup, time.Now().UnixMilli())\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) syncPermissionsByUserGroup(userGroupID int64) error {\n\tpairs, err := h.repo.ListGroupPermissionPairsByUserGroup(userGroupID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, p := range pairs {\n\t\t_ = h.applyGroupPermission(p[0], p[1])\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) syncPermissionsByTunnelGroup(tunnelGroupID int64) error {\n\tpairs, err := h.repo.ListGroupPermissionPairsByTunnelGroup(tunnelGroupID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, p := range pairs {\n\t\t_ = h.applyGroupPermission(p[0], p[1])\n\t}\n\treturn nil\n}\n\ntype tunnelRuntimeNode struct {\n\tNodeID int64\n\tProtocol string\n\tStrategy string\n\tInx int\n\tChainType int\n\tPort int\n\tConnectIP string\n}\n\ntype tunnelCreateState struct {\n\tTunnelID int64\n\tType int\n\tIPPreference string // \"\" = auto, \"v4\" = prefer IPv4, \"v6\" = prefer IPv6\n\tInNodes []tunnelRuntimeNode\n\tChainHops [][]tunnelRuntimeNode\n\tOutNodes []tunnelRuntimeNode\n\tNodes map[int64]*nodeRecord\n\tNodeIDList []int64\n}\n\nfunc (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) {\n\tstate := &tunnelCreateState{\n\t\tType: tunnelType,\n\t\tInNodes: make([]tunnelRuntimeNode, 0),\n\t\tChainHops: make([][]tunnelRuntimeNode, 0),\n\t\tOutNodes: make([]tunnelRuntimeNode, 0),\n\t\tNodes: make(map[int64]*nodeRecord),\n\t}\n\tnodeIDs := make([]int64, 0)\n\n\tfor _, item := range asMapSlice(req[\"inNodeId\"]) {\n\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\tif nodeID <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tnodeIDs = append(nodeIDs, nodeID)\n\t\tstate.InNodes = append(state.InNodes, tunnelRuntimeNode{\n\t\t\tNodeID: nodeID,\n\t\t\tProtocol: defaultString(asString(item[\"protocol\"]), \"tls\"),\n\t\t\tStrategy: defaultString(asString(item[\"strategy\"]), \"round\"),\n\t\t\tChainType: 1,\n\t\t})\n\t}\n\tif len(state.InNodes) == 0 {\n\t\treturn nil, errors.New(\"入口不能为空\")\n\t}\n\n\tif tunnelType == 2 {\n\t\toutNodesRaw := asMapSlice(req[\"outNodeId\"])\n\t\tif len(outNodesRaw) == 0 {\n\t\t\treturn nil, errors.New(\"出口不能为空\")\n\t\t}\n\n\t\tallocated := map[int64]int{}\n\t\tfor _, item := range outNodesRaw {\n\t\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\t\tif nodeID <= 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tnodeIDs = append(nodeIDs, nodeID)\n\t\t\tport := asInt(item[\"port\"], 0)\n\t\t\tif port <= 0 {\n\t\t\t\tisRemote, remoteErr := h.repo.IsRemoteNodeTx(tx, nodeID)\n\t\t\t\tif remoteErr != nil {\n\t\t\t\t\treturn nil, remoteErr\n\t\t\t\t}\n\t\t\t\tif !isRemote {\n\t\t\t\t\tvar err error\n\t\t\t\t\tport, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn nil, err\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tstate.OutNodes = append(state.OutNodes, tunnelRuntimeNode{\n\t\t\t\tNodeID: nodeID,\n\t\t\t\tProtocol: defaultString(asString(item[\"protocol\"]), \"tls\"),\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\t\t\treturn nil, errors.New(\"出口不能为空\")\n\t\t}\n\n\t\tfor hopIdx, hopRaw := range asAnySlice(req[\"chainNodes\"]) {\n\t\t\thop := make([]tunnelRuntimeNode, 0)\n\t\t\tfor _, item := range asMapSlice(hopRaw) {\n\t\t\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\t\t\tif nodeID <= 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tnodeIDs = append(nodeIDs, nodeID)\n\t\t\t\tport := asInt(item[\"port\"], 0)\n\t\t\t\tif port <= 0 {\n\t\t\t\t\tisRemote, remoteErr := h.repo.IsRemoteNodeTx(tx, nodeID)\n\t\t\t\t\tif remoteErr != nil {\n\t\t\t\t\t\treturn nil, remoteErr\n\t\t\t\t\t}\n\t\t\t\t\tif !isRemote {\n\t\t\t\t\t\tvar err error\n\t\t\t\t\t\tport, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\treturn nil, err\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thop = append(hop, tunnelRuntimeNode{\n\t\t\t\t\tNodeID: nodeID,\n\t\t\t\t\tProtocol: defaultString(asString(item[\"protocol\"]), \"tls\"),\n\t\t\t\t\tStrategy: defaultString(asString(item[\"strategy\"]), \"round\"),\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\t\t\t\tstate.ChainHops = append(state.ChainHops, hop)\n\t\t\t}\n\t\t}\n\t}\n\n\tseen := make(map[int64]struct{}, len(nodeIDs))\n\tfor _, nodeID := range nodeIDs {\n\t\tif _, ok := seen[nodeID]; ok {\n\t\t\treturn nil, errors.New(\"节点重复\")\n\t\t}\n\t\tseen[nodeID] = struct{}{}\n\t\tstate.NodeIDList = append(state.NodeIDList, nodeID)\n\t\tnode, err := h.repo.GetNodeRecordTx(tx, nodeID)\n\t\tif err != nil {\n\t\t\tif strings.Contains(err.Error(), \"不存在\") {\n\t\t\t\treturn nil, errors.New(\"节点不存在\")\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tif node == nil {\n\t\t\treturn nil, errors.New(\"节点不存在\")\n\t\t}\n\t\tif node.IsRemote != 1 && node.Status != 1 {\n\t\t\treturn nil, errors.New(\"部分节点不在线\")\n\t\t}\n\t\tstate.Nodes[nodeID] = node\n\t}\n\n\tfor _, outNode := range state.OutNodes {\n\t\tif err := validateRemoteNodePort(state.Nodes[outNode.NodeID], outNode.Port); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tfor _, hop := range state.ChainHops {\n\t\tfor _, chainNode := range hop {\n\t\t\tif err := validateRemoteNodePort(state.Nodes[chainNode.NodeID], chainNode.Port); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn state, nil\n}\n\nfunc buildTunnelInIP(inNodes []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) string {\n\tset := make(map[string]struct{})\n\tordered := make([]string, 0)\n\tpreferV6 := strings.TrimSpace(ipPreference) == \"v6\"\n\tfor _, inNode := range inNodes {\n\t\tnode := nodes[inNode.NodeID]\n\t\tif node == nil {\n\t\t\tcontinue\n\t\t}\n\t\tv4 := strings.TrimSpace(node.ServerIPv4)\n\t\tv6 := strings.TrimSpace(node.ServerIPv6)\n\t\tvar addrs []string\n\t\tif preferV6 {\n\t\t\tif v6 != \"\" {\n\t\t\t\taddrs = append(addrs, v6)\n\t\t\t}\n\t\t\tif v4 != \"\" {\n\t\t\t\taddrs = append(addrs, v4)\n\t\t\t}\n\t\t} else {\n\t\t\tif v4 != \"\" {\n\t\t\t\taddrs = append(addrs, v4)\n\t\t\t}\n\t\t\tif v6 != \"\" {\n\t\t\t\taddrs = append(addrs, v6)\n\t\t\t}\n\t\t}\n\t\tif len(addrs) == 0 {\n\t\t\tif v := strings.TrimSpace(node.ServerIP); v != \"\" {\n\t\t\t\taddrs = append(addrs, v)\n\t\t\t}\n\t\t}\n\t\tfor _, a := range addrs {\n\t\t\tif _, ok := set[a]; !ok {\n\t\t\t\tset[a] = struct{}{}\n\t\t\t\tordered = append(ordered, a)\n\t\t\t}\n\t\t}\n\t}\n\treturn strings.Join(ordered, \",\")\n}\n\nfunc applyTunnelPortsToRequest(req map[string]interface{}, state *tunnelCreateState) {\n\tif req == nil || state == nil {\n\t\treturn\n\t}\n\toutPorts := make(map[int64]int)\n\tfor _, n := range state.OutNodes {\n\t\toutPorts[n.NodeID] = n.Port\n\t}\n\tfor _, item := range asMapSlice(req[\"outNodeId\"]) {\n\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\tif port, ok := outPorts[nodeID]; ok && port > 0 {\n\t\t\titem[\"port\"] = port\n\t\t}\n\t}\n\n\tchainPorts := make(map[int64]int)\n\tfor _, hop := range state.ChainHops {\n\t\tfor _, n := range hop {\n\t\t\tchainPorts[n.NodeID] = n.Port\n\t\t}\n\t}\n\tfor _, hopRaw := range asAnySlice(req[\"chainNodes\"]) {\n\t\tfor _, item := range asMapSlice(hopRaw) {\n\t\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\t\tif port, ok := chainPorts[nodeID]; ok && port > 0 {\n\t\t\t\titem[\"port\"] = port\n\t\t\t}\n\t\t}\n\t}\n}\n\ntype federationRuntimeReleaseRef struct {\n\tRemoteURL string\n\tRemoteToken string\n\tBindingID string\n\tReservationID string\n\tResourceKey string\n}\n\nfunc federationRuntimeResourceKey(tunnelID int64, nodeID int64, chainType int, hopInx int) string {\n\treturn fmt.Sprintf(\"tunnel:%d:node:%d:type:%d:hop:%d\", tunnelID, nodeID, chainType, hopInx)\n}\n\nfunc remoteShareIDFromConfig(raw string) int64 {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn 0\n\t}\n\tvar cfg map[string]interface{}\n\tif err := json.Unmarshal([]byte(raw), &cfg); err != nil {\n\t\treturn 0\n\t}\n\treturn asInt64(cfg[\"shareId\"], 0)\n}\n\nfunc (h *Handler) federationLocalDomain() string {\n\tcfg, _ := h.repo.GetConfigByName(\"panel_domain\")\n\tif cfg == nil {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(cfg.Value)\n}\n\nfunc (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain string) ([]repo.FederationTunnelBinding, []federationRuntimeReleaseRef, error) {\n\tbindings := make([]repo.FederationTunnelBinding, 0)\n\treleaseRefs := make([]federationRuntimeReleaseRef, 0)\n\tif h == nil || state == nil {\n\t\treturn bindings, releaseRefs, nil\n\t}\n\tfc := client.NewFederationClient()\n\tnow := time.Now().UnixMilli()\n\n\tfor outIdx := range state.OutNodes {\n\t\toutNode := state.OutNodes[outIdx]\n\t\tnode := state.Nodes[outNode.NodeID]\n\t\tif node == nil || node.IsRemote != 1 {\n\t\t\tcontinue\n\t\t}\n\t\tremoteURL := strings.TrimSpace(node.RemoteURL)\n\t\tremoteToken := strings.TrimSpace(node.RemoteToken)\n\t\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 缺少共享配置\", nodeDisplayName(node))\n\t\t}\n\n\t\tresourceKey := federationRuntimeResourceKey(state.TunnelID, outNode.NodeID, 3, 0)\n\t\treserveReq := client.RuntimeReservePortRequest{\n\t\t\tResourceKey: resourceKey,\n\t\t\tProtocol: defaultString(outNode.Protocol, \"tls\"),\n\t\t\tRequestedPort: outNode.Port,\n\t\t}\n\t\treserveRes, err := fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n\t\tif err != nil && reserveReq.RequestedPort > 0 {\n\t\t\treserveReq.RequestedPort = 0\n\t\t\treserveRes, err = fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n\t\t}\n\t\tif err != nil {\n\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 端口分配失败: %w\", nodeDisplayName(node), err)\n\t\t}\n\n\t\tstate.OutNodes[outIdx].Port = reserveRes.AllocatedPort\n\t\toutNode = state.OutNodes[outIdx]\n\n\t\tapplyReq := client.RuntimeApplyRoleRequest{\n\t\t\tReservationID: reserveRes.ReservationID,\n\t\t\tResourceKey: resourceKey,\n\t\t\tRole: \"exit\",\n\t\t\tProtocol: defaultString(outNode.Protocol, \"tls\"),\n\t\t\tStrategy: defaultString(outNode.Strategy, \"round\"),\n\t\t}\n\t\tapplyRes, err := fc.ApplyRole(remoteURL, remoteToken, localDomain, applyReq)\n\t\tif err != nil {\n\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 运行时下发失败: %w\", nodeDisplayName(node), err)\n\t\t}\n\t\tif applyRes.AllocatedPort > 0 {\n\t\t\tstate.OutNodes[outIdx].Port = applyRes.AllocatedPort\n\t\t\toutNode = state.OutNodes[outIdx]\n\t\t}\n\n\t\tbindings = append(bindings, repo.FederationTunnelBinding{\n\t\t\tTunnelID: state.TunnelID,\n\t\t\tNodeID: outNode.NodeID,\n\t\t\tChainType: 3,\n\t\t\tHopInx: 0,\n\t\t\tRemoteURL: remoteURL,\n\t\t\tResourceKey: resourceKey,\n\t\t\tRemoteBindingID: defaultString(applyRes.BindingID, reserveRes.BindingID),\n\t\t\tAllocatedPort: outNode.Port,\n\t\t\tStatus: 1,\n\t\t\tCreatedTime: now,\n\t\t\tUpdatedTime: now,\n\t\t})\n\t\treleaseRefs = append(releaseRefs, federationRuntimeReleaseRef{\n\t\t\tRemoteURL: remoteURL,\n\t\t\tRemoteToken: remoteToken,\n\t\t\tBindingID: applyRes.BindingID,\n\t\t\tReservationID: reserveRes.ReservationID,\n\t\t\tResourceKey: resourceKey,\n\t\t})\n\t}\n\n\tfor hopIdx := len(state.ChainHops) - 1; hopIdx >= 0; hopIdx-- {\n\t\tfor nodeIdx := range state.ChainHops[hopIdx] {\n\t\t\tchainNode := state.ChainHops[hopIdx][nodeIdx]\n\t\t\tnode := state.Nodes[chainNode.NodeID]\n\t\t\tif node == nil || node.IsRemote != 1 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tremoteURL := strings.TrimSpace(node.RemoteURL)\n\t\t\tremoteToken := strings.TrimSpace(node.RemoteToken)\n\t\t\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 缺少共享配置\", nodeDisplayName(node))\n\t\t\t}\n\n\t\t\tresourceKey := federationRuntimeResourceKey(state.TunnelID, chainNode.NodeID, 2, hopIdx+1)\n\t\t\treserveReq := client.RuntimeReservePortRequest{\n\t\t\t\tResourceKey: resourceKey,\n\t\t\t\tProtocol: defaultString(chainNode.Protocol, \"tls\"),\n\t\t\t\tRequestedPort: chainNode.Port,\n\t\t\t}\n\t\t\treserveRes, err := fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n\t\t\tif err != nil && reserveReq.RequestedPort > 0 {\n\t\t\t\treserveReq.RequestedPort = 0\n\t\t\t\treserveRes, err = fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 端口分配失败: %w\", nodeDisplayName(node), err)\n\t\t\t}\n\n\t\t\tstate.ChainHops[hopIdx][nodeIdx].Port = reserveRes.AllocatedPort\n\t\t\tchainNode = state.ChainHops[hopIdx][nodeIdx]\n\n\t\t\tnextTargets := state.OutNodes\n\t\t\tif hopIdx+1 < len(state.ChainHops) {\n\t\t\t\tnextTargets = state.ChainHops[hopIdx+1]\n\t\t\t}\n\t\t\tapplyTargets := make([]client.RuntimeTarget, 0, len(nextTargets))\n\t\t\tfor _, target := range nextTargets {\n\t\t\t\ttargetNode := state.Nodes[target.NodeID]\n\t\t\t\tif targetNode == nil {\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, 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\t\t\t\t}\n\t\t\t\tif target.Port <= 0 {\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\tapplyTargets = append(applyTargets, client.RuntimeTarget{\n\t\t\t\t\tHost: host,\n\t\t\t\t\tPort: target.Port,\n\t\t\t\t\tProtocol: defaultString(target.Protocol, \"tls\"),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tapplyReq := client.RuntimeApplyRoleRequest{\n\t\t\t\tReservationID: reserveRes.ReservationID,\n\t\t\t\tResourceKey: resourceKey,\n\t\t\t\tRole: \"middle\",\n\t\t\t\tProtocol: defaultString(chainNode.Protocol, \"tls\"),\n\t\t\t\tStrategy: defaultString(chainNode.Strategy, \"round\"),\n\t\t\t\tTargets: applyTargets,\n\t\t\t}\n\t\t\tapplyRes, err := fc.ApplyRole(remoteURL, remoteToken, localDomain, applyReq)\n\t\t\tif err != nil {\n\t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 运行时下发失败: %w\", nodeDisplayName(node), err)\n\t\t\t}\n\t\t\tif applyRes.AllocatedPort > 0 {\n\t\t\t\tstate.ChainHops[hopIdx][nodeIdx].Port = applyRes.AllocatedPort\n\t\t\t\tchainNode = state.ChainHops[hopIdx][nodeIdx]\n\t\t\t}\n\n\t\t\tbindings = append(bindings, repo.FederationTunnelBinding{\n\t\t\t\tTunnelID: state.TunnelID,\n\t\t\t\tNodeID: chainNode.NodeID,\n\t\t\t\tChainType: 2,\n\t\t\t\tHopInx: hopIdx + 1,\n\t\t\t\tRemoteURL: remoteURL,\n\t\t\t\tResourceKey: resourceKey,\n\t\t\t\tRemoteBindingID: defaultString(applyRes.BindingID, reserveRes.BindingID),\n\t\t\t\tAllocatedPort: chainNode.Port,\n\t\t\t\tStatus: 1,\n\t\t\t\tCreatedTime: now,\n\t\t\t\tUpdatedTime: now,\n\t\t\t})\n\t\t\treleaseRefs = append(releaseRefs, federationRuntimeReleaseRef{\n\t\t\t\tRemoteURL: remoteURL,\n\t\t\t\tRemoteToken: remoteToken,\n\t\t\t\tBindingID: applyRes.BindingID,\n\t\t\t\tReservationID: reserveRes.ReservationID,\n\t\t\t\tResourceKey: resourceKey,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn bindings, releaseRefs, nil\n}\n\nfunc (h *Handler) releaseFederationRuntimeRefs(refs []federationRuntimeReleaseRef) {\n\tif h == nil || len(refs) == 0 {\n\t\treturn\n\t}\n\tfc := client.NewFederationClient()\n\tlocalDomain := h.federationLocalDomain()\n\tfor i := len(refs) - 1; i >= 0; i-- {\n\t\tref := refs[i]\n\t\tif strings.TrimSpace(ref.RemoteURL) == \"\" || strings.TrimSpace(ref.RemoteToken) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\treq := client.RuntimeReleaseRoleRequest{\n\t\t\tBindingID: ref.BindingID,\n\t\t\tReservationID: ref.ReservationID,\n\t\t\tResourceKey: ref.ResourceKey,\n\t\t}\n\t\t_ = fc.ReleaseRole(ref.RemoteURL, ref.RemoteToken, localDomain, req)\n\t}\n}\n\nfunc (h *Handler) cleanupFederationRuntime(tunnelID int64) {\n\tif h == nil || tunnelID <= 0 {\n\t\treturn\n\t}\n\tbindings, err := h.repo.ListActiveFederationTunnelBindingsByTunnel(tunnelID)\n\tif err != nil || len(bindings) == 0 {\n\t\treturn\n\t}\n\n\tfc := client.NewFederationClient()\n\tlocalDomain := h.federationLocalDomain()\n\tfor _, b := range bindings {\n\t\tnode, nodeErr := h.repo.GetNodeByID(b.NodeID)\n\t\tif nodeErr != nil || node == nil {\n\t\t\tcontinue\n\t\t}\n\t\tremoteURL := strings.TrimSpace(node.RemoteURL.String)\n\t\tif remoteURL == \"\" {\n\t\t\tremoteURL = strings.TrimSpace(b.RemoteURL)\n\t\t}\n\t\tremoteToken := strings.TrimSpace(node.RemoteToken.String)\n\t\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\treq := client.RuntimeReleaseRoleRequest{\n\t\t\tBindingID: strings.TrimSpace(b.RemoteBindingID),\n\t\t\tResourceKey: strings.TrimSpace(b.ResourceKey),\n\t\t}\n\t\t_ = fc.ReleaseRole(remoteURL, remoteToken, localDomain, req)\n\t}\n\t_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)\n}\n\nfunc (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64, error) {\n\tif h == nil || state == nil {\n\t\treturn nil, nil, errors.New(\"invalid tunnel runtime state\")\n\t}\n\tcreatedChains := make([]int64, 0)\n\tcreatedServices := make([]int64, 0)\n\tif state.Type != 2 {\n\t\treturn createdChains, createdServices, nil\n\t}\n\n\tfor _, inNode := range state.InNodes {\n\t\tnode := state.Nodes[inNode.NodeID]\n\t\ttargets := state.OutNodes\n\t\tif len(state.ChainHops) > 0 {\n\t\t\ttargets = state.ChainHops[0]\n\t\t}\n\t\tchainData, err := buildTunnelChainConfig(state.TunnelID, inNode.NodeID, targets, state.Nodes, state.IPPreference)\n\t\tif err != nil {\n\t\t\treturn createdChains, createdServices, err\n\t\t}\n\t\tif _, err := h.sendNodeCommand(inNode.NodeID, \"AddChains\", chainData, true, false); err != nil {\n\t\t\tif node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn createdChains, createdServices, fmt.Errorf(\"入口节点 %s 下发转发链失败: %w\", nodeDisplayName(state.Nodes[inNode.NodeID]), err)\n\t\t}\n\t\tcreatedChains = append(createdChains, inNode.NodeID)\n\t}\n\n\tfor i, hop := range state.ChainHops {\n\t\tnextTargets := state.OutNodes\n\t\tif i+1 < len(state.ChainHops) {\n\t\t\tnextTargets = state.ChainHops[i+1]\n\t\t}\n\t\tfor _, chainNode := range hop {\n\t\t\tif node := state.Nodes[chainNode.NodeID]; node != nil && node.IsRemote == 1 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tchainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)\n\t\t\tif err != nil {\n\t\t\t\treturn createdChains, createdServices, err\n\t\t\t}\n\t\t\tif _, err := h.sendNodeCommand(chainNode.NodeID, \"AddChains\", chainData, true, false); err != nil {\n\t\t\t\treturn createdChains, createdServices, fmt.Errorf(\"转发链节点 %s 下发转发链失败: %w\", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)\n\t\t\t}\n\t\t\tcreatedChains = append(createdChains, chainNode.NodeID)\n\n\t\t\tserviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID])\n\t\t\tif _, err := h.sendNodeCommand(chainNode.NodeID, \"AddService\", serviceData, true, false); err != nil {\n\t\t\t\treturn createdChains, createdServices, fmt.Errorf(\"转发链节点 %s 下发服务失败: %w\", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)\n\t\t\t}\n\t\t\tcreatedServices = append(createdServices, chainNode.NodeID)\n\t\t}\n\t}\n\n\tfor _, outNode := range state.OutNodes {\n\t\tif node := state.Nodes[outNode.NodeID]; node != nil && node.IsRemote == 1 {\n\t\t\tcontinue\n\t\t}\n\t\tserviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID])\n\t\tif _, err := h.sendNodeCommand(outNode.NodeID, \"AddService\", serviceData, true, false); err != nil {\n\t\t\treturn createdChains, createdServices, fmt.Errorf(\"出口节点 %s 下发服务失败: %w\", nodeDisplayName(state.Nodes[outNode.NodeID]), err)\n\t\t}\n\t\tcreatedServices = append(createdServices, outNode.NodeID)\n\t}\n\n\treturn createdChains, createdServices, nil\n}\n\nfunc (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tunnelID int64) {\n\tif h == nil || tunnelID <= 0 {\n\t\treturn\n\t}\n\tseenServices := make(map[int64]struct{})\n\tserviceName := fmt.Sprintf(\"%d_tls\", tunnelID)\n\tfor i := len(serviceNodeIDs) - 1; i >= 0; i-- {\n\t\tnodeID := serviceNodeIDs[i]\n\t\tif _, ok := seenServices[nodeID]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseenServices[nodeID] = struct{}{}\n\t\t_, _ = h.sendNodeCommand(nodeID, \"DeleteService\", map[string]interface{}{\"services\": []string{serviceName}}, false, true)\n\t}\n\n\tseenChains := make(map[int64]struct{})\n\tchainName := fmt.Sprintf(\"chains_%d\", tunnelID)\n\tfor i := len(chainNodeIDs) - 1; i >= 0; i-- {\n\t\tnodeID := chainNodeIDs[i]\n\t\tif _, ok := seenChains[nodeID]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseenChains[nodeID] = struct{}{}\n\t\t_, _ = h.sendNodeCommand(nodeID, \"DeleteChains\", map[string]interface{}{\"chain\": chainName}, false, true)\n\t}\n}\n\nfunc shouldDeferTunnelRuntimeApplyError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tmsg := strings.ToLower(strings.TrimSpace(err.Error()))\n\tif msg == \"\" {\n\t\treturn false\n\t}\n\tif strings.Contains(msg, \"节点不在线\") {\n\t\treturn true\n\t}\n\tif strings.Contains(msg, \"等待节点响应超时\") || strings.Contains(msg, \"timeout\") || strings.Contains(msg, \"超时\") {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) (map[string]interface{}, error) {\n\tfromNode := nodes[fromNodeID]\n\tif fromNode == nil {\n\t\treturn nil, errors.New(\"节点不存在\")\n\t}\n\tif len(targets) == 0 {\n\t\treturn nil, errors.New(\"转发链目标不能为空\")\n\t}\n\tnodeItems := make([]map[string]interface{}, 0, len(targets))\n\tfor idx, target := range targets {\n\t\ttargetNode := nodes[target.NodeID]\n\t\tif targetNode == nil {\n\t\t\treturn nil, errors.New(\"节点不存在\")\n\t\t}\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\t\tport := target.Port\n\t\tif port <= 0 {\n\t\t\treturn nil, errors.New(\"节点端口不能为空\")\n\t\t}\n\t\tprotocol := defaultString(target.Protocol, \"tls\")\n\t\tconnector := map[string]interface{}{\n\t\t\t\"type\": \"relay\",\n\t\t}\n\t\tif isTLSTunnelProtocol(protocol) {\n\t\t\tconnector[\"metadata\"] = map[string]interface{}{\"nodelay\": true}\n\t\t}\n\t\tnodeItems = append(nodeItems, map[string]interface{}{\n\t\t\t\"name\": fmt.Sprintf(\"node_%d\", idx+1),\n\t\t\t\"addr\": processServerAddress(fmt.Sprintf(\"%s:%d\", host, port)),\n\t\t\t\"connector\": connector,\n\t\t\t\"dialer\": map[string]interface{}{\n\t\t\t\t\"type\": protocol,\n\t\t\t},\n\t\t})\n\t}\n\n\tstrategy := defaultString(strings.TrimSpace(targets[0].Strategy), \"round\")\n\thop := map[string]interface{}{\n\t\t\"name\": fmt.Sprintf(\"hop_%d\", tunnelID),\n\t\t\"selector\": map[string]interface{}{\n\t\t\t\"strategy\": strategy,\n\t\t\t\"maxFails\": 1,\n\t\t\t\"failTimeout\": int64(600000000000),\n\t\t},\n\t\t\"nodes\": nodeItems,\n\t}\n\tif strings.TrimSpace(fromNode.InterfaceName) != \"\" {\n\t\thop[\"interface\"] = fromNode.InterfaceName\n\t}\n\n\treturn map[string]interface{}{\n\t\t\"name\": fmt.Sprintf(\"chains_%d\", tunnelID),\n\t\t\"hops\": []map[string]interface{}{hop},\n\t}, nil\n}\n\nfunc buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode, node *nodeRecord) []map[string]interface{} {\n\tif node == nil {\n\t\treturn nil\n\t}\n\tprotocol := defaultString(chainNode.Protocol, \"tls\")\n\thandlerCfg := map[string]interface{}{\n\t\t\"type\": \"relay\",\n\t}\n\tif isTLSTunnelProtocol(protocol) {\n\t\thandlerCfg[\"metadata\"] = map[string]interface{}{\"nodelay\": true}\n\t}\n\tservice := map[string]interface{}{\n\t\t\"name\": fmt.Sprintf(\"%d_tls\", tunnelID),\n\t\t\"addr\": fmt.Sprintf(\"%s:%d\", node.TCPListenAddr, chainNode.Port),\n\t\t\"handler\": handlerCfg,\n\t\t\"listener\": map[string]interface{}{\n\t\t\t\"type\": protocol,\n\t\t},\n\t}\n\tif chainNode.ChainType == 2 {\n\t\tservice[\"handler\"].(map[string]interface{})[\"chain\"] = fmt.Sprintf(\"chains_%d\", tunnelID)\n\t}\n\tif chainNode.ChainType == 3 && strings.TrimSpace(node.InterfaceName) != \"\" {\n\t\tservice[\"metadata\"] = map[string]interface{}{\"interface\": node.InterfaceName}\n\t}\n\treturn []map[string]interface{}{service}\n}\n\nfunc 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\ttoV6 := nodeSupportsV6(toNode)\n\n\tswitch strings.TrimSpace(ipPreference) {\n\tcase \"v6\":\n\t\tif fromV6 && toV6 {\n\t\t\tif host := pickNodeAddressV6(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\t\tif fromV4 && toV4 {\n\t\t\tif host := pickNodeAddressV4(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\tcase \"v4\":\n\t\tif fromV4 && toV4 {\n\t\t\tif host := pickNodeAddressV4(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\t\tif fromV6 && toV6 {\n\t\t\tif host := pickNodeAddressV6(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\tdefault:\n\t\tif fromV4 && toV4 {\n\t\t\tif host := pickNodeAddressV4(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\t\tif fromV6 && toV6 {\n\t\t\tif host := pickNodeAddressV6(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"节点链路不兼容:%s(v4=%t,v6=%t) -> %s(v4=%t,v6=%t)\", nodeDisplayName(fromNode), fromV4, fromV6, nodeDisplayName(toNode), toV4, toV6)\n}\n\nfunc nodeDisplayName(node *nodeRecord) string {\n\tif node == nil {\n\t\treturn \"node\"\n\t}\n\tif strings.TrimSpace(node.Name) != \"\" {\n\t\treturn strings.TrimSpace(node.Name)\n\t}\n\treturn fmt.Sprintf(\"node_%d\", node.ID)\n}\n\nfunc isTLSTunnelProtocol(protocol string) bool {\n\treturn strings.EqualFold(strings.TrimSpace(defaultString(protocol, \"tls\")), \"tls\")\n}\n\nfunc nodeSupportsV4(node *nodeRecord) bool {\n\tif node == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(node.ServerIPv4) != \"\" {\n\t\treturn true\n\t}\n\tif strings.TrimSpace(node.ServerIPv6) != \"\" {\n\t\treturn false\n\t}\n\tlegacy := strings.Trim(strings.TrimSpace(node.ServerIP), \"[]\")\n\tif legacy == \"\" {\n\t\treturn false\n\t}\n\tif ip := net.ParseIP(legacy); ip != nil {\n\t\treturn ip.To4() != nil\n\t}\n\treturn true\n}\n\nfunc nodeSupportsV6(node *nodeRecord) bool {\n\tif node == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(node.ServerIPv6) != \"\" {\n\t\treturn true\n\t}\n\tif strings.TrimSpace(node.ServerIPv4) != \"\" {\n\t\treturn false\n\t}\n\tlegacy := strings.Trim(strings.TrimSpace(node.ServerIP), \"[]\")\n\tif legacy == \"\" {\n\t\treturn false\n\t}\n\tif ip := net.ParseIP(legacy); ip != nil {\n\t\treturn ip.To4() == nil\n\t}\n\treturn true\n}\n\nfunc pickNodeAddressV4(node *nodeRecord) string {\n\tif node == nil {\n\t\treturn \"\"\n\t}\n\tif v := strings.TrimSpace(node.ServerIPv4); v != \"\" {\n\t\treturn v\n\t}\n\treturn strings.TrimSpace(node.ServerIP)\n}\n\nfunc pickNodeAddressV6(node *nodeRecord) string {\n\tif node == nil {\n\t\treturn \"\"\n\t}\n\tif v := strings.TrimSpace(node.ServerIPv6); v != \"\" {\n\t\treturn v\n\t}\n\treturn strings.TrimSpace(node.ServerIP)\n}\n\nfunc (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[string]interface{}) error {\n\tallocated := map[int64]int{}\n\tinNodes := asMapSlice(req[\"inNodeId\"])\n\tfor i, n := range inNodes {\n\t\tnodeID := asInt64(n[\"nodeId\"], 0)\n\t\tif nodeID <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.repo.CreateChainTunnelTx(\n\t\t\ttx,\n\t\t\ttunnelID,\n\t\t\t\"1\",\n\t\t\tnodeID,\n\t\t\tsql.NullInt64{},\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\t}\n\tfor i, n := range asMapSlice(req[\"outNodeId\"]) {\n\t\tnodeID := asInt64(n[\"nodeId\"], 0)\n\t\tif nodeID <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tport := asInt(n[\"port\"], 0)\n\t\tif port <= 0 {\n\t\t\tvar pickErr error\n\t\t\tport, pickErr = h.repo.PickNodePortTx(tx, nodeID, allocated, 0)\n\t\t\tif pickErr != nil {\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\t\t\t\"3\",\n\t\t\tnodeID,\n\t\t\tsql.NullInt64{Int64: int64(port), Valid: true},\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\t}\n\tchainNodes := asAnySlice(req[\"chainNodes\"])\n\tfor i, grp := range chainNodes {\n\t\tfor _, n := range asMapSlice(grp) {\n\t\t\tnodeID := asInt64(n[\"nodeId\"], 0)\n\t\t\tif nodeID <= 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tport := asInt(n[\"port\"], 0)\n\t\t\tif port <= 0 {\n\t\t\t\tvar pickErr error\n\t\t\t\tport, pickErr = h.repo.PickNodePortTx(tx, nodeID, allocated, 0)\n\t\t\t\tif pickErr != nil {\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\t\t\t\t\"2\",\n\t\t\t\tnodeID,\n\t\t\t\tsql.NullInt64{Int64: int64(port), Valid: true},\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\t\t}\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) deleteNodeByID(id int64) error {\n\treturn h.repo.DeleteNodeCascade(id)\n}\n\nfunc (h *Handler) deleteTunnelByID(id int64) error {\n\tif h == nil || h.repo == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\n\ttunnelName, _ := h.repo.GetTunnelName(id)\n\tif err := h.repo.DeleteTunnelCascade(id); err != nil {\n\t\treturn err\n\t}\n\n\tshareID, port, ok := parsePeerShareInfoFromFederationTunnelName(tunnelName)\n\tif !ok {\n\t\treturn nil\n\t}\n\n\treturn h.repo.MarkPeerShareRuntimeReleasedByPort(shareID, port, time.Now().UnixMilli())\n}\n\nfunc (h *Handler) deleteForwardByID(id int64) error {\n\treturn h.repo.DeleteForwardCascade(id)\n}\n\nfunc (h *Handler) batchForwardDelete(ids []int64) (int, int) {\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tif err := h.deleteForwardByID(id); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\treturn s, f\n}\n\nfunc (h *Handler) batchForwardStatus(ids []int64, status int) (int, int) {\n\treturn h.repo.BatchUpdateForwardStatus(ids, status)\n}\n\nfunc (h *Handler) tunnelEntryNodeIDs(tunnelID int64) ([]int64, error) {\n\treturn h.repo.TunnelEntryNodeIDs(tunnelID)\n}\n\nfunc (h *Handler) pickTunnelPort(tunnelID int64) int {\n\tentryNodes, err := h.tunnelEntryNodeIDs(tunnelID)\n\tif err != nil || len(entryNodes) == 0 {\n\t\treturn 10000\n\t}\n\n\tvar commonAvailable []int\n\tfirstNode := true\n\n\tfor _, nodeID := range entryNodes {\n\t\tportRange, err := h.repo.GetNodePortRange(nodeID)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif portRange == \"\" {\n\t\t\tportRange = \"1000-65535\"\n\t\t}\n\n\t\tnodePorts, err := parsePorts(portRange)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tused, err := h.getUsedPorts(nodeID)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar available []int\n\t\tfor _, p := range nodePorts {\n\t\t\tif !used[p] {\n\t\t\t\tavailable = append(available, p)\n\t\t\t}\n\t\t}\n\n\t\tif firstNode {\n\t\t\tcommonAvailable = available\n\t\t\tfirstNode = false\n\t\t} else {\n\t\t\tset := make(map[int]bool)\n\t\t\tfor _, p := range available {\n\t\t\t\tset[p] = true\n\t\t\t}\n\t\t\tvar newCommon []int\n\t\t\tfor _, p := range commonAvailable {\n\t\t\t\tif set[p] {\n\t\t\t\t\tnewCommon = append(newCommon, p)\n\t\t\t\t}\n\t\t\t}\n\t\t\tcommonAvailable = newCommon\n\t\t}\n\n\t\tif len(commonAvailable) == 0 {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif len(commonAvailable) > 0 {\n\t\tidx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(commonAvailable))))\n\t\treturn commonAvailable[idx.Int64()]\n\t}\n\n\treturn 10000\n}\n\nfunc (h *Handler) getUsedPorts(nodeID int64) (map[int]bool, error) {\n\treturn h.repo.GetUsedPortsOnNodeAsMap(nodeID)\n}\n\nfunc parsePorts(portRange string) ([]int, error) {\n\tvar ports []int\n\tparts := strings.Split(portRange, \",\")\n\tfor _, part := range parts {\n\t\tpart = strings.TrimSpace(part)\n\t\tif part == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.Contains(part, \"-\") {\n\t\t\trangeParts := strings.Split(part, \"-\")\n\t\t\tif len(rangeParts) != 2 {\n\t\t\t\treturn nil, errors.New(\"invalid port range format\")\n\t\t\t}\n\t\t\tstart, err1 := strconv.Atoi(strings.TrimSpace(rangeParts[0]))\n\t\t\tend, err2 := strconv.Atoi(strings.TrimSpace(rangeParts[1]))\n\t\t\tif err1 != nil || err2 != nil || start > end {\n\t\t\t\treturn nil, errors.New(\"invalid port range values\")\n\t\t\t}\n\t\t\tfor i := start; i <= end; i++ {\n\t\t\t\tports = append(ports, i)\n\t\t\t}\n\t\t} else {\n\t\t\tp, err := strconv.Atoi(part)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, errors.New(\"invalid port value\")\n\t\t\t}\n\t\t\tports = append(ports, p)\n\t\t}\n\t}\n\treturn ports, nil\n}\n\nfunc (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\t}\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\tInIP string\n\t\t}{NodeID: nid, Port: port, InIP: inIp}\n\t}\n\treturn h.repo.ReplaceForwardPorts(forwardID, entries)\n}\n\nfunc (h *Handler) replaceForwardPortsWithRecords(forwardID int64, ports []forwardPortRecord) error {\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\tInIP string\n\t\t}{NodeID: fp.NodeID, Port: fp.Port, InIP: fp.InIP}\n\t}\n\treturn h.repo.ReplaceForwardPorts(forwardID, entries)\n}\n\nfunc (h *Handler) rollbackForwardMutation(oldForward *forwardRecord, oldPorts []forwardPortRecord) {\n\tif h == nil || oldForward == nil || h.repo == nil {\n\t\treturn\n\t}\n\n\th.repo.RollbackForwardFields(\n\t\toldForward.ID, oldForward.UserID, oldForward.UserName, oldForward.Name,\n\t\toldForward.TunnelID, oldForward.RemoteAddr, oldForward.Strategy, oldForward.Status,\n\t\toldForward.SpeedID,\n\t\ttime.Now().UnixMilli(),\n\t)\n\n\tif err := h.replaceForwardPortsWithRecords(oldForward.ID, oldPorts); err != nil {\n\t\treturn\n\t}\n\n\t_ = h.syncForwardServices(oldForward, \"UpdateService\", true)\n}\n\nfunc (h *Handler) upsertUserTunnel(req map[string]interface{}) error {\n\tuserID := asInt64(req[\"userId\"], 0)\n\ttunnelID := asInt64(req[\"tunnelId\"], 0)\n\tif userID <= 0 || tunnelID <= 0 {\n\t\treturn fmt.Errorf(\"userId or tunnelId missing\")\n\t}\n\n\texistingID, currentFlow, currentNum, currentExpTime, currentFlowReset, currentSpeedID, currentStatus, err :=\n\t\th.repo.GetExistingUserTunnel(userID, tunnelID)\n\n\tspeedID := asAnyToInt64Ptr(req[\"speedId\"])\n\tif err := h.validateSpeedLimitReference(speedID); err != nil {\n\t\treturn err\n\t}\n\n\treqFlow := asInt64(req[\"flow\"], -1)\n\treqNum := asInt(req[\"num\"], -1)\n\treqExpTime := asInt64(req[\"expTime\"], -1)\n\treqFlowReset := asInt64(req[\"flowResetTime\"], -1)\n\treqStatus := asInt(req[\"status\"], -1)\n\n\tif err == sql.ErrNoRows {\n\t\tif reqFlow < 0 || reqNum < 0 || reqExpTime < 0 || reqFlowReset < 0 {\n\t\t\tuFlow, uNum, uExp, uReset, uErr := h.repo.GetUserDefaultsForTunnel(userID)\n\t\t\tif uErr == nil {\n\t\t\t\tif reqFlow < 0 {\n\t\t\t\t\treqFlow = uFlow\n\t\t\t\t}\n\t\t\t\tif reqNum < 0 {\n\t\t\t\t\treqNum = uNum\n\t\t\t\t}\n\t\t\t\tif reqExpTime < 0 {\n\t\t\t\t\treqExpTime = uExp\n\t\t\t\t}\n\t\t\t\tif reqFlowReset < 0 {\n\t\t\t\t\treqFlowReset = uReset\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif reqFlow < 0 {\n\t\t\treqFlow = 0\n\t\t}\n\t\tif reqNum < 0 {\n\t\t\treqNum = 0\n\t\t}\n\t\tif reqExpTime < 0 {\n\t\t\treqExpTime = time.Now().Add(365 * 24 * time.Hour).UnixMilli()\n\t\t}\n\t\tif reqFlowReset < 0 {\n\t\t\treqFlowReset = 1\n\t\t}\n\t\tif reqStatus < 0 {\n\t\t\treqStatus = 1\n\t\t}\n\n\t\tif err := h.repo.InsertUserTunnel(userID, tunnelID, nullableInt(speedID), reqNum, reqFlow, reqFlowReset, reqExpTime, reqStatus); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {\n\t\t\tinsertedID, _, _, _, _, _, _, lookupErr := h.repo.GetExistingUserTunnel(userID, tunnelID)\n\t\t\tif lookupErr != nil {\n\t\t\t\treturn fmt.Errorf(\"下发失败且回滚失败: %v; 回滚查询错误: %w\", syncErr, lookupErr)\n\t\t\t}\n\n\t\t\tif rollbackErr := h.repo.DeleteUserTunnel(insertedID); rollbackErr != nil {\n\t\t\t\treturn fmt.Errorf(\"下发失败且回滚失败: %v; 回滚删除错误: %w\", syncErr, rollbackErr)\n\t\t\t}\n\n\t\t\treturn fmt.Errorf(\"下发失败,已回滚: %w\", syncErr)\n\t\t}\n\n\t\treturn nil\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tnewFlow := currentFlow\n\tif reqFlow >= 0 {\n\t\tnewFlow = reqFlow\n\t}\n\n\tnewNum := int(currentNum)\n\tif reqNum >= 0 {\n\t\tnewNum = reqNum\n\t}\n\n\tnewExpTime := currentExpTime\n\tif reqExpTime >= 0 {\n\t\tnewExpTime = reqExpTime\n\t}\n\n\tnewFlowReset := currentFlowReset\n\tif reqFlowReset >= 0 {\n\t\tnewFlowReset = reqFlowReset\n\t}\n\n\tnewStatus := currentStatus\n\tif reqStatus >= 0 {\n\t\tnewStatus = reqStatus\n\t}\n\n\tnewSpeedID := currentSpeedID\n\tif speedID != nil {\n\t\tnewSpeedID = sql.NullInt64{Int64: *speedID, Valid: true}\n\t} else if _, ok := req[\"speedId\"]; ok {\n\t\tnewSpeedID = sql.NullInt64{Valid: false}\n\t}\n\n\tif err := h.repo.UpdateUserTunnelFields(existingID, newSpeedID, newFlow, newNum, newExpTime, newFlowReset, newStatus); err != nil {\n\t\treturn err\n\t}\n\n\tif syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {\n\t\trollbackErr := h.repo.UpdateUserTunnelFields(\n\t\t\texistingID,\n\t\t\tcurrentSpeedID,\n\t\t\tcurrentFlow,\n\t\t\tint(currentNum),\n\t\t\tcurrentExpTime,\n\t\t\tcurrentFlowReset,\n\t\t\tcurrentStatus,\n\t\t)\n\t\tif rollbackErr != nil {\n\t\t\treturn fmt.Errorf(\"下发失败且回滚失败: %v; 回滚错误: %w\", syncErr, rollbackErr)\n\t\t}\n\n\t\treturn fmt.Errorf(\"下发失败,已回滚: %w\", syncErr)\n\t}\n\n\treturn nil\n}\n\nfunc (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) error {\n\tforwards, err := h.listForwardsByTunnel(tunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor i := range forwards {\n\t\tf := &forwards[i]\n\t\tif f.UserID == userID {\n\t\t\tif err := h.syncForwardServices(f, \"UpdateService\", true); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (h *Handler) validateSpeedLimitReference(speedID *int64) error {\n\tif speedID == nil {\n\t\treturn nil\n\t}\n\n\texists, err := h.repo.SpeedLimitExists(*speedID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !exists {\n\t\treturn errors.New(\"限速规则不存在\")\n\t}\n\n\treturn nil\n}\n\nfunc asAnySlice(v interface{}) []interface{} {\n\tif v == nil {\n\t\treturn nil\n\t}\n\tif arr, ok := v.([]interface{}); ok {\n\t\treturn arr\n\t}\n\treturn nil\n}\n\nfunc asMapSlice(v interface{}) []map[string]interface{} {\n\tarr := asAnySlice(v)\n\tif arr == nil {\n\t\treturn nil\n\t}\n\tout := make([]map[string]interface{}, 0, len(arr))\n\tfor _, it := range arr {\n\t\tif m, ok := it.(map[string]interface{}); ok {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc asString(v interface{}) string {\n\tswitch t := v.(type) {\n\tcase nil:\n\t\treturn \"\"\n\tcase string:\n\t\treturn strings.TrimSpace(t)\n\tcase float64:\n\t\tif t == float64(int64(t)) {\n\t\t\treturn strconv.FormatInt(int64(t), 10)\n\t\t}\n\t\treturn strconv.FormatFloat(t, 'f', -1, 64)\n\tcase int, int32, int64:\n\t\treturn fmt.Sprintf(\"%v\", t)\n\tdefault:\n\t\tb, _ := json.Marshal(t)\n\t\treturn strings.Trim(string(b), \"\\\"\")\n\t}\n}\n\nfunc asInt(v interface{}, def int) int {\n\ts := asString(v)\n\tif s == \"\" {\n\t\treturn def\n\t}\n\ti, err := strconv.Atoi(s)\n\tif err != nil {\n\t\treturn def\n\t}\n\treturn i\n}\n\nfunc asInt64(v interface{}, def int64) int64 {\n\ts := asString(v)\n\tif s == \"\" {\n\t\treturn def\n\t}\n\ti, err := strconv.ParseInt(s, 10, 64)\n\tif err != nil {\n\t\treturn def\n\t}\n\treturn i\n}\n\nfunc asFloat(v interface{}, def float64) float64 {\n\ts := asString(v)\n\tif s == \"\" {\n\t\treturn def\n\t}\n\tf, err := strconv.ParseFloat(s, 64)\n\tif err != nil {\n\t\treturn def\n\t}\n\treturn f\n}\n\nfunc asAnyToInt64Ptr(v interface{}) *int64 {\n\ts := asString(v)\n\tif s == \"\" || strings.EqualFold(s, \"null\") {\n\t\treturn nil\n\t}\n\ti, err := strconv.ParseInt(s, 10, 64)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &i\n}\n\nfunc idFromBody(r *http.Request, w http.ResponseWriter) int64 {\n\treturn asInt64FromBodyKey(r, w, \"id\")\n}\n\nfunc asInt64FromBodyKey(r *http.Request, w http.ResponseWriter, key string) int64 {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn 0\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn 0\n\t}\n\tid := asInt64(req[key], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"参数错误\"))\n\t\treturn 0\n\t}\n\treturn id\n}\n\nfunc idsFromBody(r *http.Request, w http.ResponseWriter) []int64 {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn nil\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn nil\n\t}\n\tarr := asAnySlice(req[\"ids\"])\n\tif len(arr) == 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"ids不能为空\"))\n\t\treturn nil\n\t}\n\tids := make([]int64, 0, len(arr))\n\tfor _, x := range arr {\n\t\tid := asInt64(x, 0)\n\t\tif id > 0 {\n\t\t\tids = append(ids, id)\n\t\t}\n\t}\n\tsort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })\n\treturn ids\n}\n\nfunc nullableText(s string) interface{} {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn nil\n\t}\n\treturn s\n}\n\nfunc nullableInt(v *int64) interface{} {\n\tif v == nil {\n\t\treturn nil\n\t}\n\treturn *v\n}\n\nfunc defaultString(v, def string) string {\n\tif strings.TrimSpace(v) == \"\" {\n\t\treturn def\n\t}\n\treturn v\n}\n\nfunc randomToken(n int) string {\n\tbuf := make([]byte, n)\n\tif _, err := rand.Read(buf); err != nil {\n\t\treturn strconv.FormatInt(time.Now().UnixNano(), 16)\n\t}\n\treturn hex.EncodeToString(buf)\n}\n\nfunc asInt64Slice(v interface{}) []int64 {\n\tarr := asAnySlice(v)\n\tif len(arr) == 0 {\n\t\treturn nil\n\t}\n\tids := make([]int64, 0, len(arr))\n\tfor _, x := range arr {\n\t\tif id := asInt64(x, 0); id > 0 {\n\t\t\tids = append(ids, id)\n\t\t}\n\t}\n\treturn ids\n}\n",
|
||
"after": "package handler\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"database/sql\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math/big\"\n\t\"net\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"go-backend/internal/http/client\"\n\t\"go-backend/internal/http/response\"\n\t\"go-backend/internal/security\"\n\t\"go-backend/internal/store/model\"\n\t\"go-backend/internal/store/repo\"\n\n\t\"gorm.io/gorm\"\n)\n\nfunc (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\n\tusername := asString(req[\"user\"])\n\tpwd := asString(req[\"pwd\"])\n\tif username == \"\" || pwd == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户名或密码不能为空\"))\n\t\treturn\n\t}\n\n\texists, err := h.repo.UserExists(username)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif exists {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户名已存在\"))\n\t\treturn\n\t}\n\n\tstatus := asInt(req[\"status\"], 1)\n\tflow := asInt64(req[\"flow\"], 100)\n\tnum := asInt(req[\"num\"], 10)\n\texpTime := asInt64(req[\"expTime\"], time.Now().Add(365*24*time.Hour).UnixMilli())\n\tflowResetTime := asInt64(req[\"flowResetTime\"], 1)\n\troleID := 1\n\tnow := time.Now().UnixMilli()\n\n\tuserID, err := h.repo.CreateUser(username, security.MD5(pwd), roleID, expTime, flow, flowResetTime, num, status, now)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tgroupIDs := asInt64Slice(req[\"groupIds\"])\n\tif len(groupIDs) > 0 {\n\t\tif addErr := h.repo.AddUserToGroups(userID, groupIDs, now); addErr == nil {\n\t\t\tfor _, gid := range groupIDs {\n\t\t\t\t_ = h.syncPermissionsByUserGroup(gid)\n\t\t\t}\n\t\t}\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户ID不能为空\"))\n\t\treturn\n\t}\n\tusername := asString(req[\"user\"])\n\tif username == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户名不能为空\"))\n\t\treturn\n\t}\n\n\troleID, err := h.repo.GetUserRoleID(id)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif roleID == 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请不要作死\"))\n\t\treturn\n\t}\n\n\tdup, err := h.repo.UserExistsExcluding(username, id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif dup {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户名已存在\"))\n\t\treturn\n\t}\n\n\tflow := asInt64(req[\"flow\"], 100)\n\tnum := asInt(req[\"num\"], 10)\n\texpTime := asInt64(req[\"expTime\"], time.Now().Add(365*24*time.Hour).UnixMilli())\n\tflowResetTime := asInt64(req[\"flowResetTime\"], 1)\n\tstatus := asInt(req[\"status\"], 1)\n\tnow := time.Now().UnixMilli()\n\n\tpwd := asString(req[\"pwd\"])\n\tif strings.TrimSpace(pwd) == \"\" {\n\t\tif err := h.repo.UpdateUserWithoutPassword(id, username, flow, num, expTime, flowResetTime, status, now); err != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tif err := h.repo.UpdateUserWithPassword(id, username, security.MD5(pwd), flow, num, expTime, flowResetTime, status, now); err != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\n\th.repo.PropagateUserFlowToTunnels(id, flow, num, expTime, flowResetTime)\n\n\tif groupIDsRaw, ok := req[\"groupIds\"]; ok {\n\t\tnewGroupIDs := asInt64Slice(groupIDsRaw)\n\t\tif affected, replaceErr := h.repo.ReplaceUserGroupsByUserID(id, newGroupIDs, now); replaceErr == nil {\n\t\t\tfor _, gid := range affected {\n\t\t\t\t_ = h.syncPermissionsByUserGroup(gid)\n\t\t\t}\n\t\t}\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userGroups(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tids, err := h.repo.GetUserGroupIDsByUserID(id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OK(ids))\n}\n\nfunc (h *Handler) userDelete(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\n\troleID, err := h.repo.GetUserRoleID(id)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif roleID == 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请不要作死\"))\n\t\treturn\n\t}\n\n\tif err := h.repo.DeleteUserCascade(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userResetFlow(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\ttypeVal := asInt(req[\"type\"], 0)\n\tif id <= 0 || (typeVal != 1 && typeVal != 2) {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\n\tif typeVal == 1 {\n\t\th.repo.ResetUserFlowByUser(id, time.Now().UnixMilli())\n\t} else {\n\t\th.repo.ResetUserFlowByUserTunnel(id)\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tname := asString(req[\"name\"])\n\tserverIP := asString(req[\"serverIp\"])\n\tif name == \"\" || serverIP == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"节点名称和地址不能为空\"))\n\t\treturn\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tinx := h.repo.NextIndex(\"node\")\n\tif err := h.repo.CreateNode(\n\t\tname,\n\t\trandomToken(16),\n\t\tserverIP,\n\t\tnullableText(asString(req[\"serverIpV4\"])),\n\t\tnullableText(asString(req[\"serverIpV6\"])),\n\t\tdefaultString(asString(req[\"port\"]), \"1000-65535\"),\n\t\tnullableText(asString(req[\"interfaceName\"])),\n\t\tnullableText(\"\"),\n\t\tasInt(req[\"http\"], 0),\n\t\tasInt(req[\"tls\"], 0),\n\t\tasInt(req[\"socks\"], 0),\n\t\tnow,\n\t\t0,\n\t\tdefaultString(asString(req[\"tcpListenAddr\"]), \"[::]\"),\n\t\tdefaultString(asString(req[\"udpListenAddr\"]), \"[::]\"),\n\t\tinx,\n\t\tasInt(req[\"isRemote\"], 0),\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\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"节点ID不能为空\"))\n\t\treturn\n\t}\n\n\tcurrentStatus, currentHTTP, currentTLS, currentSocks, err := h.repo.GetNodeStatusFields(id)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"节点不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tnewHTTP := asInt(req[\"http\"], currentHTTP)\n\tnewTLS := asInt(req[\"tls\"], currentTLS)\n\tnewSocks := asInt(req[\"socks\"], currentSocks)\n\tif currentStatus == 1 && (newHTTP != currentHTTP || newTLS != currentTLS || newSocks != currentSocks) {\n\t\tif err := h.applyNodeProtocolChange(id, newHTTP, newTLS, newSocks); err != nil {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tif err := h.repo.UpdateNode(id,\n\t\tasString(req[\"name\"]),\n\t\tasString(req[\"serverIp\"]),\n\t\tnullableText(asString(req[\"serverIpV4\"])),\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\t\tdefaultString(asString(req[\"tcpListenAddr\"]), \"[::]\"),\n\t\tdefaultString(asString(req[\"udpListenAddr\"]), \"[::]\"),\n\t\tnow,\n\t); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeDelete(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tif err := h.deleteNodeByID(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\n\tvar req struct {\n\t\tID int64 `json:\"id\"`\n\t\tChannel string `json:\"channel\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tif req.ID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"参数错误\"))\n\t\treturn\n\t}\n\n\tchannel := normalizeReleaseChannel(req.Channel)\n\tversion, err := resolveLatestReleaseByChannel(channel)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, fmt.Sprintf(\"获取最新%s失败: %v\", releaseChannelLabel(channel), err)))\n\t\treturn\n\t}\n\n\tsecret, err := h.repo.GetNodeSecret(req.ID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"节点不存在\"))\n\t\treturn\n\t}\n\tpanelAddr, err := h.repo.GetViteConfigValue(\"ip\")\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"请先前往网站配置中设置ip\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tcmd := fmt.Sprintf(\"curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && VERSION=%s ./install.sh -a %s -s %s\", version, version, processServerAddress(panelAddr), secret)\n\tresponse.WriteJSON(w, response.OK(cmd))\n}\n\nfunc (h *Handler) nodeUpdateOrder(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req struct {\n\t\tNodes []struct {\n\t\t\tID int64 `json:\"id\"`\n\t\t\tInx int `json:\"inx\"`\n\t\t} `json:\"nodes\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tfor _, n := range req.Nodes {\n\t\th.repo.UpdateNodeOrder(n.ID, n.Inx, time.Now().UnixMilli())\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeBatchDelete(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tfor _, id := range ids {\n\t\t_ = h.deleteNodeByID(id)\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeCheckStatus(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\titems, err := h.repo.ListNodes()\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OK(items))\n}\n\nfunc (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tname := asString(req[\"name\"])\n\tif name == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道名称不能为空\"))\n\t\treturn\n\t}\n\tnameDup, err := h.repo.TunnelNameExists(name)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif nameDup {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道名称重复\"))\n\t\treturn\n\t}\n\n\ttypeVal := asInt(req[\"type\"], 1)\n\tflow := asInt64(req[\"flow\"], 1)\n\tstatus := asInt(req[\"status\"], 1)\n\ttrafficRatio := asFloat(req[\"trafficRatio\"], 1.0)\n\tinIP := asString(req[\"inIp\"])\n\tipPreference := asString(req[\"ipPreference\"])\n\tnow := time.Now().UnixMilli()\n\tinx := h.repo.NextIndex(\"tunnel\")\n\tlocalDomain := h.federationLocalDomain()\n\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\n\truntimeState, err := h.prepareTunnelCreateState(tx, req, typeVal, 0)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\truntimeState.IPPreference = ipPreference\n\tif strings.TrimSpace(inIP) == \"\" {\n\t\tinIP = buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes, ipPreference)\n\t}\n\n\tif len(runtimeState.InNodes) > 0 {\n\t\tfirstNodeID := runtimeState.InNodes[0].NodeID\n\t\tisRemote, rUrl, rToken, _ := h.repo.GetNodeRemoteFieldsTx(tx, firstNodeID)\n\t\tif isRemote == 1 {\n\t\t\tfc := client.NewFederationClient()\n\n\t\t\ttargetProto := \"tcp\"\n\t\t\ttargetPort := 0\n\t\t\ttargetAddr := \"\"\n\n\t\t\tif typeVal == 1 {\n\t\t\t\tif len(runtimeState.OutNodes) > 0 {\n\t\t\t\t\toutNode := runtimeState.OutNodes[0]\n\t\t\t\t\toutNodeRec := runtimeState.Nodes[outNode.NodeID]\n\t\t\t\t\ttargetAddr = processServerAddress(outNodeRec.ServerIP)\n\t\t\t\t\tif outNode.Port > 0 {\n\t\t\t\t\t\ttargetAddr = fmt.Sprintf(\"%s:%d\", targetAddr, outNode.Port)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif len(runtimeState.InNodes) > 0 {\n\t\t\t\t\tinNodesRaw := asMapSlice(req[\"inNodeId\"])\n\t\t\t\t\tif len(inNodesRaw) > 0 {\n\t\t\t\t\t\ttargetPort = asInt(inNodesRaw[0][\"port\"], 0)\n\t\t\t\t\t\ttargetProto = defaultString(asString(inNodesRaw[0][\"protocol\"]), \"tcp\")\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif targetPort > 0 && targetAddr != \"\" {\n\t\t\t\t\tinNodeRec := runtimeState.Nodes[firstNodeID]\n\t\t\t\t\tif err := validateRemoteNodePort(inNodeRec, targetPort); err != nil {\n\t\t\t\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tdomainCfg, _ := h.repo.GetConfigByName(\"panel_domain\")\n\t\t\t\t\tlocalDomain := \"\"\n\t\t\t\t\tif domainCfg != nil {\n\t\t\t\t\t\tlocalDomain = domainCfg.Value\n\t\t\t\t\t}\n\t\t\t\t\t_, err := fc.CreateTunnel(rUrl.String, rToken.String, localDomain, targetProto, targetPort, targetAddr)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"Remote tunnel creation failed: \"+err.Error()))\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tvar tunnelInIP sql.NullString\n\tif trimmed := strings.TrimSpace(inIP); trimmed != \"\" {\n\t\ttunnelInIP = sql.NullString{String: trimmed, Valid: true}\n\t}\n\ttunnel := model.Tunnel{\n\t\tName: name,\n\t\tTrafficRatio: trafficRatio,\n\t\tType: typeVal,\n\t\tProtocol: \"tls\",\n\t\tFlow: flow,\n\t\tCreatedTime: now,\n\t\tUpdatedTime: now,\n\t\tStatus: status,\n\t\tInIP: tunnelInIP,\n\t\tInx: inx,\n\t\tIPPreference: ipPreference,\n\t}\n\tif err := tx.Create(&tunnel).Error; err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\ttunnelID := tunnel.ID\n\truntimeState.TunnelID = tunnelID\n\tvar federationBindings []repo.FederationTunnelBinding\n\tvar federationReleaseRefs []federationRuntimeReleaseRef\n\tfederationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState, localDomain)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tapplyTunnelPortsToRequest(req, runtimeState)\n\tif err := h.replaceTunnelChainsTx(tx, tunnelID, req); err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := tx.Commit().Error; err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif typeVal == 2 {\n\t\tcreatedChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)\n\t\tif applyErr != nil {\n\t\t\th.rollbackTunnelRuntime(createdChains, createdServices, tunnelID)\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\t_ = h.deleteTunnelByID(tunnelID)\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(applyErr.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) cleanupTunnelRuntime(tunnelID int64) {\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil || tunnel.Type != 2 {\n\t\treturn\n\t}\n\tchainRows, err := h.listChainNodesForTunnel(tunnelID)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tserviceName := fmt.Sprintf(\"%d_tls\", tunnelID)\n\tchainName := fmt.Sprintf(\"chains_%d\", tunnelID)\n\n\tfor _, row := range chainRows {\n\t\tif row.ChainType == 1 {\n\t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteChains\", map[string]interface{}{\"chain\": chainName}, false, true)\n\t\t} else if row.ChainType == 2 {\n\t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteChains\", map[string]interface{}{\"chain\": chainName}, false, true)\n\t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteService\", map[string]interface{}{\"services\": []string{serviceName}}, false, true)\n\t\t} else if row.ChainType == 3 {\n\t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteService\", map[string]interface{}{\"services\": []string{serviceName}}, false, true)\n\t\t}\n\t}\n}\n\nfunc (h *Handler) tunnelGet(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\titems, err := h.repo.ListTunnels()\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tfor _, it := range items {\n\t\tif asInt64(it[\"id\"], 0) == id {\n\t\t\tresponse.WriteJSON(w, response.OK(it))\n\t\t\treturn\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.ErrDefault(\"隧道不存在\"))\n}\n\nfunc (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道ID不能为空\"))\n\t\treturn\n\t}\n\n\th.cleanupTunnelRuntime(id)\n\th.cleanupFederationRuntime(id)\n\n\tnow := time.Now().UnixMilli()\n\ttypeVal := asInt(req[\"type\"], 1)\n\tipPreference := asString(req[\"ipPreference\"])\n\tlocalDomain := h.federationLocalDomain()\n\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\n\truntimeState, err := h.prepareTunnelCreateState(tx, req, typeVal, id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\truntimeState.TunnelID = id\n\truntimeState.IPPreference = ipPreference\n\n\tinIp := buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes, ipPreference)\n\n\tvar federationBindings []repo.FederationTunnelBinding\n\tvar federationReleaseRefs []federationRuntimeReleaseRef\n\tfederationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState, localDomain)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tapplyTunnelPortsToRequest(req, runtimeState)\n\n\tif err := h.repo.UpdateTunnelTx(\n\t\ttx,\n\t\tid,\n\t\tasString(req[\"name\"]),\n\t\ttypeVal,\n\t\tasInt64(req[\"flow\"], 1),\n\t\tasFloat(req[\"trafficRatio\"], 1.0),\n\t\tasInt(req[\"status\"], 1),\n\t\tinIp,\n\t\tipPreference,\n\t\tnow,\n\t); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tif err := h.repo.DeleteChainTunnelsByTunnelTx(tx, id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.replaceTunnelChainsTx(tx, id, req); err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.repo.ReplaceFederationTunnelBindingsTx(tx, id, federationBindings); err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := tx.Commit().Error; err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tif typeVal == 2 {\n\t\tcreatedChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)\n\t\tif applyErr != nil {\n\t\t\th.rollbackTunnelRuntime(createdChains, createdServices, id)\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\t_ = h.repo.DeleteFederationTunnelBindingsByTunnel(id)\n\t\t\tif len(federationReleaseRefs) == 0 && shouldDeferTunnelRuntimeApplyError(applyErr) {\n\t\t\t\tresponse.WriteJSON(w, response.OKEmpty())\n\t\t\t\treturn\n\t\t\t}\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(applyErr.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\n\tif forwards, fwdErr := h.listForwardsByTunnel(id); fwdErr == nil {\n\t\tfor i := range forwards {\n\t\t\t_ = h.syncForwardServices(&forwards[i], \"UpdateService\", true)\n\t\t}\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\th.cleanupTunnelRuntime(id)\n\th.cleanupFederationRuntime(id)\n\tif err := h.deleteTunnelByID(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) tunnelDiagnose(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := asInt64FromBodyKey(r, w, \"tunnelId\")\n\tif id <= 0 {\n\t\treturn\n\t}\n\tctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)\n\tdefer cancel()\n\tresult, err := h.diagnoseTunnelRuntime(ctx, id)\n\tif err != nil {\n\t\tif strings.Contains(err.Error(), \"不存在\") || strings.Contains(err.Error(), \"不完整\") {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OK(result))\n}\n\nfunc (h *Handler) tunnelUpdateOrder(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req struct {\n\t\tTunnels []struct {\n\t\t\tID int64 `json:\"id\"`\n\t\t\tInx int `json:\"inx\"`\n\t\t} `json:\"tunnels\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tfor _, t := range req.Tunnels {\n\t\th.repo.UpdateTunnelOrder(t.ID, t.Inx, time.Now().UnixMilli())\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tsuccess := 0\n\tfail := 0\n\tfor _, id := range ids {\n\t\th.cleanupTunnelRuntime(id)\n\t\th.cleanupFederationRuntime(id)\n\t\tif err := h.deleteTunnelByID(id); err != nil {\n\t\t\tfail++\n\t\t} else {\n\t\t\tsuccess++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": success, \"failCount\": fail}))\n}\n\nfunc (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, error) {\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tchainRows, err := h.listChainNodesForTunnel(tunnelID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tipPreference := h.repo.GetTunnelIPPreference(tunnelID)\n\n\tstate := &tunnelCreateState{\n\t\tTunnelID: tunnelID,\n\t\tType: tunnel.Type,\n\t\tIPPreference: ipPreference,\n\t\tInNodes: make([]tunnelRuntimeNode, 0),\n\t\tChainHops: make([][]tunnelRuntimeNode, 0),\n\t\tOutNodes: make([]tunnelRuntimeNode, 0),\n\t\tNodes: make(map[int64]*nodeRecord),\n\t\tNodeIDList: make([]int64, 0),\n\t}\n\n\tinNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)\n\n\tfor _, r := range inNodes {\n\t\tstate.InNodes = append(state.InNodes, tunnelRuntimeNode{\n\t\t\tNodeID: r.NodeID,\n\t\t\tProtocol: r.Protocol,\n\t\t\tStrategy: r.Strategy,\n\t\t\tChainType: 1,\n\t\t})\n\t\tstate.NodeIDList = append(state.NodeIDList, r.NodeID)\n\t}\n\n\tfor _, r := range outNodes {\n\t\tstate.OutNodes = append(state.OutNodes, tunnelRuntimeNode{\n\t\t\tNodeID: r.NodeID,\n\t\t\tProtocol: r.Protocol,\n\t\t\tStrategy: r.Strategy,\n\t\t\tChainType: 3,\n\t\t\tPort: r.Port,\n\t\t})\n\t\tstate.NodeIDList = append(state.NodeIDList, r.NodeID)\n\t}\n\n\tfor _, hop := range chainHops {\n\t\tstateHop := make([]tunnelRuntimeNode, 0)\n\t\tfor _, r := range hop {\n\t\t\tstateHop = append(stateHop, tunnelRuntimeNode{\n\t\t\t\tNodeID: r.NodeID,\n\t\t\t\tProtocol: r.Protocol,\n\t\t\t\tStrategy: r.Strategy,\n\t\t\t\tChainType: 2,\n\t\t\t\tInx: int(r.Inx),\n\t\t\t\tPort: r.Port,\n\t\t\t})\n\t\t\tstate.NodeIDList = append(state.NodeIDList, r.NodeID)\n\t\t}\n\t\tstate.ChainHops = append(state.ChainHops, stateHop)\n\t}\n\n\tseen := make(map[int64]struct{})\n\tfor _, id := range state.NodeIDList {\n\t\tif _, ok := seen[id]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[id] = struct{}{}\n\t\tnode, err := h.getNodeRecord(id)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tstate.Nodes[id] = node\n\t}\n\n\treturn state, nil\n}\n\nfunc (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif tunnel.Type == 2 {\n\t\th.cleanupTunnelRuntime(tunnelID)\n\t\th.cleanupFederationRuntime(tunnelID)\n\t\tstate, err := h.reconstructTunnelState(tunnelID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfederationBindings, federationReleaseRefs, fedErr := h.applyFederationRuntime(state, h.federationLocalDomain())\n\t\tif fedErr != nil {\n\t\t\treturn fedErr\n\t\t}\n\t\ttx := h.repo.BeginTx()\n\t\tif tx.Error != nil {\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\treturn tx.Error\n\t\t}\n\t\tif replaceErr := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); replaceErr != nil {\n\t\t\ttx.Rollback()\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\treturn replaceErr\n\t\t}\n\t\tif commitErr := tx.Commit().Error; commitErr != nil {\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\treturn commitErr\n\t\t}\n\t\t_, _, applyErr := h.applyTunnelRuntime(state)\n\t\tif applyErr != nil {\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\t_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)\n\t\t\treturn applyErr\n\t\t}\n\t}\n\n\tforwards, err := h.listForwardsByTunnel(tunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor i := range forwards {\n\t\tif err := h.syncForwardServices(&forwards[i], \"UpdateService\", true); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tsuccess := 0\n\tfail := 0\n\tfor _, tunnelID := range ids {\n\t\tif err := h.redeployTunnelAndForwards(tunnelID); err != nil {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tsuccess++\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": success, \"failCount\": fail}))\n}\n\nfunc (h *Handler) userTunnelAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tif err := h.upsertUserTunnel(req); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userTunnelBatchAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tUserID int64 `json:\"userId\"`\n\t\tTunnels []struct {\n\t\t\tTunnelID int64 `json:\"tunnelId\"`\n\t\t\tSpeedID *int64 `json:\"speedId\"`\n\t\t} `json:\"tunnels\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.UserID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tfor _, t := range req.Tunnels {\n\t\tm := map[string]interface{}{\"userId\": req.UserID, \"tunnelId\": t.TunnelID}\n\t\tif t.SpeedID != nil {\n\t\t\tm[\"speedId\"] = *t.SpeedID\n\t\t}\n\t\tif err := h.upsertUserTunnel(m); err != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userTunnelRemove(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tif err := h.repo.DeleteUserTunnel(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userTunnelUpdate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"权限ID不能为空\"))\n\t\treturn\n\t}\n\n\tspeedID := asAnyToInt64Ptr(req[\"speedId\"])\n\tif err := h.validateSpeedLimitReference(speedID); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tuserID, tunnelID, utErr := h.repo.GetUserTunnelUserAndTunnel(id)\n\tif utErr != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, utErr.Error()))\n\t\treturn\n\t}\n\n\t_, oldFlow, oldNum, oldExpTime, oldFlowReset, oldSpeedID, oldStatus, oldErr :=\n\t\th.repo.GetExistingUserTunnel(userID, tunnelID)\n\tif oldErr != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, oldErr.Error()))\n\t\treturn\n\t}\n\n\tif err := h.repo.UpdateUserTunnel(id,\n\t\tasInt64(req[\"flow\"], 0),\n\t\tasInt(req[\"num\"], 0),\n\t\tasInt64(req[\"expTime\"], time.Now().Add(365*24*time.Hour).UnixMilli()),\n\t\tasInt64(req[\"flowResetTime\"], 1),\n\t\tnullableInt(speedID),\n\t\tasInt(req[\"status\"], 1),\n\t); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tif syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {\n\t\trollbackErr := h.repo.UpdateUserTunnel(\n\t\t\tid,\n\t\t\toldFlow,\n\t\t\tint(oldNum),\n\t\t\toldExpTime,\n\t\t\toldFlowReset,\n\t\t\toldSpeedID,\n\t\t\toldStatus,\n\t\t)\n\t\tif rollbackErr != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, fmt.Sprintf(\"下发失败且回滚失败: %v; 回滚错误: %v\", syncErr, rollbackErr)))\n\t\t\treturn\n\t\t}\n\n\t\tresponse.WriteJSON(w, response.Err(-2, fmt.Sprintf(\"下发失败,已回滚: %v\", syncErr)))\n\t\treturn\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tuserID, roleID, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ttunnelID := asInt64(req[\"tunnelId\"], 0)\n\tif tunnelID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道ID不能为空\"))\n\t\treturn\n\t}\n\tif err := h.ensureTunnelPermission(userID, roleID, tunnelID); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道不存在\"))\n\t\treturn\n\t}\n\tif tunnel.Status != 1 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道已禁用,无法创建转发\"))\n\t\treturn\n\t}\n\tname := asString(req[\"name\"])\n\tremoteAddr := asString(req[\"remoteAddr\"])\n\tif name == \"\" || remoteAddr == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发名称和目标地址不能为空\"))\n\t\treturn\n\t}\n\tspeedID := asAnyToInt64Ptr(req[\"speedId\"])\n\tif speedID != nil {\n\t\texists, speedErr := h.repo.SpeedLimitExists(*speedID)\n\t\tif speedErr != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, speedErr.Error()))\n\t\t\treturn\n\t\t}\n\t\tif !exists {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"限速规则不存在\"))\n\t\t\treturn\n\t\t}\n\t}\n\tport := asInt(req[\"inPort\"], 0)\n\tif port <= 0 {\n\t\tport = h.pickTunnelPort(tunnelID)\n\t}\n\tif port <= 0 {\n\t\tport = 10000\n\t}\n\tentryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)\n\tfor _, nodeID := range entryNodes {\n\t\tnode, nodeErr := h.getNodeRecord(nodeID)\n\t\tif nodeErr != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif err := validateRemoteNodePort(node, port); err != nil {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tnow := time.Now().UnixMilli()\n\tinx := h.repo.NextIndex(\"forward\")\n\tuserName := h.repo.GetUsernameByID(userID)\n\tif userName == \"\" {\n\t\tuserName = \"user\"\n\t}\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\t}\n\tcreatedForward, err := h.getForwardRecord(forwardID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.syncForwardServices(createdForward, \"UpdateService\", true); err != nil {\n\t\t_ = h.deleteForwardByID(forwardID)\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发ID不能为空\"))\n\t\treturn\n\t}\n\tforward, actorUserID, actorRole, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\toldPorts, err := h.listForwardPorts(id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\ttunnelID := asInt64(req[\"tunnelId\"], forward.TunnelID)\n\tif tunnelID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道ID不能为空\"))\n\t\treturn\n\t}\n\tif err := h.ensureTunnelPermission(actorUserID, actorRole, tunnelID); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道不存在\"))\n\t\treturn\n\t}\n\tif tunnel.Status != 1 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道已禁用,无法更新转发\"))\n\t\treturn\n\t}\n\n\tname := strings.TrimSpace(asString(req[\"name\"]))\n\tif name == \"\" {\n\t\tname = forward.Name\n\t}\n\tremoteAddr := strings.TrimSpace(asString(req[\"remoteAddr\"]))\n\tif remoteAddr == \"\" {\n\t\tremoteAddr = forward.RemoteAddr\n\t}\n\tstrategy := strings.TrimSpace(asString(req[\"strategy\"]))\n\tif strategy == \"\" {\n\t\tstrategy = forward.Strategy\n\t}\n\tspeedID := asAnyToInt64Ptr(req[\"speedId\"])\n\tif speedID != nil {\n\t\texists, speedErr := h.repo.SpeedLimitExists(*speedID)\n\t\tif speedErr != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, speedErr.Error()))\n\t\t\treturn\n\t\t}\n\t\tif !exists {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"限速规则不存在\"))\n\t\t\treturn\n\t\t}\n\t}\n\tnewSpeedID := forward.SpeedID\n\tif speedID != nil {\n\t\tnewSpeedID = sql.NullInt64{Int64: *speedID, Valid: true}\n\t} else if _, ok := req[\"speedId\"]; ok {\n\t\tnewSpeedID = sql.NullInt64{Valid: false}\n\t}\n\n\tport := asInt(req[\"inPort\"], 0)\n\tif port <= 0 {\n\t\tminPort := h.repo.GetMinForwardPort(id)\n\t\tif minPort.Valid {\n\t\t\tport = int(minPort.Int64)\n\t\t}\n\t\tif port <= 0 {\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\t\tif nodeErr != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif err := validateRemoteNodePort(node, port); err != nil {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tnow := time.Now().UnixMilli()\n\tif err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\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\t}\n\tupdatedForward, err := h.getForwardRecord(id)\n\tif err != nil {\n\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.syncForwardServices(updatedForward, \"UpdateService\", true); err != nil {\n\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardDelete(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tforward, _, _, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.controlForwardServices(forward, \"DeleteService\", true); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tif err := h.deleteForwardByID(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardForceDelete(w http.ResponseWriter, r *http.Request) {\n\th.forwardDelete(w, r)\n}\n\nfunc (h *Handler) forwardPause(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tforward, _, _, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.controlForwardServices(forward, \"PauseService\", false); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\t_ = h.repo.UpdateForwardStatus(id, 0, time.Now().UnixMilli())\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardResume(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tforward, _, _, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.controlForwardServices(forward, \"ResumeService\", false); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\t_ = h.repo.UpdateForwardStatus(id, 1, time.Now().UnixMilli())\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardDiagnose(w http.ResponseWriter, r *http.Request) {\n\tid := asInt64FromBodyKey(r, w, \"forwardId\")\n\tif id <= 0 {\n\t\treturn\n\t}\n\tforward, _, _, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)\n\tdefer cancel()\n\tpayload, err := h.diagnoseForwardRuntime(ctx, forward)\n\tif err != nil {\n\t\tif strings.Contains(err.Error(), \"不存在\") || strings.Contains(err.Error(), \"不能为空\") || strings.Contains(err.Error(), \"错误\") {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OK(payload))\n}\n\nfunc (h *Handler) forwardUpdateOrder(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tForwards []struct {\n\t\t\tID int64 `json:\"id\"`\n\t\t\tInx int `json:\"inx\"`\n\t\t} `json:\"forwards\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tfor _, f := range req.Forwards {\n\t\th.repo.UpdateForwardOrder(f.ID, f.Inx, time.Now().UnixMilli())\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardBatchDelete(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.controlForwardServices(forward, \"DeleteService\", true); err != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.deleteForwardByID(id); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": s, \"failCount\": f}))\n}\n\nfunc (h *Handler) forwardBatchPause(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.controlForwardServices(forward, \"PauseService\", false); err != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.repo.UpdateForwardStatus(id, 0, time.Now().UnixMilli()); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": s, \"failCount\": f}))\n}\n\nfunc (h *Handler) forwardBatchResume(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.controlForwardServices(forward, \"ResumeService\", false); err != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.repo.UpdateForwardStatus(id, 1, time.Now().UnixMilli()); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": s, \"failCount\": f}))\n}\n\nfunc (h *Handler) forwardBatchRedeploy(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.syncForwardServices(forward, \"UpdateService\", true); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": s, \"failCount\": f}))\n}\n\nfunc (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tForwardIDs []int64 `json:\"forwardIds\"`\n\t\tTargetTunnelID int64 `json:\"targetTunnelId\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.TargetTunnelID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\tif err := h.ensureTunnelPermission(actorUserID, actorRole, req.TargetTunnelID); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\ttargetTunnel, err := h.getTunnelRecord(req.TargetTunnelID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"目标隧道不存在\"))\n\t\treturn\n\t}\n\tif targetTunnel.Status != 1 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"目标隧道已禁用\"))\n\t\treturn\n\t}\n\tsuccess := 0\n\tfail := 0\n\tfor _, id := range req.ForwardIDs {\n\t\tif id <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tif forward.TunnelID == req.TargetTunnelID {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\toldPorts, listPortsErr := h.listForwardPorts(id)\n\t\tif listPortsErr != nil {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tport := h.repo.GetMinForwardPort(id)\n\t\tif err := h.repo.UpdateForwardTunnel(id, req.TargetTunnelID, time.Now().UnixMilli()); err != nil {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tp := 0\n\t\tif port.Valid {\n\t\t\tp = int(port.Int64)\n\t\t}\n\t\tif p <= 0 {\n\t\t\tp = h.pickTunnelPort(req.TargetTunnelID)\n\t\t}\n\t\tbctEntryNodes, _ := h.tunnelEntryNodeIDs(req.TargetTunnelID)\n\t\tportRangeOk := true\n\t\tfor _, nid := range bctEntryNodes {\n\t\t\tnd, ndErr := h.getNodeRecord(nid)\n\t\t\tif ndErr != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif validateRemoteNodePort(nd, p) != nil {\n\t\t\t\tportRangeOk = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !portRangeOk {\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\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tupdatedForward, fetchErr := h.getForwardRecord(id)\n\t\tif fetchErr != nil {\n\t\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.syncForwardServices(updatedForward, \"UpdateService\", true); err != nil {\n\t\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tsuccess++\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": success, \"failCount\": fail}))\n}\n\nfunc (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\n\tname := asString(req[\"name\"])\n\tif name == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"名称不能为空\"))\n\t\treturn\n\t}\n\n\tspeed := asInt(req[\"speed\"], 100)\n\n\tnow := time.Now().UnixMilli()\n\t_, err := h.repo.CreateSpeedLimit(name, speed, now, asInt(req[\"status\"], 1))\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) speedLimitUpdate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"限速规则ID不能为空\"))\n\t\treturn\n\t}\n\n\tname := asString(req[\"name\"])\n\tif name == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"名称不能为空\"))\n\t\treturn\n\t}\n\n\tspeed := asInt(req[\"speed\"], 100)\n\n\tif err := h.repo.UpdateSpeedLimit(id, name, speed, asInt(req[\"status\"], 1), time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) speedLimitDelete(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\n\tif err := h.repo.DeleteSpeedLimit(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupTunnelCreate(w http.ResponseWriter, r *http.Request) {\n\th.groupCreate(w, r, \"tunnel_group\")\n}\n\nfunc (h *Handler) groupTunnelUpdate(w http.ResponseWriter, r *http.Request) {\n\th.groupUpdate(w, r, \"tunnel_group\")\n}\n\nfunc (h *Handler) groupTunnelDelete(w http.ResponseWriter, r *http.Request) {\n\th.groupDelete(w, r, \"tunnel_group\")\n}\n\nfunc (h *Handler) groupUserCreate(w http.ResponseWriter, r *http.Request) {\n\th.groupCreate(w, r, \"user_group\")\n}\n\nfunc (h *Handler) groupUserUpdate(w http.ResponseWriter, r *http.Request) {\n\th.groupUpdate(w, r, \"user_group\")\n}\n\nfunc (h *Handler) groupUserDelete(w http.ResponseWriter, r *http.Request) {\n\th.groupDelete(w, r, \"user_group\")\n}\n\nfunc (h *Handler) groupTunnelAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tGroupID int64 `json:\"groupId\"`\n\t\tTunnelIDs []int64 `json:\"tunnelIds\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.GroupID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\tif err := h.repo.ReplaceTunnelGroupMembersTx(tx, req.GroupID, req.TunnelIDs, time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := tx.Commit().Error; err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\t_ = h.syncPermissionsByTunnelGroup(req.GroupID)\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupUserAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tGroupID int64 `json:\"groupId\"`\n\t\tUserIDs []int64 `json:\"userIds\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.GroupID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\tpreviousUserIDs, err := h.repo.ListUserIDsByUserGroupTx(tx, req.GroupID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.repo.ReplaceUserGroupMembersTx(tx, req.GroupID, req.UserIDs, time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.repo.RevokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := tx.Commit().Error; err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\t_ = h.syncPermissionsByUserGroup(req.GroupID)\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupPermissionAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tUserGroupID int64 `json:\"userGroupId\"`\n\t\tTunnelGroupID int64 `json:\"tunnelGroupId\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.UserGroupID <= 0 || req.TunnelGroupID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tif err := h.repo.InsertGroupPermission(req.UserGroupID, req.TunnelGroupID, time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\t_ = h.applyGroupPermission(req.UserGroupID, req.TunnelGroupID)\n\tresponse.WriteJSON(w, response.OK(\"权限分配成功\"))\n}\n\nfunc (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\n\tug, tg, exists, err := h.repo.GetGroupPermissionPairByIDTx(tx, id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tif err := h.repo.DeleteGroupPermissionByIDTx(tx, id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif exists {\n\t\tif err := h.repo.RevokeGroupPermissionPairTx(tx, ug, tg); err != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\n\tif err := tx.Commit().Error; err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupCreate(w http.ResponseWriter, r *http.Request, table string) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tname := asString(req[\"name\"])\n\tif name == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"分组名称不能为空\"))\n\t\treturn\n\t}\n\tnow := time.Now().UnixMilli()\n\tif err := h.repo.GroupCreate(table, name, asInt(req[\"status\"], 1), now); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupUpdate(w http.ResponseWriter, r *http.Request, table string) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"分组ID不能为空\"))\n\t\treturn\n\t}\n\tif err := h.repo.GroupUpdate(table, id, asString(req[\"name\"]), asInt(req[\"status\"], 1), time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupDelete(w http.ResponseWriter, r *http.Request, table string) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tif err := h.repo.GroupDeleteCascade(table, id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) applyGroupPermission(userGroupID, tunnelGroupID int64) error {\n\tuserIDs, _ := h.repo.ListUserIDsByUserGroup(userGroupID)\n\ttunnelIDs, _ := h.repo.ListTunnelIDsByTunnelGroup(tunnelGroupID)\n\tfor _, uid := range userIDs {\n\t\tfor _, tid := range tunnelIDs {\n\t\t\tutID, created, err := h.repo.EnsureUserTunnelGrant(uid, tid)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcreatedByGroup := 0\n\t\t\tif created {\n\t\t\t\tcreatedByGroup = 1\n\t\t\t}\n\t\t\th.repo.InsertGroupPermissionGrant(userGroupID, tunnelGroupID, utID, createdByGroup, time.Now().UnixMilli())\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) syncPermissionsByUserGroup(userGroupID int64) error {\n\tpairs, err := h.repo.ListGroupPermissionPairsByUserGroup(userGroupID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, p := range pairs {\n\t\t_ = h.applyGroupPermission(p[0], p[1])\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) syncPermissionsByTunnelGroup(tunnelGroupID int64) error {\n\tpairs, err := h.repo.ListGroupPermissionPairsByTunnelGroup(tunnelGroupID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, p := range pairs {\n\t\t_ = h.applyGroupPermission(p[0], p[1])\n\t}\n\treturn nil\n}\n\ntype tunnelRuntimeNode struct {\n\tNodeID int64\n\tProtocol string\n\tStrategy string\n\tInx int\n\tChainType int\n\tPort int\n\tConnectIP string\n}\n\ntype tunnelCreateState struct {\n\tTunnelID int64\n\tType int\n\tIPPreference string // \"\" = auto, \"v4\" = prefer IPv4, \"v6\" = prefer IPv6\n\tInNodes []tunnelRuntimeNode\n\tChainHops [][]tunnelRuntimeNode\n\tOutNodes []tunnelRuntimeNode\n\tNodes map[int64]*nodeRecord\n\tNodeIDList []int64\n}\n\nfunc (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) {\n\tstate := &tunnelCreateState{\n\t\tType: tunnelType,\n\t\tInNodes: make([]tunnelRuntimeNode, 0),\n\t\tChainHops: make([][]tunnelRuntimeNode, 0),\n\t\tOutNodes: make([]tunnelRuntimeNode, 0),\n\t\tNodes: make(map[int64]*nodeRecord),\n\t}\n\tnodeIDs := make([]int64, 0)\n\n\tfor _, item := range asMapSlice(req[\"inNodeId\"]) {\n\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\tif nodeID <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tnodeIDs = append(nodeIDs, nodeID)\n\t\tstate.InNodes = append(state.InNodes, tunnelRuntimeNode{\n\t\t\tNodeID: nodeID,\n\t\t\tProtocol: defaultString(asString(item[\"protocol\"]), \"tls\"),\n\t\t\tStrategy: defaultString(asString(item[\"strategy\"]), \"round\"),\n\t\t\tChainType: 1,\n\t\t})\n\t}\n\tif len(state.InNodes) == 0 {\n\t\treturn nil, errors.New(\"入口不能为空\")\n\t}\n\n\tif tunnelType == 2 {\n\t\toutNodesRaw := asMapSlice(req[\"outNodeId\"])\n\t\tif len(outNodesRaw) == 0 {\n\t\t\treturn nil, errors.New(\"出口不能为空\")\n\t\t}\n\n\t\tallocated := map[int64]int{}\n\t\tfor _, item := range outNodesRaw {\n\t\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\t\tif nodeID <= 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tnodeIDs = append(nodeIDs, nodeID)\n\t\t\tport := asInt(item[\"port\"], 0)\n\t\t\tif port <= 0 {\n\t\t\t\tisRemote, remoteErr := h.repo.IsRemoteNodeTx(tx, nodeID)\n\t\t\t\tif remoteErr != nil {\n\t\t\t\t\treturn nil, remoteErr\n\t\t\t\t}\n\t\t\t\tif !isRemote {\n\t\t\t\t\tvar err error\n\t\t\t\t\tport, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn nil, err\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tstate.OutNodes = append(state.OutNodes, tunnelRuntimeNode{\n\t\t\t\tNodeID: nodeID,\n\t\t\t\tProtocol: defaultString(asString(item[\"protocol\"]), \"tls\"),\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\t\t\treturn nil, errors.New(\"出口不能为空\")\n\t\t}\n\n\t\tfor hopIdx, hopRaw := range asAnySlice(req[\"chainNodes\"]) {\n\t\t\thop := make([]tunnelRuntimeNode, 0)\n\t\t\tfor _, item := range asMapSlice(hopRaw) {\n\t\t\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\t\t\tif nodeID <= 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tnodeIDs = append(nodeIDs, nodeID)\n\t\t\t\tport := asInt(item[\"port\"], 0)\n\t\t\t\tif port <= 0 {\n\t\t\t\t\tisRemote, remoteErr := h.repo.IsRemoteNodeTx(tx, nodeID)\n\t\t\t\t\tif remoteErr != nil {\n\t\t\t\t\t\treturn nil, remoteErr\n\t\t\t\t\t}\n\t\t\t\t\tif !isRemote {\n\t\t\t\t\t\tvar err error\n\t\t\t\t\t\tport, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\treturn nil, err\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thop = append(hop, tunnelRuntimeNode{\n\t\t\t\t\tNodeID: nodeID,\n\t\t\t\t\tProtocol: defaultString(asString(item[\"protocol\"]), \"tls\"),\n\t\t\t\t\tStrategy: defaultString(asString(item[\"strategy\"]), \"round\"),\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\t\t\t\tstate.ChainHops = append(state.ChainHops, hop)\n\t\t\t}\n\t\t}\n\t}\n\n\tseen := make(map[int64]struct{}, len(nodeIDs))\n\tfor _, nodeID := range nodeIDs {\n\t\tif _, ok := seen[nodeID]; ok {\n\t\t\treturn nil, errors.New(\"节点重复\")\n\t\t}\n\t\tseen[nodeID] = struct{}{}\n\t\tstate.NodeIDList = append(state.NodeIDList, nodeID)\n\t\tnode, err := h.repo.GetNodeRecordTx(tx, nodeID)\n\t\tif err != nil {\n\t\t\tif strings.Contains(err.Error(), \"不存在\") {\n\t\t\t\treturn nil, errors.New(\"节点不存在\")\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tif node == nil {\n\t\t\treturn nil, errors.New(\"节点不存在\")\n\t\t}\n\t\tif node.IsRemote != 1 && node.Status != 1 {\n\t\t\treturn nil, errors.New(\"部分节点不在线\")\n\t\t}\n\t\tstate.Nodes[nodeID] = node\n\t}\n\n\tfor _, outNode := range state.OutNodes {\n\t\tif err := validateRemoteNodePort(state.Nodes[outNode.NodeID], outNode.Port); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tfor _, hop := range state.ChainHops {\n\t\tfor _, chainNode := range hop {\n\t\t\tif err := validateRemoteNodePort(state.Nodes[chainNode.NodeID], chainNode.Port); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn state, nil\n}\n\nfunc buildTunnelInIP(inNodes []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) string {\n\tset := make(map[string]struct{})\n\tordered := make([]string, 0)\n\tpreferV6 := strings.TrimSpace(ipPreference) == \"v6\"\n\tfor _, inNode := range inNodes {\n\t\tnode := nodes[inNode.NodeID]\n\t\tif node == nil {\n\t\t\tcontinue\n\t\t}\n\t\tv4 := strings.TrimSpace(node.ServerIPv4)\n\t\tv6 := strings.TrimSpace(node.ServerIPv6)\n\t\tvar addrs []string\n\t\tif preferV6 {\n\t\t\tif v6 != \"\" {\n\t\t\t\taddrs = append(addrs, v6)\n\t\t\t}\n\t\t\tif v4 != \"\" {\n\t\t\t\taddrs = append(addrs, v4)\n\t\t\t}\n\t\t} else {\n\t\t\tif v4 != \"\" {\n\t\t\t\taddrs = append(addrs, v4)\n\t\t\t}\n\t\t\tif v6 != \"\" {\n\t\t\t\taddrs = append(addrs, v6)\n\t\t\t}\n\t\t}\n\t\tif len(addrs) == 0 {\n\t\t\tif v := strings.TrimSpace(node.ServerIP); v != \"\" {\n\t\t\t\taddrs = append(addrs, v)\n\t\t\t}\n\t\t}\n\t\tfor _, a := range addrs {\n\t\t\tif _, ok := set[a]; !ok {\n\t\t\t\tset[a] = struct{}{}\n\t\t\t\tordered = append(ordered, a)\n\t\t\t}\n\t\t}\n\t}\n\treturn strings.Join(ordered, \",\")\n}\n\nfunc applyTunnelPortsToRequest(req map[string]interface{}, state *tunnelCreateState) {\n\tif req == nil || state == nil {\n\t\treturn\n\t}\n\toutPorts := make(map[int64]int)\n\tfor _, n := range state.OutNodes {\n\t\toutPorts[n.NodeID] = n.Port\n\t}\n\tfor _, item := range asMapSlice(req[\"outNodeId\"]) {\n\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\tif port, ok := outPorts[nodeID]; ok && port > 0 {\n\t\t\titem[\"port\"] = port\n\t\t}\n\t}\n\n\tchainPorts := make(map[int64]int)\n\tfor _, hop := range state.ChainHops {\n\t\tfor _, n := range hop {\n\t\t\tchainPorts[n.NodeID] = n.Port\n\t\t}\n\t}\n\tfor _, hopRaw := range asAnySlice(req[\"chainNodes\"]) {\n\t\tfor _, item := range asMapSlice(hopRaw) {\n\t\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\t\tif port, ok := chainPorts[nodeID]; ok && port > 0 {\n\t\t\t\titem[\"port\"] = port\n\t\t\t}\n\t\t}\n\t}\n}\n\ntype federationRuntimeReleaseRef struct {\n\tRemoteURL string\n\tRemoteToken string\n\tBindingID string\n\tReservationID string\n\tResourceKey string\n}\n\nfunc federationRuntimeResourceKey(tunnelID int64, nodeID int64, chainType int, hopInx int) string {\n\treturn fmt.Sprintf(\"tunnel:%d:node:%d:type:%d:hop:%d\", tunnelID, nodeID, chainType, hopInx)\n}\n\nfunc remoteShareIDFromConfig(raw string) int64 {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn 0\n\t}\n\tvar cfg map[string]interface{}\n\tif err := json.Unmarshal([]byte(raw), &cfg); err != nil {\n\t\treturn 0\n\t}\n\treturn asInt64(cfg[\"shareId\"], 0)\n}\n\nfunc (h *Handler) federationLocalDomain() string {\n\tcfg, _ := h.repo.GetConfigByName(\"panel_domain\")\n\tif cfg == nil {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(cfg.Value)\n}\n\nfunc (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain string) ([]repo.FederationTunnelBinding, []federationRuntimeReleaseRef, error) {\n\tbindings := make([]repo.FederationTunnelBinding, 0)\n\treleaseRefs := make([]federationRuntimeReleaseRef, 0)\n\tif h == nil || state == nil {\n\t\treturn bindings, releaseRefs, nil\n\t}\n\tfc := client.NewFederationClient()\n\tnow := time.Now().UnixMilli()\n\n\tfor outIdx := range state.OutNodes {\n\t\toutNode := state.OutNodes[outIdx]\n\t\tnode := state.Nodes[outNode.NodeID]\n\t\tif node == nil || node.IsRemote != 1 {\n\t\t\tcontinue\n\t\t}\n\t\tremoteURL := strings.TrimSpace(node.RemoteURL)\n\t\tremoteToken := strings.TrimSpace(node.RemoteToken)\n\t\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 缺少共享配置\", nodeDisplayName(node))\n\t\t}\n\n\t\tresourceKey := federationRuntimeResourceKey(state.TunnelID, outNode.NodeID, 3, 0)\n\t\treserveReq := client.RuntimeReservePortRequest{\n\t\t\tResourceKey: resourceKey,\n\t\t\tProtocol: defaultString(outNode.Protocol, \"tls\"),\n\t\t\tRequestedPort: outNode.Port,\n\t\t}\n\t\treserveRes, err := fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n\t\tif err != nil && reserveReq.RequestedPort > 0 {\n\t\t\treserveReq.RequestedPort = 0\n\t\t\treserveRes, err = fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n\t\t}\n\t\tif err != nil {\n\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 端口分配失败: %w\", nodeDisplayName(node), err)\n\t\t}\n\n\t\tstate.OutNodes[outIdx].Port = reserveRes.AllocatedPort\n\t\toutNode = state.OutNodes[outIdx]\n\n\t\tapplyReq := client.RuntimeApplyRoleRequest{\n\t\t\tReservationID: reserveRes.ReservationID,\n\t\t\tResourceKey: resourceKey,\n\t\t\tRole: \"exit\",\n\t\t\tProtocol: defaultString(outNode.Protocol, \"tls\"),\n\t\t\tStrategy: defaultString(outNode.Strategy, \"round\"),\n\t\t}\n\t\tapplyRes, err := fc.ApplyRole(remoteURL, remoteToken, localDomain, applyReq)\n\t\tif err != nil {\n\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 运行时下发失败: %w\", nodeDisplayName(node), err)\n\t\t}\n\t\tif applyRes.AllocatedPort > 0 {\n\t\t\tstate.OutNodes[outIdx].Port = applyRes.AllocatedPort\n\t\t\toutNode = state.OutNodes[outIdx]\n\t\t}\n\n\t\tbindings = append(bindings, repo.FederationTunnelBinding{\n\t\t\tTunnelID: state.TunnelID,\n\t\t\tNodeID: outNode.NodeID,\n\t\t\tChainType: 3,\n\t\t\tHopInx: 0,\n\t\t\tRemoteURL: remoteURL,\n\t\t\tResourceKey: resourceKey,\n\t\t\tRemoteBindingID: defaultString(applyRes.BindingID, reserveRes.BindingID),\n\t\t\tAllocatedPort: outNode.Port,\n\t\t\tStatus: 1,\n\t\t\tCreatedTime: now,\n\t\t\tUpdatedTime: now,\n\t\t})\n\t\treleaseRefs = append(releaseRefs, federationRuntimeReleaseRef{\n\t\t\tRemoteURL: remoteURL,\n\t\t\tRemoteToken: remoteToken,\n\t\t\tBindingID: applyRes.BindingID,\n\t\t\tReservationID: reserveRes.ReservationID,\n\t\t\tResourceKey: resourceKey,\n\t\t})\n\t}\n\n\tfor hopIdx := len(state.ChainHops) - 1; hopIdx >= 0; hopIdx-- {\n\t\tfor nodeIdx := range state.ChainHops[hopIdx] {\n\t\t\tchainNode := state.ChainHops[hopIdx][nodeIdx]\n\t\t\tnode := state.Nodes[chainNode.NodeID]\n\t\t\tif node == nil || node.IsRemote != 1 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tremoteURL := strings.TrimSpace(node.RemoteURL)\n\t\t\tremoteToken := strings.TrimSpace(node.RemoteToken)\n\t\t\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 缺少共享配置\", nodeDisplayName(node))\n\t\t\t}\n\n\t\t\tresourceKey := federationRuntimeResourceKey(state.TunnelID, chainNode.NodeID, 2, hopIdx+1)\n\t\t\treserveReq := client.RuntimeReservePortRequest{\n\t\t\t\tResourceKey: resourceKey,\n\t\t\t\tProtocol: defaultString(chainNode.Protocol, \"tls\"),\n\t\t\t\tRequestedPort: chainNode.Port,\n\t\t\t}\n\t\t\treserveRes, err := fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n\t\t\tif err != nil && reserveReq.RequestedPort > 0 {\n\t\t\t\treserveReq.RequestedPort = 0\n\t\t\t\treserveRes, err = fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 端口分配失败: %w\", nodeDisplayName(node), err)\n\t\t\t}\n\n\t\t\tstate.ChainHops[hopIdx][nodeIdx].Port = reserveRes.AllocatedPort\n\t\t\tchainNode = state.ChainHops[hopIdx][nodeIdx]\n\n\t\t\tnextTargets := state.OutNodes\n\t\t\tif hopIdx+1 < len(state.ChainHops) {\n\t\t\t\tnextTargets = state.ChainHops[hopIdx+1]\n\t\t\t}\n\t\t\tapplyTargets := make([]client.RuntimeTarget, 0, len(nextTargets))\n\t\t\tfor _, target := range nextTargets {\n\t\t\t\ttargetNode := state.Nodes[target.NodeID]\n\t\t\t\tif targetNode == nil {\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, 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\t\t\t\t}\n\t\t\t\tif target.Port <= 0 {\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\tapplyTargets = append(applyTargets, client.RuntimeTarget{\n\t\t\t\t\tHost: host,\n\t\t\t\t\tPort: target.Port,\n\t\t\t\t\tProtocol: defaultString(target.Protocol, \"tls\"),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tapplyReq := client.RuntimeApplyRoleRequest{\n\t\t\t\tReservationID: reserveRes.ReservationID,\n\t\t\t\tResourceKey: resourceKey,\n\t\t\t\tRole: \"middle\",\n\t\t\t\tProtocol: defaultString(chainNode.Protocol, \"tls\"),\n\t\t\t\tStrategy: defaultString(chainNode.Strategy, \"round\"),\n\t\t\t\tTargets: applyTargets,\n\t\t\t}\n\t\t\tapplyRes, err := fc.ApplyRole(remoteURL, remoteToken, localDomain, applyReq)\n\t\t\tif err != nil {\n\t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 运行时下发失败: %w\", nodeDisplayName(node), err)\n\t\t\t}\n\t\t\tif applyRes.AllocatedPort > 0 {\n\t\t\t\tstate.ChainHops[hopIdx][nodeIdx].Port = applyRes.AllocatedPort\n\t\t\t\tchainNode = state.ChainHops[hopIdx][nodeIdx]\n\t\t\t}\n\n\t\t\tbindings = append(bindings, repo.FederationTunnelBinding{\n\t\t\t\tTunnelID: state.TunnelID,\n\t\t\t\tNodeID: chainNode.NodeID,\n\t\t\t\tChainType: 2,\n\t\t\t\tHopInx: hopIdx + 1,\n\t\t\t\tRemoteURL: remoteURL,\n\t\t\t\tResourceKey: resourceKey,\n\t\t\t\tRemoteBindingID: defaultString(applyRes.BindingID, reserveRes.BindingID),\n\t\t\t\tAllocatedPort: chainNode.Port,\n\t\t\t\tStatus: 1,\n\t\t\t\tCreatedTime: now,\n\t\t\t\tUpdatedTime: now,\n\t\t\t})\n\t\t\treleaseRefs = append(releaseRefs, federationRuntimeReleaseRef{\n\t\t\t\tRemoteURL: remoteURL,\n\t\t\t\tRemoteToken: remoteToken,\n\t\t\t\tBindingID: applyRes.BindingID,\n\t\t\t\tReservationID: reserveRes.ReservationID,\n\t\t\t\tResourceKey: resourceKey,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn bindings, releaseRefs, nil\n}\n\nfunc (h *Handler) releaseFederationRuntimeRefs(refs []federationRuntimeReleaseRef) {\n\tif h == nil || len(refs) == 0 {\n\t\treturn\n\t}\n\tfc := client.NewFederationClient()\n\tlocalDomain := h.federationLocalDomain()\n\tfor i := len(refs) - 1; i >= 0; i-- {\n\t\tref := refs[i]\n\t\tif strings.TrimSpace(ref.RemoteURL) == \"\" || strings.TrimSpace(ref.RemoteToken) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\treq := client.RuntimeReleaseRoleRequest{\n\t\t\tBindingID: ref.BindingID,\n\t\t\tReservationID: ref.ReservationID,\n\t\t\tResourceKey: ref.ResourceKey,\n\t\t}\n\t\t_ = fc.ReleaseRole(ref.RemoteURL, ref.RemoteToken, localDomain, req)\n\t}\n}\n\nfunc (h *Handler) cleanupFederationRuntime(tunnelID int64) {\n\tif h == nil || tunnelID <= 0 {\n\t\treturn\n\t}\n\tbindings, err := h.repo.ListActiveFederationTunnelBindingsByTunnel(tunnelID)\n\tif err != nil || len(bindings) == 0 {\n\t\treturn\n\t}\n\n\tfc := client.NewFederationClient()\n\tlocalDomain := h.federationLocalDomain()\n\tfor _, b := range bindings {\n\t\tnode, nodeErr := h.repo.GetNodeByID(b.NodeID)\n\t\tif nodeErr != nil || node == nil {\n\t\t\tcontinue\n\t\t}\n\t\tremoteURL := strings.TrimSpace(node.RemoteURL.String)\n\t\tif remoteURL == \"\" {\n\t\t\tremoteURL = strings.TrimSpace(b.RemoteURL)\n\t\t}\n\t\tremoteToken := strings.TrimSpace(node.RemoteToken.String)\n\t\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\treq := client.RuntimeReleaseRoleRequest{\n\t\t\tBindingID: strings.TrimSpace(b.RemoteBindingID),\n\t\t\tResourceKey: strings.TrimSpace(b.ResourceKey),\n\t\t}\n\t\t_ = fc.ReleaseRole(remoteURL, remoteToken, localDomain, req)\n\t}\n\t_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)\n}\n\nfunc (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64, error) {\n\tif h == nil || state == nil {\n\t\treturn nil, nil, errors.New(\"invalid tunnel runtime state\")\n\t}\n\tcreatedChains := make([]int64, 0)\n\tcreatedServices := make([]int64, 0)\n\tif state.Type != 2 {\n\t\treturn createdChains, createdServices, nil\n\t}\n\n\tfor _, inNode := range state.InNodes {\n\t\tnode := state.Nodes[inNode.NodeID]\n\t\ttargets := state.OutNodes\n\t\tif len(state.ChainHops) > 0 {\n\t\t\ttargets = state.ChainHops[0]\n\t\t}\n\t\tchainData, err := buildTunnelChainConfig(state.TunnelID, inNode.NodeID, targets, state.Nodes, state.IPPreference)\n\t\tif err != nil {\n\t\t\treturn createdChains, createdServices, err\n\t\t}\n\t\tif _, err := h.sendNodeCommand(inNode.NodeID, \"AddChains\", chainData, true, false); err != nil {\n\t\t\tif node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn createdChains, createdServices, fmt.Errorf(\"入口节点 %s 下发转发链失败: %w\", nodeDisplayName(state.Nodes[inNode.NodeID]), err)\n\t\t}\n\t\tcreatedChains = append(createdChains, inNode.NodeID)\n\t}\n\n\tfor i, hop := range state.ChainHops {\n\t\tnextTargets := state.OutNodes\n\t\tif i+1 < len(state.ChainHops) {\n\t\t\tnextTargets = state.ChainHops[i+1]\n\t\t}\n\t\tfor _, chainNode := range hop {\n\t\t\tif node := state.Nodes[chainNode.NodeID]; node != nil && node.IsRemote == 1 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tchainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)\n\t\t\tif err != nil {\n\t\t\t\treturn createdChains, createdServices, err\n\t\t\t}\n\t\t\tif _, err := h.sendNodeCommand(chainNode.NodeID, \"AddChains\", chainData, true, false); err != nil {\n\t\t\t\treturn createdChains, createdServices, fmt.Errorf(\"转发链节点 %s 下发转发链失败: %w\", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)\n\t\t\t}\n\t\t\tcreatedChains = append(createdChains, chainNode.NodeID)\n\n\t\t\tserviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID])\n\t\t\tif _, err := h.sendNodeCommand(chainNode.NodeID, \"AddService\", serviceData, true, false); err != nil {\n\t\t\t\treturn createdChains, createdServices, fmt.Errorf(\"转发链节点 %s 下发服务失败: %w\", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)\n\t\t\t}\n\t\t\tcreatedServices = append(createdServices, chainNode.NodeID)\n\t\t}\n\t}\n\n\tfor _, outNode := range state.OutNodes {\n\t\tif node := state.Nodes[outNode.NodeID]; node != nil && node.IsRemote == 1 {\n\t\t\tcontinue\n\t\t}\n\t\tserviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID])\n\t\tif _, err := h.sendNodeCommand(outNode.NodeID, \"AddService\", serviceData, true, false); err != nil {\n\t\t\treturn createdChains, createdServices, fmt.Errorf(\"出口节点 %s 下发服务失败: %w\", nodeDisplayName(state.Nodes[outNode.NodeID]), err)\n\t\t}\n\t\tcreatedServices = append(createdServices, outNode.NodeID)\n\t}\n\n\treturn createdChains, createdServices, nil\n}\n\nfunc (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tunnelID int64) {\n\tif h == nil || tunnelID <= 0 {\n\t\treturn\n\t}\n\tseenServices := make(map[int64]struct{})\n\tserviceName := fmt.Sprintf(\"%d_tls\", tunnelID)\n\tfor i := len(serviceNodeIDs) - 1; i >= 0; i-- {\n\t\tnodeID := serviceNodeIDs[i]\n\t\tif _, ok := seenServices[nodeID]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseenServices[nodeID] = struct{}{}\n\t\t_, _ = h.sendNodeCommand(nodeID, \"DeleteService\", map[string]interface{}{\"services\": []string{serviceName}}, false, true)\n\t}\n\n\tseenChains := make(map[int64]struct{})\n\tchainName := fmt.Sprintf(\"chains_%d\", tunnelID)\n\tfor i := len(chainNodeIDs) - 1; i >= 0; i-- {\n\t\tnodeID := chainNodeIDs[i]\n\t\tif _, ok := seenChains[nodeID]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseenChains[nodeID] = struct{}{}\n\t\t_, _ = h.sendNodeCommand(nodeID, \"DeleteChains\", map[string]interface{}{\"chain\": chainName}, false, true)\n\t}\n}\n\nfunc shouldDeferTunnelRuntimeApplyError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tmsg := strings.ToLower(strings.TrimSpace(err.Error()))\n\tif msg == \"\" {\n\t\treturn false\n\t}\n\tif strings.Contains(msg, \"节点不在线\") {\n\t\treturn true\n\t}\n\tif strings.Contains(msg, \"等待节点响应超时\") || strings.Contains(msg, \"timeout\") || strings.Contains(msg, \"超时\") {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) (map[string]interface{}, error) {\n\tfromNode := nodes[fromNodeID]\n\tif fromNode == nil {\n\t\treturn nil, errors.New(\"节点不存在\")\n\t}\n\tif len(targets) == 0 {\n\t\treturn nil, errors.New(\"转发链目标不能为空\")\n\t}\n\tnodeItems := make([]map[string]interface{}, 0, len(targets))\n\tfor idx, target := range targets {\n\t\ttargetNode := nodes[target.NodeID]\n\t\tif targetNode == nil {\n\t\t\treturn nil, errors.New(\"节点不存在\")\n\t\t}\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\t\tport := target.Port\n\t\tif port <= 0 {\n\t\t\treturn nil, errors.New(\"节点端口不能为空\")\n\t\t}\n\t\tprotocol := defaultString(target.Protocol, \"tls\")\n\t\tconnector := map[string]interface{}{\n\t\t\t\"type\": \"relay\",\n\t\t}\n\t\tif isTLSTunnelProtocol(protocol) {\n\t\t\tconnector[\"metadata\"] = map[string]interface{}{\"nodelay\": true}\n\t\t}\n\t\tnodeItems = append(nodeItems, map[string]interface{}{\n\t\t\t\"name\": fmt.Sprintf(\"node_%d\", idx+1),\n\t\t\t\"addr\": processServerAddress(fmt.Sprintf(\"%s:%d\", host, port)),\n\t\t\t\"connector\": connector,\n\t\t\t\"dialer\": map[string]interface{}{\n\t\t\t\t\"type\": protocol,\n\t\t\t},\n\t\t})\n\t}\n\n\tstrategy := defaultString(strings.TrimSpace(targets[0].Strategy), \"round\")\n\thop := map[string]interface{}{\n\t\t\"name\": fmt.Sprintf(\"hop_%d\", tunnelID),\n\t\t\"selector\": map[string]interface{}{\n\t\t\t\"strategy\": strategy,\n\t\t\t\"maxFails\": 1,\n\t\t\t\"failTimeout\": int64(600000000000),\n\t\t},\n\t\t\"nodes\": nodeItems,\n\t}\n\tif strings.TrimSpace(fromNode.InterfaceName) != \"\" {\n\t\thop[\"interface\"] = fromNode.InterfaceName\n\t}\n\n\treturn map[string]interface{}{\n\t\t\"name\": fmt.Sprintf(\"chains_%d\", tunnelID),\n\t\t\"hops\": []map[string]interface{}{hop},\n\t}, nil\n}\n\nfunc buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode, node *nodeRecord) []map[string]interface{} {\n\tif node == nil {\n\t\treturn nil\n\t}\n\tprotocol := defaultString(chainNode.Protocol, \"tls\")\n\thandlerCfg := map[string]interface{}{\n\t\t\"type\": \"relay\",\n\t}\n\tif isTLSTunnelProtocol(protocol) {\n\t\thandlerCfg[\"metadata\"] = map[string]interface{}{\"nodelay\": true}\n\t}\n\tservice := map[string]interface{}{\n\t\t\"name\": fmt.Sprintf(\"%d_tls\", tunnelID),\n\t\t\"addr\": processServerAddress(fmt.Sprintf(\"%s:%d\", defaultString(strings.TrimSpace(chainNode.ConnectIP), node.TCPListenAddr), chainNode.Port)),\n\t\t\"handler\": handlerCfg,\n\t\t\"listener\": map[string]interface{}{\n\t\t\t\"type\": protocol,\n\t\t},\n\t}\n\tif chainNode.ChainType == 2 {\n\t\tservice[\"handler\"].(map[string]interface{})[\"chain\"] = fmt.Sprintf(\"chains_%d\", tunnelID)\n\t}\n\tif chainNode.ChainType == 3 && strings.TrimSpace(node.InterfaceName) != \"\" {\n\t\tservice[\"metadata\"] = map[string]interface{}{\"interface\": node.InterfaceName}\n\t}\n\treturn []map[string]interface{}{service}\n}\n\nfunc 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\ttoV6 := nodeSupportsV6(toNode)\n\n\tswitch strings.TrimSpace(ipPreference) {\n\tcase \"v6\":\n\t\tif fromV6 && toV6 {\n\t\t\tif host := pickNodeAddressV6(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\t\tif fromV4 && toV4 {\n\t\t\tif host := pickNodeAddressV4(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\tcase \"v4\":\n\t\tif fromV4 && toV4 {\n\t\t\tif host := pickNodeAddressV4(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\t\tif fromV6 && toV6 {\n\t\t\tif host := pickNodeAddressV6(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\tdefault:\n\t\tif fromV4 && toV4 {\n\t\t\tif host := pickNodeAddressV4(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\t\tif fromV6 && toV6 {\n\t\t\tif host := pickNodeAddressV6(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"节点链路不兼容:%s(v4=%t,v6=%t) -> %s(v4=%t,v6=%t)\", nodeDisplayName(fromNode), fromV4, fromV6, nodeDisplayName(toNode), toV4, toV6)\n}\n\nfunc nodeDisplayName(node *nodeRecord) string {\n\tif node == nil {\n\t\treturn \"node\"\n\t}\n\tif strings.TrimSpace(node.Name) != \"\" {\n\t\treturn strings.TrimSpace(node.Name)\n\t}\n\treturn fmt.Sprintf(\"node_%d\", node.ID)\n}\n\nfunc isTLSTunnelProtocol(protocol string) bool {\n\treturn strings.EqualFold(strings.TrimSpace(defaultString(protocol, \"tls\")), \"tls\")\n}\n\nfunc nodeSupportsV4(node *nodeRecord) bool {\n\tif node == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(node.ServerIPv4) != \"\" {\n\t\treturn true\n\t}\n\tif strings.TrimSpace(node.ServerIPv6) != \"\" {\n\t\treturn false\n\t}\n\tlegacy := strings.Trim(strings.TrimSpace(node.ServerIP), \"[]\")\n\tif legacy == \"\" {\n\t\treturn false\n\t}\n\tif ip := net.ParseIP(legacy); ip != nil {\n\t\treturn ip.To4() != nil\n\t}\n\treturn true\n}\n\nfunc nodeSupportsV6(node *nodeRecord) bool {\n\tif node == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(node.ServerIPv6) != \"\" {\n\t\treturn true\n\t}\n\tif strings.TrimSpace(node.ServerIPv4) != \"\" {\n\t\treturn false\n\t}\n\tlegacy := strings.Trim(strings.TrimSpace(node.ServerIP), \"[]\")\n\tif legacy == \"\" {\n\t\treturn false\n\t}\n\tif ip := net.ParseIP(legacy); ip != nil {\n\t\treturn ip.To4() == nil\n\t}\n\treturn true\n}\n\nfunc pickNodeAddressV4(node *nodeRecord) string {\n\tif node == nil {\n\t\treturn \"\"\n\t}\n\tif v := strings.TrimSpace(node.ServerIPv4); v != \"\" {\n\t\treturn v\n\t}\n\treturn strings.TrimSpace(node.ServerIP)\n}\n\nfunc pickNodeAddressV6(node *nodeRecord) string {\n\tif node == nil {\n\t\treturn \"\"\n\t}\n\tif v := strings.TrimSpace(node.ServerIPv6); v != \"\" {\n\t\treturn v\n\t}\n\treturn strings.TrimSpace(node.ServerIP)\n}\n\nfunc (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[string]interface{}) error {\n\tallocated := map[int64]int{}\n\tinNodes := asMapSlice(req[\"inNodeId\"])\n\tfor i, n := range inNodes {\n\t\tnodeID := asInt64(n[\"nodeId\"], 0)\n\t\tif nodeID <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.repo.CreateChainTunnelTx(\n\t\t\ttx,\n\t\t\ttunnelID,\n\t\t\t\"1\",\n\t\t\tnodeID,\n\t\t\tsql.NullInt64{},\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\t}\n\tfor i, n := range asMapSlice(req[\"outNodeId\"]) {\n\t\tnodeID := asInt64(n[\"nodeId\"], 0)\n\t\tif nodeID <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tport := asInt(n[\"port\"], 0)\n\t\tif port <= 0 {\n\t\t\tvar pickErr error\n\t\t\tport, pickErr = h.repo.PickNodePortTx(tx, nodeID, allocated, 0)\n\t\t\tif pickErr != nil {\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\t\t\t\"3\",\n\t\t\tnodeID,\n\t\t\tsql.NullInt64{Int64: int64(port), Valid: true},\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\t}\n\tchainNodes := asAnySlice(req[\"chainNodes\"])\n\tfor i, grp := range chainNodes {\n\t\tfor _, n := range asMapSlice(grp) {\n\t\t\tnodeID := asInt64(n[\"nodeId\"], 0)\n\t\t\tif nodeID <= 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tport := asInt(n[\"port\"], 0)\n\t\t\tif port <= 0 {\n\t\t\t\tvar pickErr error\n\t\t\t\tport, pickErr = h.repo.PickNodePortTx(tx, nodeID, allocated, 0)\n\t\t\t\tif pickErr != nil {\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\t\t\t\t\"2\",\n\t\t\t\tnodeID,\n\t\t\t\tsql.NullInt64{Int64: int64(port), Valid: true},\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\t\t}\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) deleteNodeByID(id int64) error {\n\treturn h.repo.DeleteNodeCascade(id)\n}\n\nfunc (h *Handler) deleteTunnelByID(id int64) error {\n\tif h == nil || h.repo == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\n\ttunnelName, _ := h.repo.GetTunnelName(id)\n\tif err := h.repo.DeleteTunnelCascade(id); err != nil {\n\t\treturn err\n\t}\n\n\tshareID, port, ok := parsePeerShareInfoFromFederationTunnelName(tunnelName)\n\tif !ok {\n\t\treturn nil\n\t}\n\n\treturn h.repo.MarkPeerShareRuntimeReleasedByPort(shareID, port, time.Now().UnixMilli())\n}\n\nfunc (h *Handler) deleteForwardByID(id int64) error {\n\treturn h.repo.DeleteForwardCascade(id)\n}\n\nfunc (h *Handler) batchForwardDelete(ids []int64) (int, int) {\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tif err := h.deleteForwardByID(id); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\treturn s, f\n}\n\nfunc (h *Handler) batchForwardStatus(ids []int64, status int) (int, int) {\n\treturn h.repo.BatchUpdateForwardStatus(ids, status)\n}\n\nfunc (h *Handler) tunnelEntryNodeIDs(tunnelID int64) ([]int64, error) {\n\treturn h.repo.TunnelEntryNodeIDs(tunnelID)\n}\n\nfunc (h *Handler) pickTunnelPort(tunnelID int64) int {\n\tentryNodes, err := h.tunnelEntryNodeIDs(tunnelID)\n\tif err != nil || len(entryNodes) == 0 {\n\t\treturn 10000\n\t}\n\n\tvar commonAvailable []int\n\tfirstNode := true\n\n\tfor _, nodeID := range entryNodes {\n\t\tportRange, err := h.repo.GetNodePortRange(nodeID)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif portRange == \"\" {\n\t\t\tportRange = \"1000-65535\"\n\t\t}\n\n\t\tnodePorts, err := parsePorts(portRange)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tused, err := h.getUsedPorts(nodeID)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar available []int\n\t\tfor _, p := range nodePorts {\n\t\t\tif !used[p] {\n\t\t\t\tavailable = append(available, p)\n\t\t\t}\n\t\t}\n\n\t\tif firstNode {\n\t\t\tcommonAvailable = available\n\t\t\tfirstNode = false\n\t\t} else {\n\t\t\tset := make(map[int]bool)\n\t\t\tfor _, p := range available {\n\t\t\t\tset[p] = true\n\t\t\t}\n\t\t\tvar newCommon []int\n\t\t\tfor _, p := range commonAvailable {\n\t\t\t\tif set[p] {\n\t\t\t\t\tnewCommon = append(newCommon, p)\n\t\t\t\t}\n\t\t\t}\n\t\t\tcommonAvailable = newCommon\n\t\t}\n\n\t\tif len(commonAvailable) == 0 {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif len(commonAvailable) > 0 {\n\t\tidx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(commonAvailable))))\n\t\treturn commonAvailable[idx.Int64()]\n\t}\n\n\treturn 10000\n}\n\nfunc (h *Handler) getUsedPorts(nodeID int64) (map[int]bool, error) {\n\treturn h.repo.GetUsedPortsOnNodeAsMap(nodeID)\n}\n\nfunc parsePorts(portRange string) ([]int, error) {\n\tvar ports []int\n\tparts := strings.Split(portRange, \",\")\n\tfor _, part := range parts {\n\t\tpart = strings.TrimSpace(part)\n\t\tif part == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.Contains(part, \"-\") {\n\t\t\trangeParts := strings.Split(part, \"-\")\n\t\t\tif len(rangeParts) != 2 {\n\t\t\t\treturn nil, errors.New(\"invalid port range format\")\n\t\t\t}\n\t\t\tstart, err1 := strconv.Atoi(strings.TrimSpace(rangeParts[0]))\n\t\t\tend, err2 := strconv.Atoi(strings.TrimSpace(rangeParts[1]))\n\t\t\tif err1 != nil || err2 != nil || start > end {\n\t\t\t\treturn nil, errors.New(\"invalid port range values\")\n\t\t\t}\n\t\t\tfor i := start; i <= end; i++ {\n\t\t\t\tports = append(ports, i)\n\t\t\t}\n\t\t} else {\n\t\t\tp, err := strconv.Atoi(part)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, errors.New(\"invalid port value\")\n\t\t\t}\n\t\t\tports = append(ports, p)\n\t\t}\n\t}\n\treturn ports, nil\n}\n\nfunc (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\t}\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\tInIP string\n\t\t}{NodeID: nid, Port: port, InIP: inIp}\n\t}\n\treturn h.repo.ReplaceForwardPorts(forwardID, entries)\n}\n\nfunc (h *Handler) replaceForwardPortsWithRecords(forwardID int64, ports []forwardPortRecord) error {\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\tInIP string\n\t\t}{NodeID: fp.NodeID, Port: fp.Port, InIP: fp.InIP}\n\t}\n\treturn h.repo.ReplaceForwardPorts(forwardID, entries)\n}\n\nfunc (h *Handler) rollbackForwardMutation(oldForward *forwardRecord, oldPorts []forwardPortRecord) {\n\tif h == nil || oldForward == nil || h.repo == nil {\n\t\treturn\n\t}\n\n\th.repo.RollbackForwardFields(\n\t\toldForward.ID, oldForward.UserID, oldForward.UserName, oldForward.Name,\n\t\toldForward.TunnelID, oldForward.RemoteAddr, oldForward.Strategy, oldForward.Status,\n\t\toldForward.SpeedID,\n\t\ttime.Now().UnixMilli(),\n\t)\n\n\tif err := h.replaceForwardPortsWithRecords(oldForward.ID, oldPorts); err != nil {\n\t\treturn\n\t}\n\n\t_ = h.syncForwardServices(oldForward, \"UpdateService\", true)\n}\n\nfunc (h *Handler) upsertUserTunnel(req map[string]interface{}) error {\n\tuserID := asInt64(req[\"userId\"], 0)\n\ttunnelID := asInt64(req[\"tunnelId\"], 0)\n\tif userID <= 0 || tunnelID <= 0 {\n\t\treturn fmt.Errorf(\"userId or tunnelId missing\")\n\t}\n\n\texistingID, currentFlow, currentNum, currentExpTime, currentFlowReset, currentSpeedID, currentStatus, err :=\n\t\th.repo.GetExistingUserTunnel(userID, tunnelID)\n\n\tspeedID := asAnyToInt64Ptr(req[\"speedId\"])\n\tif err := h.validateSpeedLimitReference(speedID); err != nil {\n\t\treturn err\n\t}\n\n\treqFlow := asInt64(req[\"flow\"], -1)\n\treqNum := asInt(req[\"num\"], -1)\n\treqExpTime := asInt64(req[\"expTime\"], -1)\n\treqFlowReset := asInt64(req[\"flowResetTime\"], -1)\n\treqStatus := asInt(req[\"status\"], -1)\n\n\tif err == sql.ErrNoRows {\n\t\tif reqFlow < 0 || reqNum < 0 || reqExpTime < 0 || reqFlowReset < 0 {\n\t\t\tuFlow, uNum, uExp, uReset, uErr := h.repo.GetUserDefaultsForTunnel(userID)\n\t\t\tif uErr == nil {\n\t\t\t\tif reqFlow < 0 {\n\t\t\t\t\treqFlow = uFlow\n\t\t\t\t}\n\t\t\t\tif reqNum < 0 {\n\t\t\t\t\treqNum = uNum\n\t\t\t\t}\n\t\t\t\tif reqExpTime < 0 {\n\t\t\t\t\treqExpTime = uExp\n\t\t\t\t}\n\t\t\t\tif reqFlowReset < 0 {\n\t\t\t\t\treqFlowReset = uReset\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif reqFlow < 0 {\n\t\t\treqFlow = 0\n\t\t}\n\t\tif reqNum < 0 {\n\t\t\treqNum = 0\n\t\t}\n\t\tif reqExpTime < 0 {\n\t\t\treqExpTime = time.Now().Add(365 * 24 * time.Hour).UnixMilli()\n\t\t}\n\t\tif reqFlowReset < 0 {\n\t\t\treqFlowReset = 1\n\t\t}\n\t\tif reqStatus < 0 {\n\t\t\treqStatus = 1\n\t\t}\n\n\t\tif err := h.repo.InsertUserTunnel(userID, tunnelID, nullableInt(speedID), reqNum, reqFlow, reqFlowReset, reqExpTime, reqStatus); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {\n\t\t\tinsertedID, _, _, _, _, _, _, lookupErr := h.repo.GetExistingUserTunnel(userID, tunnelID)\n\t\t\tif lookupErr != nil {\n\t\t\t\treturn fmt.Errorf(\"下发失败且回滚失败: %v; 回滚查询错误: %w\", syncErr, lookupErr)\n\t\t\t}\n\n\t\t\tif rollbackErr := h.repo.DeleteUserTunnel(insertedID); rollbackErr != nil {\n\t\t\t\treturn fmt.Errorf(\"下发失败且回滚失败: %v; 回滚删除错误: %w\", syncErr, rollbackErr)\n\t\t\t}\n\n\t\t\treturn fmt.Errorf(\"下发失败,已回滚: %w\", syncErr)\n\t\t}\n\n\t\treturn nil\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tnewFlow := currentFlow\n\tif reqFlow >= 0 {\n\t\tnewFlow = reqFlow\n\t}\n\n\tnewNum := int(currentNum)\n\tif reqNum >= 0 {\n\t\tnewNum = reqNum\n\t}\n\n\tnewExpTime := currentExpTime\n\tif reqExpTime >= 0 {\n\t\tnewExpTime = reqExpTime\n\t}\n\n\tnewFlowReset := currentFlowReset\n\tif reqFlowReset >= 0 {\n\t\tnewFlowReset = reqFlowReset\n\t}\n\n\tnewStatus := currentStatus\n\tif reqStatus >= 0 {\n\t\tnewStatus = reqStatus\n\t}\n\n\tnewSpeedID := currentSpeedID\n\tif speedID != nil {\n\t\tnewSpeedID = sql.NullInt64{Int64: *speedID, Valid: true}\n\t} else if _, ok := req[\"speedId\"]; ok {\n\t\tnewSpeedID = sql.NullInt64{Valid: false}\n\t}\n\n\tif err := h.repo.UpdateUserTunnelFields(existingID, newSpeedID, newFlow, newNum, newExpTime, newFlowReset, newStatus); err != nil {\n\t\treturn err\n\t}\n\n\tif syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {\n\t\trollbackErr := h.repo.UpdateUserTunnelFields(\n\t\t\texistingID,\n\t\t\tcurrentSpeedID,\n\t\t\tcurrentFlow,\n\t\t\tint(currentNum),\n\t\t\tcurrentExpTime,\n\t\t\tcurrentFlowReset,\n\t\t\tcurrentStatus,\n\t\t)\n\t\tif rollbackErr != nil {\n\t\t\treturn fmt.Errorf(\"下发失败且回滚失败: %v; 回滚错误: %w\", syncErr, rollbackErr)\n\t\t}\n\n\t\treturn fmt.Errorf(\"下发失败,已回滚: %w\", syncErr)\n\t}\n\n\treturn nil\n}\n\nfunc (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) error {\n\tforwards, err := h.listForwardsByTunnel(tunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor i := range forwards {\n\t\tf := &forwards[i]\n\t\tif f.UserID == userID {\n\t\t\tif err := h.syncForwardServices(f, \"UpdateService\", true); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (h *Handler) validateSpeedLimitReference(speedID *int64) error {\n\tif speedID == nil {\n\t\treturn nil\n\t}\n\n\texists, err := h.repo.SpeedLimitExists(*speedID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !exists {\n\t\treturn errors.New(\"限速规则不存在\")\n\t}\n\n\treturn nil\n}\n\nfunc asAnySlice(v interface{}) []interface{} {\n\tif v == nil {\n\t\treturn nil\n\t}\n\tif arr, ok := v.([]interface{}); ok {\n\t\treturn arr\n\t}\n\treturn nil\n}\n\nfunc asMapSlice(v interface{}) []map[string]interface{} {\n\tarr := asAnySlice(v)\n\tif arr == nil {\n\t\treturn nil\n\t}\n\tout := make([]map[string]interface{}, 0, len(arr))\n\tfor _, it := range arr {\n\t\tif m, ok := it.(map[string]interface{}); ok {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc asString(v interface{}) string {\n\tswitch t := v.(type) {\n\tcase nil:\n\t\treturn \"\"\n\tcase string:\n\t\treturn strings.TrimSpace(t)\n\tcase float64:\n\t\tif t == float64(int64(t)) {\n\t\t\treturn strconv.FormatInt(int64(t), 10)\n\t\t}\n\t\treturn strconv.FormatFloat(t, 'f', -1, 64)\n\tcase int, int32, int64:\n\t\treturn fmt.Sprintf(\"%v\", t)\n\tdefault:\n\t\tb, _ := json.Marshal(t)\n\t\treturn strings.Trim(string(b), \"\\\"\")\n\t}\n}\n\nfunc asInt(v interface{}, def int) int {\n\ts := asString(v)\n\tif s == \"\" {\n\t\treturn def\n\t}\n\ti, err := strconv.Atoi(s)\n\tif err != nil {\n\t\treturn def\n\t}\n\treturn i\n}\n\nfunc asInt64(v interface{}, def int64) int64 {\n\ts := asString(v)\n\tif s == \"\" {\n\t\treturn def\n\t}\n\ti, err := strconv.ParseInt(s, 10, 64)\n\tif err != nil {\n\t\treturn def\n\t}\n\treturn i\n}\n\nfunc asFloat(v interface{}, def float64) float64 {\n\ts := asString(v)\n\tif s == \"\" {\n\t\treturn def\n\t}\n\tf, err := strconv.ParseFloat(s, 64)\n\tif err != nil {\n\t\treturn def\n\t}\n\treturn f\n}\n\nfunc asAnyToInt64Ptr(v interface{}) *int64 {\n\ts := asString(v)\n\tif s == \"\" || strings.EqualFold(s, \"null\") {\n\t\treturn nil\n\t}\n\ti, err := strconv.ParseInt(s, 10, 64)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &i\n}\n\nfunc idFromBody(r *http.Request, w http.ResponseWriter) int64 {\n\treturn asInt64FromBodyKey(r, w, \"id\")\n}\n\nfunc asInt64FromBodyKey(r *http.Request, w http.ResponseWriter, key string) int64 {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn 0\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn 0\n\t}\n\tid := asInt64(req[key], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"参数错误\"))\n\t\treturn 0\n\t}\n\treturn id\n}\n\nfunc idsFromBody(r *http.Request, w http.ResponseWriter) []int64 {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn nil\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn nil\n\t}\n\tarr := asAnySlice(req[\"ids\"])\n\tif len(arr) == 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"ids不能为空\"))\n\t\treturn nil\n\t}\n\tids := make([]int64, 0, len(arr))\n\tfor _, x := range arr {\n\t\tid := asInt64(x, 0)\n\t\tif id > 0 {\n\t\t\tids = append(ids, id)\n\t\t}\n\t}\n\tsort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })\n\treturn ids\n}\n\nfunc nullableText(s string) interface{} {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn nil\n\t}\n\treturn s\n}\n\nfunc nullableInt(v *int64) interface{} {\n\tif v == nil {\n\t\treturn nil\n\t}\n\treturn *v\n}\n\nfunc defaultString(v, def string) string {\n\tif strings.TrimSpace(v) == \"\" {\n\t\treturn def\n\t}\n\treturn v\n}\n\nfunc randomToken(n int) string {\n\tbuf := make([]byte, n)\n\tif _, err := rand.Read(buf); err != nil {\n\t\treturn strconv.FormatInt(time.Now().UnixNano(), 16)\n\t}\n\treturn hex.EncodeToString(buf)\n}\n\nfunc asInt64Slice(v interface{}) []int64 {\n\tarr := asAnySlice(v)\n\tif len(arr) == 0 {\n\t\treturn nil\n\t}\n\tids := make([]int64, 0, len(arr))\n\tfor _, x := range arr {\n\t\tif id := asInt64(x, 0); id > 0 {\n\t\t\tids = append(ids, id)\n\t\t}\n\t}\n\treturn ids\n}\n",
|
||
"additions": 1,
|
||
"deletions": 1,
|
||
"status": "modified"
|
||
}
|
||
]
|
||
},
|
||
"agent": "build",
|
||
"model": {
|
||
"providerID": "api",
|
||
"modelID": "gpt-5.3-codex"
|
||
},
|
||
"variant": "xhigh",
|
||
"id": "msg_cb19b9ae6001Tg0NlwB47EgTPl",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "text",
|
||
"text": "实施",
|
||
"id": "prt_cb19b9ae6002tNtPh4EXX2kqcd",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19b9ae6001Tg0NlwB47EgTPl"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506290937,
|
||
"completed": 1772506299063
|
||
},
|
||
"parentID": "msg_cb19b9ae6001Tg0NlwB47EgTPl",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"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": 57216,
|
||
"input": 6311,
|
||
"output": 217,
|
||
"reasoning": 143,
|
||
"cache": {
|
||
"read": 50688,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb19b9af9002167h0EHxJwf2Lf",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "a9f81f5eb2b2f394bfbbb0512b6a054cce21cae2",
|
||
"id": "prt_cb19bb989001umS4ZMc2QMa64y",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19b9af9002167h0EHxJwf2Lf"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Planning IP inclusion modifications**",
|
||
"time": {
|
||
"start": 1772506298761,
|
||
"end": 1772506298987
|
||
},
|
||
"id": "prt_cb19bb989002rYD423TZ1Iqv1I",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19b9af9002167h0EHxJwf2Lf"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_YWgSNO0X2Mmex6zzJ8lcr6cB",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go",
|
||
"offset": 500,
|
||
"limit": 40
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go</path>\n<type>file</type>\n<content>500: \tStatus int\n501: \tSpeedID sql.NullInt64\n502: }\n503: \n504: // TunnelRecord is a minimal tunnel view used by control plane.\n505: type TunnelRecord struct {\n506: \tID int64\n507: \tType int\n508: \tStatus int\n509: \tFlow int64\n510: \tTrafficRatio float64\n511: }\n512: \n513: // ForwardPortRecord is a forward port mapping used by control plane.\n514: type ForwardPortRecord struct {\n515: \tNodeID int64\n516: \tPort int\n517: \tInIP string\n518: }\n519: \n520: // NodeRecord is a node view used by control plane.\n521: type NodeRecord struct {\n522: \tID int64\n523: \tName string\n524: \tServerIP string\n525: \tServerIPv4 string\n526: \tServerIPv6 string\n527: \tExtraIPs string\n528: \tStatus int\n529: \tPortRange string\n530: \tTCPListenAddr string\n531: \tUDPListenAddr string\n532: \tInterfaceName string\n533: \tIsRemote int\n534: \tRemoteURL string\n535: \tRemoteToken string\n536: \tRemoteConfig string\n537: }\n538: \n539: type ChainNodeRecord struct {\n\n(Showing lines 500-539 of 601. Use offset=540 to continue.)\n</content>",
|
||
"title": "go-backend/internal/store/model/model.go",
|
||
"metadata": {
|
||
"preview": "\tStatus int\n\tSpeedID sql.NullInt64\n}\n\n// TunnelRecord is a minimal tunnel view used by control plane.\ntype TunnelRecord struct {\n\tID int64\n\tType int\n\tStatus int\n\tFlow int64\n\tTrafficRatio float64\n}\n\n// ForwardPortRecord is a forward port mapping used by control plane.\ntype ForwardPortRecord struct {\n\tNodeID int64\n\tPort int\n\tInIP string\n}\n",
|
||
"truncated": true,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772506298950,
|
||
"end": 1772506298957
|
||
}
|
||
},
|
||
"id": "prt_cb19bb98c001S6HWpzyV03Uvtz",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19b9af9002167h0EHxJwf2Lf"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "a9f81f5eb2b2f394bfbbb0512b6a054cce21cae2",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 57216,
|
||
"input": 6311,
|
||
"output": 217,
|
||
"reasoning": 143,
|
||
"cache": {
|
||
"read": 50688,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb19bba6e001fxLmLX2XZvor5U",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19b9af9002167h0EHxJwf2Lf"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506299065,
|
||
"completed": 1772506307397
|
||
},
|
||
"parentID": "msg_cb19b9ae6001Tg0NlwB47EgTPl",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"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": 57847,
|
||
"input": 57474,
|
||
"output": 373,
|
||
"reasoning": 68,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb19bbab90028jtCZBsLEW7pGz",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "a9f81f5eb2b2f394bfbbb0512b6a054cce21cae2",
|
||
"id": "prt_cb19bcc91001JxElKP85GA1SPV",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19bbab90028jtCZBsLEW7pGz"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Planning signature and struct updates**",
|
||
"time": {
|
||
"start": 1772506303634,
|
||
"end": 1772506307336
|
||
},
|
||
"id": "prt_cb19bcc92001vtKZ6v93z7r1xi",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19bbab90028jtCZBsLEW7pGz"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_GaF00f78AbAKSm3wv0qCFJ2P",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go",
|
||
"offset": 220,
|
||
"limit": 80
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go</path>\n<type>file</type>\n<content>220: \n221: func (h *Handler) listUserTunnelIDsByUser(userID int64) ([]int64, error) {\n222: \treturn h.repo.ListUserTunnelIDsByUser(userID)\n223: }\n224: \n225: func (h *Handler) syncForwardServices(forward *forwardRecord, method string, allowFallbackAdd bool) error {\n226: \tif h == nil || forward == nil {\n227: \t\treturn errors.New(\"invalid forward sync context\")\n228: \t}\n229: \n230: \ttunnel, err := h.getTunnelRecord(forward.TunnelID)\n231: \tif err != nil {\n232: \t\treturn err\n233: \t}\n234: \tports, err := h.listForwardPorts(forward.ID)\n235: \tif err != nil {\n236: \t\treturn err\n237: \t}\n238: \tif len(ports) == 0 {\n239: \t\treturn errors.New(\"转发入口端口不存在\")\n240: \t}\n241: \n242: \t// Determine limiter from forward's SpeedID first, fallback to UserTunnel's limiter\n243: \tvar limiterID *int64\n244: \tvar speed *int\n245: \n246: \tif forward.SpeedID.Valid && forward.SpeedID.Int64 > 0 {\n247: \t\t// Forward has its own speed limit\n248: \t\tspeedVal, err := h.repo.GetSpeedLimitSpeed(forward.SpeedID.Int64)\n249: \t\tif err == nil && speedVal > 0 {\n250: \t\t\tlimiterID = &forward.SpeedID.Int64\n251: \t\t\tspeed = &speedVal\n252: \t\t}\n253: \t}\n254: \n255: \tif limiterID == nil {\n256: \t\t// Fall back to UserTunnel speed limit\n257: \t\tvar utLimiterID *int64\n258: \t\tvar utSpeed *int\n259: \t\t_, utLimiterID, utSpeed, err = h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)\n260: \t\tif err != nil {\n261: \t\t\treturn err\n262: \t\t}\n263: \t\tlimiterID = utLimiterID\n264: \t\tspeed = utSpeed\n265: \t}\n266: \n267: \tserviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)\n268: \ttunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)\n269: \tif err != nil {\n270: \t\treturn err\n271: \t}\n272: \n273: \tfor _, fp := range ports {\n274: \t\tif limiterID != nil && speed != nil {\n275: \t\t\tif err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {\n276: \t\t\t\treturn err\n277: \t\t\t}\n278: \t\t}\n279: \n280: \t\tnode, err := h.getNodeRecord(fp.NodeID)\n281: \t\tif err != nil {\n282: \t\t\treturn err\n283: \t\t}\n284: \t\tservices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID, tunnelTLSProtocol)\n285: \t\t_, err = h.sendNodeCommand(node.ID, method, services, true, false)\n286: \t\tif err != nil && allowFallbackAdd && method == \"UpdateService\" {\n287: \t\t\t_, err = h.sendNodeCommand(node.ID, \"AddService\", services, true, false)\n288: \t\t}\n289: \t\tif err != nil {\n290: \t\t\treturn fmt.Errorf(\"节点 %s 下发失败: %w\", node.Name, err)\n291: \t\t}\n292: \t}\n293: \treturn nil\n294: }\n295: \n296: func (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {\n297: \tif h == nil || forward == nil {\n298: \t\treturn errors.New(\"invalid forward control context\")\n299: \t}\n\n(Showing lines 220-299 of 1458. Use offset=300 to continue.)\n</content>",
|
||
"title": "go-backend/internal/http/handler/control_plane.go",
|
||
"metadata": {
|
||
"preview": "\nfunc (h *Handler) listUserTunnelIDsByUser(userID int64) ([]int64, error) {\n\treturn h.repo.ListUserTunnelIDsByUser(userID)\n}\n\nfunc (h *Handler) syncForwardServices(forward *forwardRecord, method string, allowFallbackAdd bool) error {\n\tif h == nil || forward == nil {\n\t\treturn errors.New(\"invalid forward sync context\")\n\t}\n\n\ttunnel, err := h.getTunnelRecord(forward.TunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tports, err := h.listForwardPorts(forward.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(ports) == 0 {\n\t\treturn errors.New(\"转发入口端口不存在\")",
|
||
"truncated": true,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772506307021,
|
||
"end": 1772506307029
|
||
}
|
||
},
|
||
"id": "prt_cb19bd9bc00195DDXLB3yzOHEe",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19bbab90028jtCZBsLEW7pGz"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_d2GQNLdgCk7shZ4wp8JuCLjG",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go",
|
||
"offset": 1298,
|
||
"limit": 90
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go</path>\n<type>file</type>\n<content>1298: \t\treturn false\n1299: \t}\n1300: \tif strings.Contains(msg, \"address already in use\") {\n1301: \t\treturn false\n1302: \t}\n1303: \treturn strings.Contains(msg, \"already exists\") || strings.Contains(msg, \"已存在\")\n1304: }\n1305: \n1306: func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {\n1307: \tprotocols := []string{\"tcp\", \"udp\"}\n1308: \tservices := make([]map[string]interface{}, 0, 2)\n1309: \ttargets := splitRemoteTargets(forward.RemoteAddr)\n1310: \tstrategy := strings.TrimSpace(forward.Strategy)\n1311: \tif strategy == \"\" {\n1312: \t\tstrategy = \"fifo\"\n1313: \t}\n1314: \n1315: \tfor _, protocol := range protocols {\n1316: \t\tlistenerAddr := node.TCPListenAddr\n1317: \t\tif protocol == \"udp\" {\n1318: \t\t\tlistenerAddr = node.UDPListenAddr\n1319: \t\t}\n1320: \t\tservice := map[string]interface{}{\n1321: \t\t\t\"name\": fmt.Sprintf(\"%s_%s\", baseName, protocol),\n1322: \t\t\t\"addr\": fmt.Sprintf(\"%s:%d\", listenerAddr, port),\n1323: \t\t\t\"handler\": map[string]interface{}{\n1324: \t\t\t\t\"type\": protocol,\n1325: \t\t\t},\n1326: \t\t\t\"listener\": map[string]interface{}{\n1327: \t\t\t\t\"type\": protocol,\n1328: \t\t\t},\n1329: \t\t\t\"forwarder\": map[string]interface{}{\n1330: \t\t\t\t\"nodes\": buildForwarderNodes(targets),\n1331: \t\t\t\t\"selector\": map[string]interface{}{\n1332: \t\t\t\t\t\"strategy\": strategy,\n1333: \t\t\t\t\t\"maxFails\": 1,\n1334: \t\t\t\t\t\"failTimeout\": \"600s\",\n1335: \t\t\t\t},\n1336: \t\t\t},\n1337: \t\t}\n1338: \t\tif protocol == \"udp\" {\n1339: \t\t\tlistenerMetadata := map[string]interface{}{\"keepAlive\": true}\n1340: \t\t\tif tunnelTLSProtocol {\n1341: \t\t\t\tlistenerMetadata[\"ttl\"] = \"10s\"\n1342: \t\t\t}\n1343: \t\t\tservice[\"listener\"].(map[string]interface{})[\"metadata\"] = listenerMetadata\n1344: \t\t}\n1345: \t\tif tunnel != nil && tunnel.Type == 2 {\n1346: \t\t\tservice[\"handler\"].(map[string]interface{})[\"chain\"] = fmt.Sprintf(\"chains_%d\", forward.TunnelID)\n1347: \t\t}\n1348: \t\tif tunnel != nil && tunnel.Type == 1 && strings.TrimSpace(node.InterfaceName) != \"\" {\n1349: \t\t\tservice[\"metadata\"] = map[string]interface{}{\"interface\": node.InterfaceName}\n1350: \t\t}\n1351: \t\tif limiterID != nil && *limiterID > 0 {\n1352: \t\t\tservice[\"limiter\"] = strconv.FormatInt(*limiterID, 10)\n1353: \t\t}\n1354: \t\tservices = append(services, service)\n1355: \t}\n1356: \n1357: \treturn services\n1358: }\n1359: \n1360: func buildForwarderNodes(targets []string) []map[string]interface{} {\n1361: \tnodes := make([]map[string]interface{}, 0, len(targets))\n1362: \tfor i, addr := range targets {\n1363: \t\tnodes = append(nodes, map[string]interface{}{\n1364: \t\t\t\"name\": fmt.Sprintf(\"node_%d\", i+1),\n1365: \t\t\t\"addr\": addr,\n1366: \t\t})\n1367: \t}\n1368: \treturn nodes\n1369: }\n1370: \n1371: func processServerAddress(serverAddr string) string {\n1372: \tserverAddr = strings.TrimSpace(serverAddr)\n1373: \tif serverAddr == \"\" {\n1374: \t\treturn serverAddr\n1375: \t}\n1376: \tif strings.HasPrefix(serverAddr, \"[\") {\n1377: \t\treturn serverAddr\n1378: \t}\n1379: \tidx := strings.LastIndex(serverAddr, \":\")\n1380: \tif idx < 0 {\n1381: \t\tif looksLikeIPv6(serverAddr) {\n1382: \t\t\treturn \"[\" + serverAddr + \"]\"\n1383: \t\t}\n1384: \t\treturn serverAddr\n1385: \t}\n1386: \thost := strings.TrimSpace(serverAddr[:idx])\n1387: \tport := strings.TrimSpace(serverAddr[idx+1:])\n\n(Showing lines 1298-1387 of 1458. Use offset=1388 to continue.)\n</content>",
|
||
"title": "go-backend/internal/http/handler/control_plane.go",
|
||
"metadata": {
|
||
"preview": "\t\treturn false\n\t}\n\tif strings.Contains(msg, \"address already in use\") {\n\t\treturn false\n\t}\n\treturn strings.Contains(msg, \"already exists\") || strings.Contains(msg, \"已存在\")\n}\n\nfunc buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {\n\tprotocols := []string{\"tcp\", \"udp\"}\n\tservices := make([]map[string]interface{}, 0, 2)\n\ttargets := splitRemoteTargets(forward.RemoteAddr)\n\tstrategy := strings.TrimSpace(forward.Strategy)\n\tif strategy == \"\" {\n\t\tstrategy = \"fifo\"\n\t}\n\n\tfor _, protocol := range protocols {\n\t\tlistenerAddr := node.TCPListenAddr\n\t\tif protocol == \"udp\" {",
|
||
"truncated": true,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772506307250,
|
||
"end": 1772506307256
|
||
}
|
||
},
|
||
"id": "prt_cb19bd9d8001CUIP8cTkyewjGP",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19bbab90028jtCZBsLEW7pGz"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_IAJm8f3G6c58z94tYuIz84e8",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go",
|
||
"offset": 1948,
|
||
"limit": 40
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go</path>\n<type>file</type>\n<content>1948: \tfor _, p := range pairs {\n1949: \t\t_ = h.applyGroupPermission(p[0], p[1])\n1950: \t}\n1951: \treturn nil\n1952: }\n1953: \n1954: type tunnelRuntimeNode struct {\n1955: \tNodeID int64\n1956: \tProtocol string\n1957: \tStrategy string\n1958: \tInx int\n1959: \tChainType int\n1960: \tPort int\n1961: \tConnectIP string\n1962: }\n1963: \n1964: type tunnelCreateState struct {\n1965: \tTunnelID int64\n1966: \tType int\n1967: \tIPPreference string // \"\" = auto, \"v4\" = prefer IPv4, \"v6\" = prefer IPv6\n1968: \tInNodes []tunnelRuntimeNode\n1969: \tChainHops [][]tunnelRuntimeNode\n1970: \tOutNodes []tunnelRuntimeNode\n1971: \tNodes map[int64]*nodeRecord\n1972: \tNodeIDList []int64\n1973: }\n1974: \n1975: func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) {\n1976: \tstate := &tunnelCreateState{\n1977: \t\tType: tunnelType,\n1978: \t\tInNodes: make([]tunnelRuntimeNode, 0),\n1979: \t\tChainHops: make([][]tunnelRuntimeNode, 0),\n1980: \t\tOutNodes: make([]tunnelRuntimeNode, 0),\n1981: \t\tNodes: make(map[int64]*nodeRecord),\n1982: \t}\n1983: \tnodeIDs := make([]int64, 0)\n1984: \n1985: \tfor _, item := range asMapSlice(req[\"inNodeId\"]) {\n1986: \t\tnodeID := asInt64(item[\"nodeId\"], 0)\n1987: \t\tif nodeID <= 0 {\n\n(Showing lines 1948-1987 of 3419. Use offset=1988 to continue.)\n</content>",
|
||
"title": "go-backend/internal/http/handler/mutations.go",
|
||
"metadata": {
|
||
"preview": "\tfor _, p := range pairs {\n\t\t_ = h.applyGroupPermission(p[0], p[1])\n\t}\n\treturn nil\n}\n\ntype tunnelRuntimeNode struct {\n\tNodeID int64\n\tProtocol string\n\tStrategy string\n\tInx int\n\tChainType int\n\tPort int\n\tConnectIP string\n}\n\ntype tunnelCreateState struct {\n\tTunnelID int64\n\tType int\n\tIPPreference string // \"\" = auto, \"v4\" = prefer IPv4, \"v6\" = prefer IPv6",
|
||
"truncated": true,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772506307287,
|
||
"end": 1772506307292
|
||
}
|
||
},
|
||
"id": "prt_cb19bdad4001lQwdj6Lb5g7raS",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19bbab90028jtCZBsLEW7pGz"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_pRnOtsvotZTgUgOSKg4ZWeFV",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go",
|
||
"offset": 2628,
|
||
"limit": 40
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go</path>\n<type>file</type>\n<content>2628: \t\t\"name\": fmt.Sprintf(\"chains_%d\", tunnelID),\n2629: \t\t\"hops\": []map[string]interface{}{hop},\n2630: \t}, nil\n2631: }\n2632: \n2633: func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode, node *nodeRecord) []map[string]interface{} {\n2634: \tif node == nil {\n2635: \t\treturn nil\n2636: \t}\n2637: \tprotocol := defaultString(chainNode.Protocol, \"tls\")\n2638: \thandlerCfg := map[string]interface{}{\n2639: \t\t\"type\": \"relay\",\n2640: \t}\n2641: \tif isTLSTunnelProtocol(protocol) {\n2642: \t\thandlerCfg[\"metadata\"] = map[string]interface{}{\"nodelay\": true}\n2643: \t}\n2644: \tservice := map[string]interface{}{\n2645: \t\t\"name\": fmt.Sprintf(\"%d_tls\", tunnelID),\n2646: \t\t\"addr\": fmt.Sprintf(\"%s:%d\", node.TCPListenAddr, chainNode.Port),\n2647: \t\t\"handler\": handlerCfg,\n2648: \t\t\"listener\": map[string]interface{}{\n2649: \t\t\t\"type\": protocol,\n2650: \t\t},\n2651: \t}\n2652: \tif chainNode.ChainType == 2 {\n2653: \t\tservice[\"handler\"].(map[string]interface{})[\"chain\"] = fmt.Sprintf(\"chains_%d\", tunnelID)\n2654: \t}\n2655: \tif chainNode.ChainType == 3 && strings.TrimSpace(node.InterfaceName) != \"\" {\n2656: \t\tservice[\"metadata\"] = map[string]interface{}{\"interface\": node.InterfaceName}\n2657: \t}\n2658: \treturn []map[string]interface{}{service}\n2659: }\n2660: \n2661: func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, connectIp string) (string, error) {\n2662: \tif fromNode == nil || toNode == nil {\n2663: \t\treturn \"\", errors.New(\"节点不存在\")\n2664: \t}\n2665: \tif strings.TrimSpace(connectIp) != \"\" {\n2666: \t\treturn strings.TrimSpace(connectIp), nil\n2667: \t}\n\n(Showing lines 2628-2667 of 3419. Use offset=2668 to continue.)\n</content>",
|
||
"title": "go-backend/internal/http/handler/mutations.go",
|
||
"metadata": {
|
||
"preview": "\t\t\"name\": fmt.Sprintf(\"chains_%d\", tunnelID),\n\t\t\"hops\": []map[string]interface{}{hop},\n\t}, nil\n}\n\nfunc buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode, node *nodeRecord) []map[string]interface{} {\n\tif node == nil {\n\t\treturn nil\n\t}\n\tprotocol := defaultString(chainNode.Protocol, \"tls\")\n\thandlerCfg := map[string]interface{}{\n\t\t\"type\": \"relay\",\n\t}\n\tif isTLSTunnelProtocol(protocol) {\n\t\thandlerCfg[\"metadata\"] = map[string]interface{}{\"nodelay\": true}\n\t}\n\tservice := map[string]interface{}{\n\t\t\"name\": fmt.Sprintf(\"%d_tls\", tunnelID),\n\t\t\"addr\": fmt.Sprintf(\"%s:%d\", node.TCPListenAddr, chainNode.Port),\n\t\t\"handler\": handlerCfg,",
|
||
"truncated": true,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772506307311,
|
||
"end": 1772506307318
|
||
}
|
||
},
|
||
"id": "prt_cb19bdaed001qaaTQmMrnpxyxF",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19bbab90028jtCZBsLEW7pGz"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "a9f81f5eb2b2f394bfbbb0512b6a054cce21cae2",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 57847,
|
||
"input": 57474,
|
||
"output": 373,
|
||
"reasoning": 68,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb19bdb08001qP0ic6gk1Fi316",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19bbab90028jtCZBsLEW7pGz"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506307398,
|
||
"completed": 1772506318522
|
||
},
|
||
"parentID": "msg_cb19b9ae6001Tg0NlwB47EgTPl",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"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": 61575,
|
||
"input": 61023,
|
||
"output": 552,
|
||
"reasoning": 111,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb19bdb46002pDoh87xGuapeO1",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "a9f81f5eb2b2f394bfbbb0512b6a054cce21cae2",
|
||
"id": "prt_cb19bf0fb00169LyYSq7gEVLPG",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19bdb46002pDoh87xGuapeO1"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Adjusting function signatures and tests**",
|
||
"time": {
|
||
"start": 1772506312956,
|
||
"end": 1772506318357
|
||
},
|
||
"id": "prt_cb19bf0fc001V5c3bGYDIaOgWu",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19bdb46002pDoh87xGuapeO1"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_1RIT5QWxuFd2IrbPwYA3Ed1a",
|
||
"tool": "apply_patch",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"patchText": "*** Begin Patch\n*** Update File: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go\n@@\n-\t\tservices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID, tunnelTLSProtocol)\n+\t\tservices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol)\n@@\n-func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {\n+func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {\n@@\n \tfor _, protocol := range protocols {\n \t\tlistenerAddr := node.TCPListenAddr\n \t\tif protocol == \"udp\" {\n \t\t\tlistenerAddr = node.UDPListenAddr\n \t\t}\n+\t\tif bindIP != \"\" {\n+\t\t\tlistenerAddr = bindIP\n+\t\t}\n \t\tservice := map[string]interface{}{\n \t\t\t\"name\": fmt.Sprintf(\"%s_%s\", baseName, protocol),\n-\t\t\t\"addr\": fmt.Sprintf(\"%s:%d\", listenerAddr, port),\n+\t\t\t\"addr\": processServerAddress(fmt.Sprintf(\"%s:%d\", listenerAddr, port)),\n \t\t\t\"handler\": map[string]interface{}{\n \t\t\t\t\"type\": protocol,\n \t\t\t},\n*** End Patch"
|
||
},
|
||
"output": "Success. Updated the following files:\nM go-backend/internal/http/handler/control_plane.go",
|
||
"title": "Success. Updated the following files:\nM go-backend/internal/http/handler/control_plane.go",
|
||
"metadata": {
|
||
"diff": "Index: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go\n===================================================================\n--- /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go\n+++ /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go\n@@ -280,9 +280,9 @@\n \t\tnode, err := h.getNodeRecord(fp.NodeID)\n \t\tif err != nil {\n \t\t\treturn err\n \t\t}\n-\t\tservices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID, tunnelTLSProtocol)\n+\t\tservices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol)\n \t\t_, err = h.sendNodeCommand(node.ID, method, services, true, false)\n \t\tif err != nil && allowFallbackAdd && method == \"UpdateService\" {\n \t\t\t_, err = h.sendNodeCommand(node.ID, \"AddService\", services, true, false)\n \t\t}\n@@ -1302,9 +1302,9 @@\n \t}\n \treturn strings.Contains(msg, \"already exists\") || strings.Contains(msg, \"已存在\")\n }\n \n-func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {\n+func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {\n \tprotocols := []string{\"tcp\", \"udp\"}\n \tservices := make([]map[string]interface{}, 0, 2)\n \ttargets := splitRemoteTargets(forward.RemoteAddr)\n \tstrategy := strings.TrimSpace(forward.Strategy)\n@@ -1316,11 +1316,14 @@\n \t\tlistenerAddr := node.TCPListenAddr\n \t\tif protocol == \"udp\" {\n \t\t\tlistenerAddr = node.UDPListenAddr\n \t\t}\n+\t\tif bindIP != \"\" {\n+\t\t\tlistenerAddr = bindIP\n+\t\t}\n \t\tservice := map[string]interface{}{\n \t\t\t\"name\": fmt.Sprintf(\"%s_%s\", baseName, protocol),\n-\t\t\t\"addr\": fmt.Sprintf(\"%s:%d\", listenerAddr, port),\n+\t\t\t\"addr\": processServerAddress(fmt.Sprintf(\"%s:%d\", listenerAddr, port)),\n \t\t\t\"handler\": map[string]interface{}{\n \t\t\t\t\"type\": protocol,\n \t\t\t},\n \t\t\t\"listener\": map[string]interface{}{\n\n",
|
||
"files": [
|
||
{
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go",
|
||
"relativePath": "go-backend/internal/http/handler/control_plane.go",
|
||
"type": "update",
|
||
"diff": "Index: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go\n===================================================================\n--- /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go\n+++ /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go\n@@ -280,9 +280,9 @@\n \t\tnode, err := h.getNodeRecord(fp.NodeID)\n \t\tif err != nil {\n \t\t\treturn err\n \t\t}\n-\t\tservices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID, tunnelTLSProtocol)\n+\t\tservices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol)\n \t\t_, err = h.sendNodeCommand(node.ID, method, services, true, false)\n \t\tif err != nil && allowFallbackAdd && method == \"UpdateService\" {\n \t\t\t_, err = h.sendNodeCommand(node.ID, \"AddService\", services, true, false)\n \t\t}\n@@ -1302,9 +1302,9 @@\n \t}\n \treturn strings.Contains(msg, \"already exists\") || strings.Contains(msg, \"已存在\")\n }\n \n-func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {\n+func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {\n \tprotocols := []string{\"tcp\", \"udp\"}\n \tservices := make([]map[string]interface{}, 0, 2)\n \ttargets := splitRemoteTargets(forward.RemoteAddr)\n \tstrategy := strings.TrimSpace(forward.Strategy)\n@@ -1316,11 +1316,14 @@\n \t\tlistenerAddr := node.TCPListenAddr\n \t\tif protocol == \"udp\" {\n \t\t\tlistenerAddr = node.UDPListenAddr\n \t\t}\n+\t\tif bindIP != \"\" {\n+\t\t\tlistenerAddr = bindIP\n+\t\t}\n \t\tservice := map[string]interface{}{\n \t\t\t\"name\": fmt.Sprintf(\"%s_%s\", baseName, protocol),\n-\t\t\t\"addr\": fmt.Sprintf(\"%s:%d\", listenerAddr, port),\n+\t\t\t\"addr\": processServerAddress(fmt.Sprintf(\"%s:%d\", listenerAddr, port)),\n \t\t\t\"handler\": map[string]interface{}{\n \t\t\t\t\"type\": protocol,\n \t\t\t},\n \t\t\t\"listener\": map[string]interface{}{\n",
|
||
"before": "package handler\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"go-backend/internal/http/client\"\n\t\"go-backend/internal/store/model\"\n\t\"go-backend/internal/ws\"\n)\n\nvar errForwardNotFound = errors.New(\"forward not found\")\n\ntype forwardRecord = model.ForwardRecord\ntype tunnelRecord = model.TunnelRecord\ntype forwardPortRecord = model.ForwardPortRecord\ntype nodeRecord = model.NodeRecord\n\ntype chainNodeRecord = model.ChainNodeRecord\n\ntype diagnosisTarget struct {\n\tAddress string\n\tIP string\n\tPort int\n}\n\ntype diagnosisWorkItem struct {\n\tfromNodeID int64\n\ttargetIP string\n\ttargetPort int\n\tdescription string\n\tmetadata map[string]interface{}\n\ttoNode chainNodeRecord\n\thasChainHop bool\n\tipPreference string\n}\n\ntype diagnosisExecOptions struct {\n\tcommandTimeout time.Duration\n\tpingTimeoutMS int\n\ttimeoutMessage string\n}\n\ntype diagnosisProgress struct {\n\tTotal int `json:\"total\"`\n\tCompleted int `json:\"completed\"`\n\tSuccess int `json:\"success\"`\n\tFailed int `json:\"failed\"`\n}\n\ntype diagnosisItemEmitter func(index int, item map[string]interface{}, progress diagnosisProgress)\n\nfunc (h *Handler) buildDiagnosisStreamStartItems(workItems []diagnosisWorkItem) []map[string]interface{} {\n\tif len(workItems) == 0 {\n\t\treturn []map[string]interface{}{}\n\t}\n\n\tnodeCache := map[int64]*nodeRecord{}\n\titems := make([]map[string]interface{}, 0, len(workItems))\n\tfor _, workItem := range workItems {\n\t\ttargetIP := strings.TrimSpace(workItem.targetIP)\n\t\ttargetPort := workItem.targetPort\n\t\tif workItem.hasChainHop {\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\tif resolveErr == nil {\n\t\t\t\t\ttargetIP = resolvedIP\n\t\t\t\t\ttargetPort = resolvedPort\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif targetPort <= 0 {\n\t\t\ttargetPort = 443\n\t\t}\n\n\t\tnodeName := fmt.Sprintf(\"node_%d\", workItem.fromNodeID)\n\t\tif node, err := h.cachedNode(nodeCache, workItem.fromNodeID); err == nil && strings.TrimSpace(node.Name) != \"\" {\n\t\t\tnodeName = node.Name\n\t\t}\n\n\t\titem := map[string]interface{}{\n\t\t\t\"success\": false,\n\t\t\t\"diagnosing\": true,\n\t\t\t\"description\": workItem.description,\n\t\t\t\"nodeName\": nodeName,\n\t\t\t\"nodeId\": strconv.FormatInt(workItem.fromNodeID, 10),\n\t\t\t\"targetIp\": targetIP,\n\t\t\t\"targetPort\": targetPort,\n\t\t\t\"message\": \"诊断中...\",\n\t\t}\n\t\tfor key, value := range workItem.metadata {\n\t\t\titem[key] = value\n\t\t}\n\t\titems = append(items, item)\n\t}\n\n\treturn items\n}\n\nconst diagnosisMaxConcurrency = 8\n\nconst (\n\tdefaultNodeCommandTimeout = 6 * time.Second\n\tdiagnosisCommandTimeout = 30 * time.Second\n\tdiagnosisRequestTimeout = 2 * time.Minute\n\tdiagnosisCommandTimeoutMsg = \"诊断超时(30秒)\"\n\tdiagnosisRequestTimeoutMsg = \"诊断超时(2分钟)\"\n)\n\nfunc (h *Handler) resolveForwardAccess(r *http.Request, forwardID int64) (*forwardRecord, int64, int, error) {\n\tuserID, roleID, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\treturn nil, 0, 0, err\n\t}\n\tforward, err := h.ensureForwardAccessByActor(userID, roleID, forwardID)\n\tif err != nil {\n\t\treturn nil, userID, roleID, err\n\t}\n\treturn forward, userID, roleID, nil\n}\n\nfunc (h *Handler) ensureForwardAccessByActor(actorUserID int64, actorRole int, forwardID int64) (*forwardRecord, error) {\n\tforward, err := h.getForwardRecord(forwardID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif actorRole != 0 && forward.UserID != actorUserID {\n\t\treturn nil, errForwardNotFound\n\t}\n\treturn forward, nil\n}\n\nfunc (h *Handler) ensureTunnelPermission(userID int64, roleID int, tunnelID int64) error {\n\tif roleID == 0 {\n\t\treturn nil\n\t}\n\tok, err := h.repo.UserTunnelExistsByUserAndTunnel(userID, tunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !ok {\n\t\treturn errors.New(\"你没有该隧道的权限\")\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) getForwardRecord(forwardID int64) (*forwardRecord, error) {\n\tfr, err := h.repo.GetForwardRecord(forwardID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif fr == nil {\n\t\treturn nil, errForwardNotFound\n\t}\n\treturn fr, nil\n}\n\nfunc (h *Handler) getTunnelRecord(tunnelID int64) (*tunnelRecord, error) {\n\ttr, err := h.repo.GetTunnelRecord(tunnelID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif tr == nil {\n\t\treturn nil, errors.New(\"隧道不存在\")\n\t}\n\treturn tr, nil\n}\n\nfunc (h *Handler) listForwardsByTunnel(tunnelID int64) ([]forwardRecord, error) {\n\treturn h.repo.ListForwardsByTunnel(tunnelID)\n}\n\nfunc (h *Handler) listForwardPorts(forwardID int64) ([]forwardPortRecord, error) {\n\treturn h.repo.ListForwardPorts(forwardID)\n}\n\nfunc (h *Handler) isTunnelSelectedTLSProtocol(tunnelID int64) (bool, error) {\n\tprotocol, err := h.repo.GetTunnelOutProtocol(tunnelID)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn isTLSTunnelProtocol(protocol), nil\n}\n\nfunc (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {\n\tn, err := h.repo.GetNodeRecord(nodeID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif n == nil {\n\t\treturn nil, errors.New(\"节点不存在\")\n\t}\n\treturn n, nil\n}\n\nfunc (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *int64, *int, error) {\n\tinfo, err := h.repo.ResolveUserTunnelAndLimiter(userID, tunnelID)\n\tif err != nil {\n\t\treturn 0, nil, nil, err\n\t}\n\tif info == nil {\n\t\treturn 0, nil, nil, nil\n\t}\n\treturn info.UserTunnelID, info.LimiterID, info.Speed, nil\n}\n\nfunc (h *Handler) listUserTunnelIDs(userID, tunnelID int64) ([]int64, error) {\n\treturn h.repo.ListUserTunnelIDs(userID, tunnelID)\n}\n\nfunc (h *Handler) listUserTunnelIDsByUser(userID int64) ([]int64, error) {\n\treturn h.repo.ListUserTunnelIDsByUser(userID)\n}\n\nfunc (h *Handler) syncForwardServices(forward *forwardRecord, method string, allowFallbackAdd bool) error {\n\tif h == nil || forward == nil {\n\t\treturn errors.New(\"invalid forward sync context\")\n\t}\n\n\ttunnel, err := h.getTunnelRecord(forward.TunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tports, err := h.listForwardPorts(forward.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(ports) == 0 {\n\t\treturn errors.New(\"转发入口端口不存在\")\n\t}\n\n\t// Determine limiter from forward's SpeedID first, fallback to UserTunnel's limiter\n\tvar limiterID *int64\n\tvar speed *int\n\n\tif forward.SpeedID.Valid && forward.SpeedID.Int64 > 0 {\n\t\t// Forward has its own speed limit\n\t\tspeedVal, err := h.repo.GetSpeedLimitSpeed(forward.SpeedID.Int64)\n\t\tif err == nil && speedVal > 0 {\n\t\t\tlimiterID = &forward.SpeedID.Int64\n\t\t\tspeed = &speedVal\n\t\t}\n\t}\n\n\tif limiterID == nil {\n\t\t// Fall back to UserTunnel speed limit\n\t\tvar utLimiterID *int64\n\t\tvar utSpeed *int\n\t\t_, utLimiterID, utSpeed, err = h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tlimiterID = utLimiterID\n\t\tspeed = utSpeed\n\t}\n\n\tserviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)\n\ttunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, fp := range ports {\n\t\tif limiterID != nil && speed != nil {\n\t\t\tif err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\tnode, err := h.getNodeRecord(fp.NodeID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tservices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID, tunnelTLSProtocol)\n\t\t_, err = h.sendNodeCommand(node.ID, method, services, true, false)\n\t\tif err != nil && allowFallbackAdd && method == \"UpdateService\" {\n\t\t\t_, err = h.sendNodeCommand(node.ID, \"AddService\", services, true, false)\n\t\t}\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"节点 %s 下发失败: %w\", node.Name, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {\n\tif h == nil || forward == nil {\n\t\treturn errors.New(\"invalid forward control context\")\n\t}\n\tports, err := h.listForwardPorts(forward.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(ports) == 0 {\n\t\treturn nil\n\t}\n\tuserTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tuserTunnelIDs, err := h.listUserTunnelIDs(forward.UserID, forward.TunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tallUserTunnelIDs, err := h.listUserTunnelIDsByUser(forward.UserID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tcandidateTunnelIDs := make([]int64, 0, len(userTunnelIDs)+len(allUserTunnelIDs))\n\tcandidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)\n\tcandidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)\n\tbases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)\n\tseen := map[int64]struct{}{}\n\tfor _, fp := range ports {\n\t\tif _, ok := seen[fp.NodeID]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[fp.NodeID] = struct{}{}\n\n\t\tvar lastNotFoundErr error\n\t\tnodeHandled := false\n\n\t\tfor _, base := range bases {\n\t\t\tvariants := []string{base + \"_tcp\", base + \"_udp\"}\n\t\t\tif shouldTryLegacySingleService(commandType) || strings.EqualFold(strings.TrimSpace(commandType), \"DeleteService\") {\n\t\t\t\tvariants = append(variants, base)\n\t\t\t}\n\n\t\t\tcandidateHandled := false\n\t\t\tfor _, name := range variants {\n\t\t\t\tpayload := map[string]interface{}{\n\t\t\t\t\t\"services\": []string{name},\n\t\t\t\t}\n\t\t\t\t_, err := h.sendNodeCommand(fp.NodeID, commandType, payload, false, false)\n\t\t\t\tif err == nil {\n\t\t\t\t\tcandidateHandled = true\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif !isNotFoundError(err) {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tlastNotFoundErr = err\n\t\t\t}\n\n\t\t\tif candidateHandled {\n\t\t\t\tnodeHandled = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif nodeHandled {\n\t\t\tcontinue\n\t\t}\n\t\tif tolerateNotFound {\n\t\t\tcontinue\n\t\t}\n\t\tif lastNotFoundErr != nil {\n\t\t\treturn lastNotFoundErr\n\t\t}\n\t\treturn errors.New(\"service control failed\")\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) applyNodeProtocolChange(nodeID int64, httpVal, tlsVal, socksVal int) error {\n\t_, err := h.sendNodeCommand(nodeID, \"SetProtocol\", map[string]interface{}{\n\t\t\"http\": httpVal,\n\t\t\"tls\": tlsVal,\n\t\t\"socks\": socksVal,\n\t}, false, false)\n\treturn err\n}\n\nfunc (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interface{}, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {\n\treturn h.sendNodeCommandWithTimeout(nodeID, commandType, data, defaultNodeCommandTimeout, tolerateExists, tolerateNotFound)\n}\n\nfunc (h *Handler) sendNodeCommandWithTimeout(nodeID int64, commandType string, data interface{}, timeout time.Duration, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {\n\tvar (\n\t\tresult ws.CommandResult\n\t\terr error\n\t)\n\tif timeout <= 0 {\n\t\ttimeout = defaultNodeCommandTimeout\n\t}\n\n\tnode, nodeErr := h.getNodeRecord(nodeID)\n\tif nodeErr == nil && node != nil && node.IsRemote == 1 {\n\t\tresult, err = h.sendRemoteNodeCommandWithTimeout(node, commandType, data, timeout)\n\t} else {\n\t\tresult, err = h.wsServer.SendCommand(nodeID, commandType, data, timeout)\n\t}\n\tif err == nil {\n\t\treturn result, nil\n\t}\n\tmsg := strings.ToLower(strings.TrimSpace(err.Error()))\n\tif tolerateExists {\n\t\tif isAlreadyExistsMessage(msg) {\n\t\t\treturn result, nil\n\t\t}\n\t}\n\tif tolerateNotFound {\n\t\tif strings.Contains(msg, \"not found\") || strings.Contains(msg, \"不存在\") {\n\t\t\treturn result, nil\n\t\t}\n\t}\n\treturn result, err\n}\n\nfunc (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, data interface{}) (ws.CommandResult, error) {\n\treturn h.sendRemoteNodeCommandWithTimeout(node, commandType, data, 0)\n}\n\nfunc (h *Handler) sendRemoteNodeCommandWithTimeout(node *nodeRecord, commandType string, data interface{}, timeout time.Duration) (ws.CommandResult, error) {\n\tif node == nil {\n\t\treturn ws.CommandResult{}, errors.New(\"节点不存在\")\n\t}\n\tremoteURL := strings.TrimSpace(node.RemoteURL)\n\tremoteToken := strings.TrimSpace(node.RemoteToken)\n\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\treturn ws.CommandResult{}, errors.New(\"远程节点缺少共享配置\")\n\t}\n\n\tfc := client.NewFederationClient()\n\tif timeout > 0 {\n\t\tfc = client.NewFederationClientWithTimeout(timeout)\n\t}\n\tres, err := fc.Command(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeNodeCommandRequest{\n\t\tCommandType: commandType,\n\t\tData: data,\n\t})\n\tif err != nil {\n\t\treturn ws.CommandResult{}, err\n\t}\n\tif res == nil {\n\t\treturn ws.CommandResult{}, errors.New(\"远程节点未返回命令结果\")\n\t}\n\n\tresult := ws.CommandResult{\n\t\tType: res.Type,\n\t\tSuccess: res.Success,\n\t\tMessage: res.Message,\n\t\tData: res.Data,\n\t}\n\tif !result.Success {\n\t\tmsg := strings.TrimSpace(result.Message)\n\t\tif msg == \"\" {\n\t\t\tmsg = \"命令执行失败\"\n\t\t}\n\t\treturn result, errors.New(msg)\n\t}\n\treturn result, nil\n}\n\nfunc (h *Handler) diagnoseForwardRuntime(ctx context.Context, forward *forwardRecord) (map[string]interface{}, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tforwardName, workItems, err := h.prepareForwardDiagnosis(forward)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresults := h.runDiagnosisWorkItems(ctx, workItems, nil)\n\n\tpayload := map[string]interface{}{\n\t\t\"forwardName\": forwardName,\n\t\t\"timestamp\": time.Now().UnixMilli(),\n\t\t\"results\": results,\n\t}\n\treturn payload, nil\n}\n\nfunc (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []diagnosisWorkItem, error) {\n\tif forward == nil {\n\t\treturn \"\", nil, errForwardNotFound\n\t}\n\ttargets, err := resolveDiagnosisTargets(forward.RemoteAddr)\n\tif err != nil {\n\t\treturn \"\", nil, err\n\t}\n\n\ttunnel, err := h.getTunnelRecord(forward.TunnelID)\n\tif err != nil {\n\t\treturn \"\", nil, err\n\t}\n\n\tchainRows, err := h.listChainNodesForTunnel(forward.TunnelID)\n\tif err != nil {\n\t\treturn \"\", nil, err\n\t}\n\tif len(chainRows) == 0 {\n\t\treturn \"\", nil, errors.New(\"隧道配置不完整\")\n\t}\n\n\tipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)\n\n\tinNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)\n\tworkItems := make([]diagnosisWorkItem, 0, len(chainRows)*2+len(targets))\n\n\tswitch tunnel.Type {\n\tcase 1:\n\t\tfor _, inNode := range inNodes {\n\t\t\tfor _, target := range targets {\n\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->目标(%s)\", inNode.NodeName, target.Address)\n\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\ttargetIP: target.IP,\n\t\t\t\t\ttargetPort: target.Port,\n\t\t\t\t\tdescription: description,\n\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\tcase 2:\n\t\tfor _, inNode := range inNodes {\n\t\t\tif len(chainHops) > 0 {\n\t\t\t\tfor _, firstNode := range chainHops[0] {\n\t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->第1跳(%s)\", inNode.NodeName, firstNode.NodeName)\n\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\t\ttoNode: firstNode,\n\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t\t\t\"toChainType\": 2,\n\t\t\t\t\t\t\t\"toInx\": firstNode.Inx,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tfor _, outNode := range outNodes {\n\t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->出口(%s)\", inNode.NodeName, outNode.NodeName)\n\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\t\ttoNode: outNode,\n\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t\t\t\"toChainType\": 3,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor i, hop := range chainHops {\n\t\t\tfor _, currentNode := range hop {\n\t\t\t\tif i+1 < len(chainHops) {\n\t\t\t\t\tfor _, nextNode := range chainHops[i+1] {\n\t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->第%d跳(%s)\", i+1, currentNode.NodeName, i+2, nextNode.NodeName)\n\t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n\t\t\t\t\t\t\ttoNode: nextNode,\n\t\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\t\"fromChainType\": 2,\n\t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n\t\t\t\t\t\t\t\t\"toChainType\": 2,\n\t\t\t\t\t\t\t\t\"toInx\": nextNode.Inx,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tfor _, outNode := range outNodes {\n\t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->出口(%s)\", i+1, currentNode.NodeName, outNode.NodeName)\n\t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n\t\t\t\t\t\t\ttoNode: outNode,\n\t\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\t\"fromChainType\": 2,\n\t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n\t\t\t\t\t\t\t\t\"toChainType\": 3,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor _, outNode := range outNodes {\n\t\t\tfor _, target := range targets {\n\t\t\t\tdescription := fmt.Sprintf(\"出口(%s)->目标(%s)\", outNode.NodeName, target.Address)\n\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\tfromNodeID: outNode.NodeID,\n\t\t\t\t\ttargetIP: target.IP,\n\t\t\t\t\ttargetPort: target.Port,\n\t\t\t\t\tdescription: description,\n\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\"fromChainType\": 3,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\tdefault:\n\t\tfor _, inNode := range inNodes {\n\t\t\tfor _, target := range targets {\n\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->目标(%s)\", inNode.NodeName, target.Address)\n\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\ttargetIP: target.IP,\n\t\t\t\t\ttargetPort: target.Port,\n\t\t\t\t\tdescription: description,\n\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\treturn forward.Name, workItems, nil\n}\n\nfunc (h *Handler) diagnoseTunnelRuntime(ctx context.Context, tunnelID int64) (map[string]interface{}, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\ttunnelName, tunnelType, workItems, err := h.prepareTunnelDiagnosis(tunnelID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresults := h.runDiagnosisWorkItems(ctx, workItems, nil)\n\n\tpayload := map[string]interface{}{\n\t\t\"tunnelName\": tunnelName,\n\t\t\"tunnelType\": tunnelType,\n\t\t\"timestamp\": time.Now().UnixMilli(),\n\t\t\"results\": results,\n\t}\n\treturn payload, nil\n}\n\nfunc (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diagnosisWorkItem, error) {\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\treturn \"\", \"\", nil, err\n\t}\n\n\ttunnelName, err := h.repo.GetTunnelName(tunnelID)\n\tif err != nil {\n\t\treturn \"\", \"\", nil, err\n\t}\n\tif tunnelName == \"\" {\n\t\treturn \"\", \"\", nil, errors.New(\"隧道不存在\")\n\t}\n\n\tchainRows, err := h.listChainNodesForTunnel(tunnelID)\n\tif err != nil {\n\t\treturn \"\", \"\", nil, err\n\t}\n\tif len(chainRows) == 0 {\n\t\treturn \"\", \"\", nil, errors.New(\"隧道配置不完整\")\n\t}\n\n\tipPreference := h.repo.GetTunnelIPPreference(tunnelID)\n\tinNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)\n\tworkItems := make([]diagnosisWorkItem, 0, len(chainRows)*2)\n\n\tswitch tunnel.Type {\n\tcase 1:\n\t\tfor _, inNode := range inNodes {\n\t\t\tdescription := fmt.Sprintf(\"入口(%s)->外网\", inNode.NodeName)\n\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\ttargetIP: \"www.bing.com\",\n\t\t\t\ttargetPort: 443,\n\t\t\t\tdescription: description,\n\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\tcase 2:\n\t\tfor _, inNode := range inNodes {\n\t\t\tif len(chainHops) > 0 {\n\t\t\t\tfor _, firstNode := range chainHops[0] {\n\t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->第1跳(%s)\", inNode.NodeName, firstNode.NodeName)\n\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\t\ttoNode: firstNode,\n\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t\t\t\"toChainType\": 2,\n\t\t\t\t\t\t\t\"toInx\": firstNode.Inx,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tfor _, outNode := range outNodes {\n\t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->出口(%s)\", inNode.NodeName, outNode.NodeName)\n\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\t\ttoNode: outNode,\n\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t\t\t\"toChainType\": 3,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor i, hop := range chainHops {\n\t\t\tfor _, currentNode := range hop {\n\t\t\t\tif i+1 < len(chainHops) {\n\t\t\t\t\tfor _, nextNode := range chainHops[i+1] {\n\t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->第%d跳(%s)\", i+1, currentNode.NodeName, i+2, nextNode.NodeName)\n\t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n\t\t\t\t\t\t\ttoNode: nextNode,\n\t\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\t\"fromChainType\": 2,\n\t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n\t\t\t\t\t\t\t\t\"toChainType\": 2,\n\t\t\t\t\t\t\t\t\"toInx\": nextNode.Inx,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tfor _, outNode := range outNodes {\n\t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->出口(%s)\", i+1, currentNode.NodeName, outNode.NodeName)\n\t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n\t\t\t\t\t\t\ttoNode: outNode,\n\t\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\t\"fromChainType\": 2,\n\t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n\t\t\t\t\t\t\t\t\"toChainType\": 3,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor _, outNode := range outNodes {\n\t\t\tdescription := fmt.Sprintf(\"出口(%s)->外网\", outNode.NodeName)\n\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\tfromNodeID: outNode.NodeID,\n\t\t\t\ttargetIP: \"www.bing.com\",\n\t\t\t\ttargetPort: 443,\n\t\t\t\tdescription: description,\n\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\"fromChainType\": 3,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\tdefault:\n\t\tfor _, inNode := range inNodes {\n\t\t\tdescription := fmt.Sprintf(\"入口(%s)->外网\", inNode.NodeName)\n\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\ttargetIP: \"www.bing.com\",\n\t\t\t\ttargetPort: 443,\n\t\t\t\tdescription: description,\n\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\t}\n\n\ttunnelType := map[bool]string{true: \"端口转发\", false: \"隧道转发\"}[tunnel.Type == 1]\n\treturn tunnelName, tunnelType, workItems, nil\n}\n\nfunc splitChainNodeGroups(rows []chainNodeRecord) ([]chainNodeRecord, [][]chainNodeRecord, []chainNodeRecord) {\n\tinNodes := make([]chainNodeRecord, 0)\n\toutNodes := make([]chainNodeRecord, 0)\n\tchainByInx := map[int64][]chainNodeRecord{}\n\thopOrder := make([]int64, 0)\n\n\tfor _, row := range rows {\n\t\tswitch row.ChainType {\n\t\tcase 1:\n\t\t\tinNodes = append(inNodes, row)\n\t\tcase 2:\n\t\t\tif _, ok := chainByInx[row.Inx]; !ok {\n\t\t\t\thopOrder = append(hopOrder, row.Inx)\n\t\t\t}\n\t\t\tchainByInx[row.Inx] = append(chainByInx[row.Inx], row)\n\t\tcase 3:\n\t\t\toutNodes = append(outNodes, row)\n\t\t}\n\t}\n\n\tsort.Slice(hopOrder, func(i, j int) bool { return hopOrder[i] < hopOrder[j] })\n\tchainHops := make([][]chainNodeRecord, 0, len(hopOrder))\n\tfor _, inx := range hopOrder {\n\t\tchainHops = append(chainHops, chainByInx[inx])\n\t}\n\n\treturn inNodes, chainHops, outNodes\n}\n\nfunc resolveDiagnosisTargets(remoteAddr string) ([]diagnosisTarget, error) {\n\trawTargets := splitRemoteTargets(remoteAddr)\n\tif len(rawTargets) == 0 {\n\t\treturn nil, errors.New(\"目标地址不能为空\")\n\t}\n\n\ttargets := make([]diagnosisTarget, 0, len(rawTargets))\n\tfor _, raw := range rawTargets {\n\t\tip, port, err := parseTargetAddress(raw)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttargets = append(targets, diagnosisTarget{Address: raw, IP: ip, Port: port})\n\t}\n\tif len(targets) == 0 {\n\t\treturn nil, errors.New(\"目标地址格式错误\")\n\t}\n\treturn targets, nil\n}\n\nfunc diagnosisContextMessage(ctx context.Context) string {\n\tif ctx == nil {\n\t\treturn diagnosisRequestTimeoutMsg\n\t}\n\tswitch ctx.Err() {\n\tcase context.DeadlineExceeded:\n\t\treturn diagnosisRequestTimeoutMsg\n\tcase context.Canceled:\n\t\treturn \"诊断已取消\"\n\tdefault:\n\t\treturn diagnosisRequestTimeoutMsg\n\t}\n}\n\nfunc diagnosisExecOptionsFromContext(ctx context.Context) diagnosisExecOptions {\n\ttimeout := diagnosisCommandTimeout\n\tif ctx != nil {\n\t\tif deadline, ok := ctx.Deadline(); ok {\n\t\t\tremaining := time.Until(deadline)\n\t\t\tif remaining <= 0 {\n\t\t\t\tremaining = 100 * time.Millisecond\n\t\t\t}\n\t\t\tif remaining < timeout {\n\t\t\t\ttimeout = remaining\n\t\t\t}\n\t\t}\n\t}\n\tif timeout <= 0 {\n\t\ttimeout = 100 * time.Millisecond\n\t}\n\tpingTimeoutMS := int(timeout / time.Millisecond)\n\tif pingTimeoutMS <= 0 {\n\t\tpingTimeoutMS = 100\n\t}\n\treturn diagnosisExecOptions{\n\t\tcommandTimeout: timeout,\n\t\tpingTimeoutMS: pingTimeoutMS,\n\t\ttimeoutMessage: diagnosisContextMessage(ctx),\n\t}\n}\n\nfunc newDiagnosisTimeoutItem(workItem diagnosisWorkItem, message string) map[string]interface{} {\n\ttargetPort := workItem.targetPort\n\tif targetPort <= 0 {\n\t\ttargetPort = workItem.toNode.Port\n\t}\n\titem := newDiagnosisResultItem(workItem.fromNodeID, workItem.targetIP, targetPort, workItem.description, workItem.metadata)\n\titem[\"success\"] = false\n\tif strings.TrimSpace(message) == \"\" {\n\t\tmessage = diagnosisCommandTimeoutMsg\n\t}\n\titem[\"message\"] = message\n\treturn item\n}\n\nfunc (h *Handler) executeDiagnosisWorkItem(workItem diagnosisWorkItem, options diagnosisExecOptions) map[string]interface{} {\n\tsingle := make([]map[string]interface{}, 0, 1)\n\tnodeCache := map[int64]*nodeRecord{}\n\tif workItem.hasChainHop {\n\t\th.appendChainHopDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.toNode, workItem.description, workItem.metadata, workItem.ipPreference, options)\n\t} else {\n\t\th.appendPathDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.targetIP, workItem.targetPort, workItem.description, workItem.metadata, options)\n\t}\n\n\tif len(single) == 0 {\n\t\treturn newDiagnosisTimeoutItem(workItem, \"诊断任务未返回结果\")\n\t}\n\treturn single[0]\n}\n\nfunc (h *Handler) runDiagnosisWorkItems(ctx context.Context, workItems []diagnosisWorkItem, emitter diagnosisItemEmitter) []map[string]interface{} {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tresults := make([]map[string]interface{}, len(workItems))\n\tif len(workItems) == 0 {\n\t\treturn results\n\t}\n\n\tworkerLimit := diagnosisMaxConcurrency\n\tif workerLimit < 1 {\n\t\tworkerLimit = 1\n\t}\n\tif workerLimit > len(workItems) {\n\t\tworkerLimit = len(workItems)\n\t}\n\n\ttype diagnosisWorkResult struct {\n\t\tindex int\n\t\titem map[string]interface{}\n\t}\n\n\tjobs := make(chan int)\n\tresultCh := make(chan diagnosisWorkResult, len(workItems))\n\n\tvar wg sync.WaitGroup\n\tfor i := 0; i < workerLimit; i++ {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tfor index := range jobs {\n\t\t\t\tselect {\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\tresultCh <- diagnosisWorkResult{index: index, item: newDiagnosisTimeoutItem(workItems[index], diagnosisContextMessage(ctx))}\n\t\t\t\t\tcontinue\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\toptions := diagnosisExecOptionsFromContext(ctx)\n\t\t\t\tresultCh <- diagnosisWorkResult{index: index, item: h.executeDiagnosisWorkItem(workItems[index], options)}\n\t\t\t}\n\t\t}()\n\t}\n\nenqueueLoop:\n\tfor i := 0; i < len(workItems); i++ {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tmessage := diagnosisContextMessage(ctx)\n\t\t\tfor j := i; j < len(workItems); j++ {\n\t\t\t\tresultCh <- diagnosisWorkResult{index: j, item: newDiagnosisTimeoutItem(workItems[j], message)}\n\t\t\t}\n\t\t\tbreak enqueueLoop\n\t\tcase jobs <- i:\n\t\t}\n\t}\n\tclose(jobs)\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultCh)\n\t}()\n\n\tprogress := diagnosisProgress{Total: len(workItems)}\n\tfor result := range resultCh {\n\t\tresults[result.index] = result.item\n\t\tprogress.Completed++\n\t\tif asBool(result.item[\"success\"], false) {\n\t\t\tprogress.Success++\n\t\t} else {\n\t\t\tprogress.Failed++\n\t\t}\n\t\tif emitter != nil {\n\t\t\temitter(result.index, result.item, progress)\n\t\t}\n\t}\n\n\tfor i := range results {\n\t\tif results[i] == nil {\n\t\t\tresults[i] = newDiagnosisTimeoutItem(workItems[i], diagnosisCommandTimeoutMsg)\n\t\t}\n\t}\n\treturn results\n}\n\nfunc (h *Handler) cachedNode(nodeCache map[int64]*nodeRecord, nodeID int64) (*nodeRecord, error) {\n\tif node, ok := nodeCache[nodeID]; ok {\n\t\treturn node, nil\n\t}\n\tnode, err := h.getNodeRecord(nodeID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tnodeCache[nodeID] = node\n\treturn node, nil\n}\n\nfunc newDiagnosisResultItem(fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}) map[string]interface{} {\n\titem := map[string]interface{}{\n\t\t\"nodeName\": fmt.Sprintf(\"node_%d\", fromNodeID),\n\t\t\"nodeId\": strconv.FormatInt(fromNodeID, 10),\n\t\t\"targetIp\": targetIP,\n\t\t\"targetPort\": targetPort,\n\t\t\"description\": description,\n\t\t\"averageTime\": 0,\n\t\t\"packetLoss\": 100,\n\t}\n\tfor k, v := range metadata {\n\t\titem[k] = v\n\t}\n\treturn item\n}\n\nfunc (h *Handler) appendFailedDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}, message string) {\n\titem := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)\n\tif node, err := h.cachedNode(nodeCache, fromNodeID); err == nil {\n\t\titem[\"nodeName\"] = node.Name\n\t}\n\tif strings.TrimSpace(message) == \"\" {\n\t\tmessage = \"TCP连接失败\"\n\t}\n\titem[\"success\"] = false\n\titem[\"message\"] = message\n\t*results = append(*results, item)\n}\n\nfunc (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}, options diagnosisExecOptions) {\n\titem := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)\n\n\tfromNode, err := h.cachedNode(nodeCache, fromNodeID)\n\tif err != nil {\n\t\titem[\"success\"] = false\n\t\titem[\"message\"] = err.Error()\n\t\t*results = append(*results, item)\n\t\treturn\n\t}\n\titem[\"nodeName\"] = fromNode.Name\n\n\tvar (\n\t\tpingData map[string]interface{}\n\t\tpingErr error\n\t)\n\tif fromNode.IsRemote == 1 {\n\t\tpingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort, options)\n\t} else {\n\t\tpingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort, options)\n\t}\n\tif pingErr != nil {\n\t\titem[\"success\"] = false\n\t\titem[\"message\"] = pingErr.Error()\n\t\t*results = append(*results, item)\n\t\treturn\n\t}\n\n\tsuccess := asBool(pingData[\"success\"], false)\n\titem[\"success\"] = success\n\titem[\"averageTime\"] = asFloat(pingData[\"averageTime\"], 0)\n\titem[\"packetLoss\"] = asFloat(pingData[\"packetLoss\"], 100)\n\n\tmessage := strings.TrimSpace(asString(pingData[\"message\"]))\n\tif success {\n\t\tif message == \"\" {\n\t\t\tmessage = \"TCP连接成功\"\n\t\t}\n\t} else {\n\t\tif message == \"\" {\n\t\t\tmessage = strings.TrimSpace(asString(pingData[\"errorMessage\"]))\n\t\t}\n\t\tif message == \"\" {\n\t\t\tmessage = \"TCP连接失败\"\n\t\t}\n\t}\n\titem[\"message\"] = message\n\t*results = append(*results, item)\n}\n\nfunc (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, toNode chainNodeRecord, description string, metadata map[string]interface{}, ipPreference string, options diagnosisExecOptions) {\n\tfromNode, _ := h.cachedNode(nodeCache, fromNodeID)\n\ttargetNode, err := h.cachedNode(nodeCache, toNode.NodeID)\n\tif err != nil {\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\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\t}\n\th.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata, options)\n}\n\nfunc 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, connectIp)\n\tif err != nil {\n\t\thost = strings.Trim(strings.TrimSpace(targetNode.ServerIP), \"[]\")\n\t}\n\tif host == \"\" {\n\t\treturn \"\", 0, errors.New(\"目标节点地址为空\")\n\t}\n\tport := preferredPort\n\tif port <= 0 {\n\t\tport = firstPortFromRange(targetNode.PortRange)\n\t}\n\tif port <= 0 {\n\t\tport = 443\n\t}\n\treturn host, port, nil\n}\n\nfunc firstPortFromRange(portRange string) int {\n\tportRange = strings.TrimSpace(portRange)\n\tif portRange == \"\" {\n\t\treturn 0\n\t}\n\tfirst := strings.Split(portRange, \",\")[0]\n\tfirst = strings.TrimSpace(first)\n\tif strings.Contains(first, \"-\") {\n\t\tparts := strings.SplitN(first, \"-\", 2)\n\t\tif len(parts) != 2 {\n\t\t\treturn 0\n\t\t}\n\t\tp, err := strconv.Atoi(strings.TrimSpace(parts[0]))\n\t\tif err != nil || p <= 0 {\n\t\t\treturn 0\n\t\t}\n\t\treturn p\n\t}\n\tp, err := strconv.Atoi(first)\n\tif err != nil || p <= 0 {\n\t\treturn 0\n\t}\n\treturn p\n}\n\nfunc (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, error) {\n\treturn h.repo.ListChainNodesForTunnel(tunnelID)\n}\n\nfunc (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {\n\tif options.commandTimeout <= 0 {\n\t\toptions.commandTimeout = diagnosisCommandTimeout\n\t}\n\tif options.pingTimeoutMS <= 0 {\n\t\toptions.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)\n\t}\n\tres, err := h.sendNodeCommandWithTimeout(nodeID, \"TcpPing\", map[string]interface{}{\n\t\t\"ip\": ip,\n\t\t\"port\": port,\n\t\t\"count\": 4,\n\t\t\"timeout\": options.pingTimeoutMS,\n\t}, options.commandTimeout, false, false)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif res.Data == nil {\n\t\treturn nil, errors.New(\"节点未返回诊断数据\")\n\t}\n\treturn res.Data, nil\n}\n\nfunc (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {\n\tif node == nil {\n\t\treturn nil, errors.New(\"节点不存在\")\n\t}\n\tremoteURL := strings.TrimSpace(node.RemoteURL)\n\tremoteToken := strings.TrimSpace(node.RemoteToken)\n\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\treturn nil, errors.New(\"远程节点缺少共享配置\")\n\t}\n\tif options.commandTimeout <= 0 {\n\t\toptions.commandTimeout = diagnosisCommandTimeout\n\t}\n\tif options.pingTimeoutMS <= 0 {\n\t\toptions.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)\n\t}\n\n\tfc := client.NewFederationClientWithTimeout(options.commandTimeout)\n\treturn fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{\n\t\tIP: strings.TrimSpace(ip),\n\t\tPort: port,\n\t\tCount: 4,\n\t\tTimeout: options.pingTimeoutMS,\n\t})\n}\n\nfunc splitRemoteTargets(remoteAddr string) []string {\n\tparts := strings.Split(remoteAddr, \",\")\n\tout := make([]string, 0, len(parts))\n\tfor _, part := range parts {\n\t\tpart = strings.TrimSpace(part)\n\t\tif part == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, processServerAddress(part))\n\t}\n\treturn out\n}\n\nfunc parseTargetAddress(addr string) (string, int, error) {\n\taddr = strings.TrimSpace(addr)\n\tif addr == \"\" {\n\t\treturn \"\", 0, errors.New(\"empty address\")\n\t}\n\thost, portStr, err := net.SplitHostPort(addr)\n\tif err != nil {\n\t\tidx := strings.LastIndex(addr, \":\")\n\t\tif idx <= 0 || idx >= len(addr)-1 {\n\t\t\treturn \"\", 0, err\n\t\t}\n\t\thost = strings.TrimSpace(addr[:idx])\n\t\tportStr = strings.TrimSpace(addr[idx+1:])\n\t}\n\tport, err := strconv.Atoi(strings.TrimSpace(portStr))\n\tif err != nil || port <= 0 || port > 65535 {\n\t\treturn \"\", 0, errors.New(\"invalid port\")\n\t}\n\thost = strings.Trim(strings.TrimSpace(host), \"[]\")\n\tif host == \"\" {\n\t\treturn \"\", 0, errors.New(\"invalid host\")\n\t}\n\treturn host, port, nil\n}\n\nfunc buildForwardServiceBase(forwardID, userID, userTunnelID int64) string {\n\treturn fmt.Sprintf(\"%d_%d_%d\", forwardID, userID, userTunnelID)\n}\n\nfunc buildForwardServiceBaseCandidates(forwardID, userID, preferredUserTunnelID int64, userTunnelIDs []int64) []string {\n\torderedIDs := make([]int64, 0, len(userTunnelIDs)+2)\n\tseen := make(map[int64]struct{}, len(userTunnelIDs)+2)\n\n\tappendID := func(id int64) {\n\t\tif _, ok := seen[id]; ok {\n\t\t\treturn\n\t\t}\n\t\tseen[id] = struct{}{}\n\t\torderedIDs = append(orderedIDs, id)\n\t}\n\n\tappendID(preferredUserTunnelID)\n\tfor _, id := range userTunnelIDs {\n\t\tappendID(id)\n\t}\n\tappendID(0)\n\n\tbases := make([]string, 0, len(orderedIDs))\n\tfor _, id := range orderedIDs {\n\t\tbases = append(bases, buildForwardServiceBase(forwardID, userID, id))\n\t}\n\treturn bases\n}\n\nfunc buildForwardControlServiceNames(base, commandType string) []string {\n\tnames := []string{base + \"_tcp\", base + \"_udp\"}\n\tif strings.EqualFold(strings.TrimSpace(commandType), \"DeleteService\") {\n\t\treturn append([]string{base}, names...)\n\t}\n\treturn names\n}\n\nfunc shouldTryLegacySingleService(commandType string) bool {\n\tcmd := strings.ToLower(strings.TrimSpace(commandType))\n\treturn cmd == \"pauseservice\" || cmd == \"resumeservice\"\n}\n\nfunc isNotFoundError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tmsg := strings.ToLower(strings.TrimSpace(err.Error()))\n\treturn strings.Contains(msg, \"not found\") || strings.Contains(msg, \"不存在\")\n}\n\nfunc isAlreadyExistsMessage(message string) bool {\n\tmsg := strings.ToLower(strings.TrimSpace(message))\n\tif msg == \"\" {\n\t\treturn false\n\t}\n\tif strings.Contains(msg, \"address already in use\") {\n\t\treturn false\n\t}\n\treturn strings.Contains(msg, \"already exists\") || strings.Contains(msg, \"已存在\")\n}\n\nfunc buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {\n\tprotocols := []string{\"tcp\", \"udp\"}\n\tservices := make([]map[string]interface{}, 0, 2)\n\ttargets := splitRemoteTargets(forward.RemoteAddr)\n\tstrategy := strings.TrimSpace(forward.Strategy)\n\tif strategy == \"\" {\n\t\tstrategy = \"fifo\"\n\t}\n\n\tfor _, protocol := range protocols {\n\t\tlistenerAddr := node.TCPListenAddr\n\t\tif protocol == \"udp\" {\n\t\t\tlistenerAddr = node.UDPListenAddr\n\t\t}\n\t\tservice := map[string]interface{}{\n\t\t\t\"name\": fmt.Sprintf(\"%s_%s\", baseName, protocol),\n\t\t\t\"addr\": fmt.Sprintf(\"%s:%d\", listenerAddr, port),\n\t\t\t\"handler\": map[string]interface{}{\n\t\t\t\t\"type\": protocol,\n\t\t\t},\n\t\t\t\"listener\": map[string]interface{}{\n\t\t\t\t\"type\": protocol,\n\t\t\t},\n\t\t\t\"forwarder\": map[string]interface{}{\n\t\t\t\t\"nodes\": buildForwarderNodes(targets),\n\t\t\t\t\"selector\": map[string]interface{}{\n\t\t\t\t\t\"strategy\": strategy,\n\t\t\t\t\t\"maxFails\": 1,\n\t\t\t\t\t\"failTimeout\": \"600s\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tif protocol == \"udp\" {\n\t\t\tlistenerMetadata := map[string]interface{}{\"keepAlive\": true}\n\t\t\tif tunnelTLSProtocol {\n\t\t\t\tlistenerMetadata[\"ttl\"] = \"10s\"\n\t\t\t}\n\t\t\tservice[\"listener\"].(map[string]interface{})[\"metadata\"] = listenerMetadata\n\t\t}\n\t\tif tunnel != nil && tunnel.Type == 2 {\n\t\t\tservice[\"handler\"].(map[string]interface{})[\"chain\"] = fmt.Sprintf(\"chains_%d\", forward.TunnelID)\n\t\t}\n\t\tif tunnel != nil && tunnel.Type == 1 && strings.TrimSpace(node.InterfaceName) != \"\" {\n\t\t\tservice[\"metadata\"] = map[string]interface{}{\"interface\": node.InterfaceName}\n\t\t}\n\t\tif limiterID != nil && *limiterID > 0 {\n\t\t\tservice[\"limiter\"] = strconv.FormatInt(*limiterID, 10)\n\t\t}\n\t\tservices = append(services, service)\n\t}\n\n\treturn services\n}\n\nfunc buildForwarderNodes(targets []string) []map[string]interface{} {\n\tnodes := make([]map[string]interface{}, 0, len(targets))\n\tfor i, addr := range targets {\n\t\tnodes = append(nodes, map[string]interface{}{\n\t\t\t\"name\": fmt.Sprintf(\"node_%d\", i+1),\n\t\t\t\"addr\": addr,\n\t\t})\n\t}\n\treturn nodes\n}\n\nfunc processServerAddress(serverAddr string) string {\n\tserverAddr = strings.TrimSpace(serverAddr)\n\tif serverAddr == \"\" {\n\t\treturn serverAddr\n\t}\n\tif strings.HasPrefix(serverAddr, \"[\") {\n\t\treturn serverAddr\n\t}\n\tidx := strings.LastIndex(serverAddr, \":\")\n\tif idx < 0 {\n\t\tif looksLikeIPv6(serverAddr) {\n\t\t\treturn \"[\" + serverAddr + \"]\"\n\t\t}\n\t\treturn serverAddr\n\t}\n\thost := strings.TrimSpace(serverAddr[:idx])\n\tport := strings.TrimSpace(serverAddr[idx+1:])\n\tif host == \"\" || port == \"\" {\n\t\treturn serverAddr\n\t}\n\tif looksLikeIPv6(host) {\n\t\treturn \"[\" + host + \"]:\" + port\n\t}\n\treturn serverAddr\n}\n\nfunc looksLikeIPv6(address string) bool {\n\treturn strings.Count(address, \":\") >= 2\n}\n\nfunc asBool(v interface{}, def bool) bool {\n\ts := strings.TrimSpace(strings.ToLower(asString(v)))\n\tif s == \"\" {\n\t\treturn def\n\t}\n\tswitch s {\n\tcase \"1\", \"t\", \"true\", \"yes\", \"y\":\n\t\treturn true\n\tcase \"0\", \"f\", \"false\", \"no\", \"n\":\n\t\treturn false\n\tdefault:\n\t\treturn def\n\t}\n}\n\nfunc (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) error {\n\tif err := h.upsertLimiterOnNode(nodeID, limiterID, speed); err != nil {\n\t\treturn fmt.Errorf(\"限速规则下发失败: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc buildLimiterAddPayload(limiterID int64, speed int) (string, map[string]interface{}) {\n\trate := float64(speed) / 8.0\n\tlimitStr := fmt.Sprintf(\"$ %.1fMB %.1fMB\", rate, rate)\n\tname := strconv.FormatInt(limiterID, 10)\n\n\treturn name, map[string]interface{}{\n\t\t\"name\": name,\n\t\t\"limits\": []string{limitStr},\n\t}\n}\n\nfunc buildLimiterUpdatePayload(name string, data map[string]interface{}) map[string]interface{} {\n\treturn map[string]interface{}{\n\t\t\"limiter\": name,\n\t\t\"data\": data,\n\t}\n}\n\nfunc (h *Handler) upsertLimiterOnNode(nodeID int64, limiterID int64, speed int) error {\n\tname, addPayload := buildLimiterAddPayload(limiterID, speed)\n\tif _, err := h.sendNodeCommand(nodeID, \"AddLimiters\", addPayload, false, false); err != nil {\n\t\tif !isAlreadyExistsMessage(err.Error()) {\n\t\t\treturn err\n\t\t}\n\t\tpayload := map[string]interface{}{\n\t\t\t\"name\": name,\n\t\t\t\"limits\": addPayload[\"limits\"],\n\t\t}\n\t\tif _, updateErr := h.sendNodeCommand(nodeID, \"UpdateLimiters\", buildLimiterUpdatePayload(name, payload), false, false); updateErr != nil {\n\t\t\treturn updateErr\n\t\t}\n\t}\n\n\treturn nil\n}\n",
|
||
"after": "package handler\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"go-backend/internal/http/client\"\n\t\"go-backend/internal/store/model\"\n\t\"go-backend/internal/ws\"\n)\n\nvar errForwardNotFound = errors.New(\"forward not found\")\n\ntype forwardRecord = model.ForwardRecord\ntype tunnelRecord = model.TunnelRecord\ntype forwardPortRecord = model.ForwardPortRecord\ntype nodeRecord = model.NodeRecord\n\ntype chainNodeRecord = model.ChainNodeRecord\n\ntype diagnosisTarget struct {\n\tAddress string\n\tIP string\n\tPort int\n}\n\ntype diagnosisWorkItem struct {\n\tfromNodeID int64\n\ttargetIP string\n\ttargetPort int\n\tdescription string\n\tmetadata map[string]interface{}\n\ttoNode chainNodeRecord\n\thasChainHop bool\n\tipPreference string\n}\n\ntype diagnosisExecOptions struct {\n\tcommandTimeout time.Duration\n\tpingTimeoutMS int\n\ttimeoutMessage string\n}\n\ntype diagnosisProgress struct {\n\tTotal int `json:\"total\"`\n\tCompleted int `json:\"completed\"`\n\tSuccess int `json:\"success\"`\n\tFailed int `json:\"failed\"`\n}\n\ntype diagnosisItemEmitter func(index int, item map[string]interface{}, progress diagnosisProgress)\n\nfunc (h *Handler) buildDiagnosisStreamStartItems(workItems []diagnosisWorkItem) []map[string]interface{} {\n\tif len(workItems) == 0 {\n\t\treturn []map[string]interface{}{}\n\t}\n\n\tnodeCache := map[int64]*nodeRecord{}\n\titems := make([]map[string]interface{}, 0, len(workItems))\n\tfor _, workItem := range workItems {\n\t\ttargetIP := strings.TrimSpace(workItem.targetIP)\n\t\ttargetPort := workItem.targetPort\n\t\tif workItem.hasChainHop {\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\tif resolveErr == nil {\n\t\t\t\t\ttargetIP = resolvedIP\n\t\t\t\t\ttargetPort = resolvedPort\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif targetPort <= 0 {\n\t\t\ttargetPort = 443\n\t\t}\n\n\t\tnodeName := fmt.Sprintf(\"node_%d\", workItem.fromNodeID)\n\t\tif node, err := h.cachedNode(nodeCache, workItem.fromNodeID); err == nil && strings.TrimSpace(node.Name) != \"\" {\n\t\t\tnodeName = node.Name\n\t\t}\n\n\t\titem := map[string]interface{}{\n\t\t\t\"success\": false,\n\t\t\t\"diagnosing\": true,\n\t\t\t\"description\": workItem.description,\n\t\t\t\"nodeName\": nodeName,\n\t\t\t\"nodeId\": strconv.FormatInt(workItem.fromNodeID, 10),\n\t\t\t\"targetIp\": targetIP,\n\t\t\t\"targetPort\": targetPort,\n\t\t\t\"message\": \"诊断中...\",\n\t\t}\n\t\tfor key, value := range workItem.metadata {\n\t\t\titem[key] = value\n\t\t}\n\t\titems = append(items, item)\n\t}\n\n\treturn items\n}\n\nconst diagnosisMaxConcurrency = 8\n\nconst (\n\tdefaultNodeCommandTimeout = 6 * time.Second\n\tdiagnosisCommandTimeout = 30 * time.Second\n\tdiagnosisRequestTimeout = 2 * time.Minute\n\tdiagnosisCommandTimeoutMsg = \"诊断超时(30秒)\"\n\tdiagnosisRequestTimeoutMsg = \"诊断超时(2分钟)\"\n)\n\nfunc (h *Handler) resolveForwardAccess(r *http.Request, forwardID int64) (*forwardRecord, int64, int, error) {\n\tuserID, roleID, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\treturn nil, 0, 0, err\n\t}\n\tforward, err := h.ensureForwardAccessByActor(userID, roleID, forwardID)\n\tif err != nil {\n\t\treturn nil, userID, roleID, err\n\t}\n\treturn forward, userID, roleID, nil\n}\n\nfunc (h *Handler) ensureForwardAccessByActor(actorUserID int64, actorRole int, forwardID int64) (*forwardRecord, error) {\n\tforward, err := h.getForwardRecord(forwardID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif actorRole != 0 && forward.UserID != actorUserID {\n\t\treturn nil, errForwardNotFound\n\t}\n\treturn forward, nil\n}\n\nfunc (h *Handler) ensureTunnelPermission(userID int64, roleID int, tunnelID int64) error {\n\tif roleID == 0 {\n\t\treturn nil\n\t}\n\tok, err := h.repo.UserTunnelExistsByUserAndTunnel(userID, tunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !ok {\n\t\treturn errors.New(\"你没有该隧道的权限\")\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) getForwardRecord(forwardID int64) (*forwardRecord, error) {\n\tfr, err := h.repo.GetForwardRecord(forwardID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif fr == nil {\n\t\treturn nil, errForwardNotFound\n\t}\n\treturn fr, nil\n}\n\nfunc (h *Handler) getTunnelRecord(tunnelID int64) (*tunnelRecord, error) {\n\ttr, err := h.repo.GetTunnelRecord(tunnelID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif tr == nil {\n\t\treturn nil, errors.New(\"隧道不存在\")\n\t}\n\treturn tr, nil\n}\n\nfunc (h *Handler) listForwardsByTunnel(tunnelID int64) ([]forwardRecord, error) {\n\treturn h.repo.ListForwardsByTunnel(tunnelID)\n}\n\nfunc (h *Handler) listForwardPorts(forwardID int64) ([]forwardPortRecord, error) {\n\treturn h.repo.ListForwardPorts(forwardID)\n}\n\nfunc (h *Handler) isTunnelSelectedTLSProtocol(tunnelID int64) (bool, error) {\n\tprotocol, err := h.repo.GetTunnelOutProtocol(tunnelID)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn isTLSTunnelProtocol(protocol), nil\n}\n\nfunc (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {\n\tn, err := h.repo.GetNodeRecord(nodeID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif n == nil {\n\t\treturn nil, errors.New(\"节点不存在\")\n\t}\n\treturn n, nil\n}\n\nfunc (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *int64, *int, error) {\n\tinfo, err := h.repo.ResolveUserTunnelAndLimiter(userID, tunnelID)\n\tif err != nil {\n\t\treturn 0, nil, nil, err\n\t}\n\tif info == nil {\n\t\treturn 0, nil, nil, nil\n\t}\n\treturn info.UserTunnelID, info.LimiterID, info.Speed, nil\n}\n\nfunc (h *Handler) listUserTunnelIDs(userID, tunnelID int64) ([]int64, error) {\n\treturn h.repo.ListUserTunnelIDs(userID, tunnelID)\n}\n\nfunc (h *Handler) listUserTunnelIDsByUser(userID int64) ([]int64, error) {\n\treturn h.repo.ListUserTunnelIDsByUser(userID)\n}\n\nfunc (h *Handler) syncForwardServices(forward *forwardRecord, method string, allowFallbackAdd bool) error {\n\tif h == nil || forward == nil {\n\t\treturn errors.New(\"invalid forward sync context\")\n\t}\n\n\ttunnel, err := h.getTunnelRecord(forward.TunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tports, err := h.listForwardPorts(forward.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(ports) == 0 {\n\t\treturn errors.New(\"转发入口端口不存在\")\n\t}\n\n\t// Determine limiter from forward's SpeedID first, fallback to UserTunnel's limiter\n\tvar limiterID *int64\n\tvar speed *int\n\n\tif forward.SpeedID.Valid && forward.SpeedID.Int64 > 0 {\n\t\t// Forward has its own speed limit\n\t\tspeedVal, err := h.repo.GetSpeedLimitSpeed(forward.SpeedID.Int64)\n\t\tif err == nil && speedVal > 0 {\n\t\t\tlimiterID = &forward.SpeedID.Int64\n\t\t\tspeed = &speedVal\n\t\t}\n\t}\n\n\tif limiterID == nil {\n\t\t// Fall back to UserTunnel speed limit\n\t\tvar utLimiterID *int64\n\t\tvar utSpeed *int\n\t\t_, utLimiterID, utSpeed, err = h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tlimiterID = utLimiterID\n\t\tspeed = utSpeed\n\t}\n\n\tserviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)\n\ttunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, fp := range ports {\n\t\tif limiterID != nil && speed != nil {\n\t\t\tif err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\tnode, err := h.getNodeRecord(fp.NodeID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tservices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol)\n\t\t_, err = h.sendNodeCommand(node.ID, method, services, true, false)\n\t\tif err != nil && allowFallbackAdd && method == \"UpdateService\" {\n\t\t\t_, err = h.sendNodeCommand(node.ID, \"AddService\", services, true, false)\n\t\t}\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"节点 %s 下发失败: %w\", node.Name, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) controlForwardServices(forward *forwardRecord, commandType string, tolerateNotFound bool) error {\n\tif h == nil || forward == nil {\n\t\treturn errors.New(\"invalid forward control context\")\n\t}\n\tports, err := h.listForwardPorts(forward.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(ports) == 0 {\n\t\treturn nil\n\t}\n\tuserTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tuserTunnelIDs, err := h.listUserTunnelIDs(forward.UserID, forward.TunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tallUserTunnelIDs, err := h.listUserTunnelIDsByUser(forward.UserID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tcandidateTunnelIDs := make([]int64, 0, len(userTunnelIDs)+len(allUserTunnelIDs))\n\tcandidateTunnelIDs = append(candidateTunnelIDs, userTunnelIDs...)\n\tcandidateTunnelIDs = append(candidateTunnelIDs, allUserTunnelIDs...)\n\tbases := buildForwardServiceBaseCandidates(forward.ID, forward.UserID, userTunnelID, candidateTunnelIDs)\n\tseen := map[int64]struct{}{}\n\tfor _, fp := range ports {\n\t\tif _, ok := seen[fp.NodeID]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[fp.NodeID] = struct{}{}\n\n\t\tvar lastNotFoundErr error\n\t\tnodeHandled := false\n\n\t\tfor _, base := range bases {\n\t\t\tvariants := []string{base + \"_tcp\", base + \"_udp\"}\n\t\t\tif shouldTryLegacySingleService(commandType) || strings.EqualFold(strings.TrimSpace(commandType), \"DeleteService\") {\n\t\t\t\tvariants = append(variants, base)\n\t\t\t}\n\n\t\t\tcandidateHandled := false\n\t\t\tfor _, name := range variants {\n\t\t\t\tpayload := map[string]interface{}{\n\t\t\t\t\t\"services\": []string{name},\n\t\t\t\t}\n\t\t\t\t_, err := h.sendNodeCommand(fp.NodeID, commandType, payload, false, false)\n\t\t\t\tif err == nil {\n\t\t\t\t\tcandidateHandled = true\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif !isNotFoundError(err) {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tlastNotFoundErr = err\n\t\t\t}\n\n\t\t\tif candidateHandled {\n\t\t\t\tnodeHandled = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif nodeHandled {\n\t\t\tcontinue\n\t\t}\n\t\tif tolerateNotFound {\n\t\t\tcontinue\n\t\t}\n\t\tif lastNotFoundErr != nil {\n\t\t\treturn lastNotFoundErr\n\t\t}\n\t\treturn errors.New(\"service control failed\")\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) applyNodeProtocolChange(nodeID int64, httpVal, tlsVal, socksVal int) error {\n\t_, err := h.sendNodeCommand(nodeID, \"SetProtocol\", map[string]interface{}{\n\t\t\"http\": httpVal,\n\t\t\"tls\": tlsVal,\n\t\t\"socks\": socksVal,\n\t}, false, false)\n\treturn err\n}\n\nfunc (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interface{}, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {\n\treturn h.sendNodeCommandWithTimeout(nodeID, commandType, data, defaultNodeCommandTimeout, tolerateExists, tolerateNotFound)\n}\n\nfunc (h *Handler) sendNodeCommandWithTimeout(nodeID int64, commandType string, data interface{}, timeout time.Duration, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {\n\tvar (\n\t\tresult ws.CommandResult\n\t\terr error\n\t)\n\tif timeout <= 0 {\n\t\ttimeout = defaultNodeCommandTimeout\n\t}\n\n\tnode, nodeErr := h.getNodeRecord(nodeID)\n\tif nodeErr == nil && node != nil && node.IsRemote == 1 {\n\t\tresult, err = h.sendRemoteNodeCommandWithTimeout(node, commandType, data, timeout)\n\t} else {\n\t\tresult, err = h.wsServer.SendCommand(nodeID, commandType, data, timeout)\n\t}\n\tif err == nil {\n\t\treturn result, nil\n\t}\n\tmsg := strings.ToLower(strings.TrimSpace(err.Error()))\n\tif tolerateExists {\n\t\tif isAlreadyExistsMessage(msg) {\n\t\t\treturn result, nil\n\t\t}\n\t}\n\tif tolerateNotFound {\n\t\tif strings.Contains(msg, \"not found\") || strings.Contains(msg, \"不存在\") {\n\t\t\treturn result, nil\n\t\t}\n\t}\n\treturn result, err\n}\n\nfunc (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, data interface{}) (ws.CommandResult, error) {\n\treturn h.sendRemoteNodeCommandWithTimeout(node, commandType, data, 0)\n}\n\nfunc (h *Handler) sendRemoteNodeCommandWithTimeout(node *nodeRecord, commandType string, data interface{}, timeout time.Duration) (ws.CommandResult, error) {\n\tif node == nil {\n\t\treturn ws.CommandResult{}, errors.New(\"节点不存在\")\n\t}\n\tremoteURL := strings.TrimSpace(node.RemoteURL)\n\tremoteToken := strings.TrimSpace(node.RemoteToken)\n\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\treturn ws.CommandResult{}, errors.New(\"远程节点缺少共享配置\")\n\t}\n\n\tfc := client.NewFederationClient()\n\tif timeout > 0 {\n\t\tfc = client.NewFederationClientWithTimeout(timeout)\n\t}\n\tres, err := fc.Command(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeNodeCommandRequest{\n\t\tCommandType: commandType,\n\t\tData: data,\n\t})\n\tif err != nil {\n\t\treturn ws.CommandResult{}, err\n\t}\n\tif res == nil {\n\t\treturn ws.CommandResult{}, errors.New(\"远程节点未返回命令结果\")\n\t}\n\n\tresult := ws.CommandResult{\n\t\tType: res.Type,\n\t\tSuccess: res.Success,\n\t\tMessage: res.Message,\n\t\tData: res.Data,\n\t}\n\tif !result.Success {\n\t\tmsg := strings.TrimSpace(result.Message)\n\t\tif msg == \"\" {\n\t\t\tmsg = \"命令执行失败\"\n\t\t}\n\t\treturn result, errors.New(msg)\n\t}\n\treturn result, nil\n}\n\nfunc (h *Handler) diagnoseForwardRuntime(ctx context.Context, forward *forwardRecord) (map[string]interface{}, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tforwardName, workItems, err := h.prepareForwardDiagnosis(forward)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresults := h.runDiagnosisWorkItems(ctx, workItems, nil)\n\n\tpayload := map[string]interface{}{\n\t\t\"forwardName\": forwardName,\n\t\t\"timestamp\": time.Now().UnixMilli(),\n\t\t\"results\": results,\n\t}\n\treturn payload, nil\n}\n\nfunc (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []diagnosisWorkItem, error) {\n\tif forward == nil {\n\t\treturn \"\", nil, errForwardNotFound\n\t}\n\ttargets, err := resolveDiagnosisTargets(forward.RemoteAddr)\n\tif err != nil {\n\t\treturn \"\", nil, err\n\t}\n\n\ttunnel, err := h.getTunnelRecord(forward.TunnelID)\n\tif err != nil {\n\t\treturn \"\", nil, err\n\t}\n\n\tchainRows, err := h.listChainNodesForTunnel(forward.TunnelID)\n\tif err != nil {\n\t\treturn \"\", nil, err\n\t}\n\tif len(chainRows) == 0 {\n\t\treturn \"\", nil, errors.New(\"隧道配置不完整\")\n\t}\n\n\tipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)\n\n\tinNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)\n\tworkItems := make([]diagnosisWorkItem, 0, len(chainRows)*2+len(targets))\n\n\tswitch tunnel.Type {\n\tcase 1:\n\t\tfor _, inNode := range inNodes {\n\t\t\tfor _, target := range targets {\n\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->目标(%s)\", inNode.NodeName, target.Address)\n\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\ttargetIP: target.IP,\n\t\t\t\t\ttargetPort: target.Port,\n\t\t\t\t\tdescription: description,\n\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\tcase 2:\n\t\tfor _, inNode := range inNodes {\n\t\t\tif len(chainHops) > 0 {\n\t\t\t\tfor _, firstNode := range chainHops[0] {\n\t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->第1跳(%s)\", inNode.NodeName, firstNode.NodeName)\n\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\t\ttoNode: firstNode,\n\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t\t\t\"toChainType\": 2,\n\t\t\t\t\t\t\t\"toInx\": firstNode.Inx,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tfor _, outNode := range outNodes {\n\t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->出口(%s)\", inNode.NodeName, outNode.NodeName)\n\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\t\ttoNode: outNode,\n\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t\t\t\"toChainType\": 3,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor i, hop := range chainHops {\n\t\t\tfor _, currentNode := range hop {\n\t\t\t\tif i+1 < len(chainHops) {\n\t\t\t\t\tfor _, nextNode := range chainHops[i+1] {\n\t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->第%d跳(%s)\", i+1, currentNode.NodeName, i+2, nextNode.NodeName)\n\t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n\t\t\t\t\t\t\ttoNode: nextNode,\n\t\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\t\"fromChainType\": 2,\n\t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n\t\t\t\t\t\t\t\t\"toChainType\": 2,\n\t\t\t\t\t\t\t\t\"toInx\": nextNode.Inx,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tfor _, outNode := range outNodes {\n\t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->出口(%s)\", i+1, currentNode.NodeName, outNode.NodeName)\n\t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n\t\t\t\t\t\t\ttoNode: outNode,\n\t\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\t\"fromChainType\": 2,\n\t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n\t\t\t\t\t\t\t\t\"toChainType\": 3,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor _, outNode := range outNodes {\n\t\t\tfor _, target := range targets {\n\t\t\t\tdescription := fmt.Sprintf(\"出口(%s)->目标(%s)\", outNode.NodeName, target.Address)\n\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\tfromNodeID: outNode.NodeID,\n\t\t\t\t\ttargetIP: target.IP,\n\t\t\t\t\ttargetPort: target.Port,\n\t\t\t\t\tdescription: description,\n\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\"fromChainType\": 3,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\tdefault:\n\t\tfor _, inNode := range inNodes {\n\t\t\tfor _, target := range targets {\n\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->目标(%s)\", inNode.NodeName, target.Address)\n\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\ttargetIP: target.IP,\n\t\t\t\t\ttargetPort: target.Port,\n\t\t\t\t\tdescription: description,\n\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\treturn forward.Name, workItems, nil\n}\n\nfunc (h *Handler) diagnoseTunnelRuntime(ctx context.Context, tunnelID int64) (map[string]interface{}, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\ttunnelName, tunnelType, workItems, err := h.prepareTunnelDiagnosis(tunnelID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresults := h.runDiagnosisWorkItems(ctx, workItems, nil)\n\n\tpayload := map[string]interface{}{\n\t\t\"tunnelName\": tunnelName,\n\t\t\"tunnelType\": tunnelType,\n\t\t\"timestamp\": time.Now().UnixMilli(),\n\t\t\"results\": results,\n\t}\n\treturn payload, nil\n}\n\nfunc (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diagnosisWorkItem, error) {\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\treturn \"\", \"\", nil, err\n\t}\n\n\ttunnelName, err := h.repo.GetTunnelName(tunnelID)\n\tif err != nil {\n\t\treturn \"\", \"\", nil, err\n\t}\n\tif tunnelName == \"\" {\n\t\treturn \"\", \"\", nil, errors.New(\"隧道不存在\")\n\t}\n\n\tchainRows, err := h.listChainNodesForTunnel(tunnelID)\n\tif err != nil {\n\t\treturn \"\", \"\", nil, err\n\t}\n\tif len(chainRows) == 0 {\n\t\treturn \"\", \"\", nil, errors.New(\"隧道配置不完整\")\n\t}\n\n\tipPreference := h.repo.GetTunnelIPPreference(tunnelID)\n\tinNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)\n\tworkItems := make([]diagnosisWorkItem, 0, len(chainRows)*2)\n\n\tswitch tunnel.Type {\n\tcase 1:\n\t\tfor _, inNode := range inNodes {\n\t\t\tdescription := fmt.Sprintf(\"入口(%s)->外网\", inNode.NodeName)\n\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\ttargetIP: \"www.bing.com\",\n\t\t\t\ttargetPort: 443,\n\t\t\t\tdescription: description,\n\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\tcase 2:\n\t\tfor _, inNode := range inNodes {\n\t\t\tif len(chainHops) > 0 {\n\t\t\t\tfor _, firstNode := range chainHops[0] {\n\t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->第1跳(%s)\", inNode.NodeName, firstNode.NodeName)\n\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\t\ttoNode: firstNode,\n\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t\t\t\"toChainType\": 2,\n\t\t\t\t\t\t\t\"toInx\": firstNode.Inx,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tfor _, outNode := range outNodes {\n\t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->出口(%s)\", inNode.NodeName, outNode.NodeName)\n\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\t\t\ttoNode: outNode,\n\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t\t\t\t\"toChainType\": 3,\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor i, hop := range chainHops {\n\t\t\tfor _, currentNode := range hop {\n\t\t\t\tif i+1 < len(chainHops) {\n\t\t\t\t\tfor _, nextNode := range chainHops[i+1] {\n\t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->第%d跳(%s)\", i+1, currentNode.NodeName, i+2, nextNode.NodeName)\n\t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n\t\t\t\t\t\t\ttoNode: nextNode,\n\t\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\t\"fromChainType\": 2,\n\t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n\t\t\t\t\t\t\t\t\"toChainType\": 2,\n\t\t\t\t\t\t\t\t\"toInx\": nextNode.Inx,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tfor _, outNode := range outNodes {\n\t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->出口(%s)\", i+1, currentNode.NodeName, outNode.NodeName)\n\t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n\t\t\t\t\t\t\ttoNode: outNode,\n\t\t\t\t\t\t\thasChainHop: true,\n\t\t\t\t\t\t\tipPreference: ipPreference,\n\t\t\t\t\t\t\tdescription: description,\n\t\t\t\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\t\t\t\"fromChainType\": 2,\n\t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n\t\t\t\t\t\t\t\t\"toChainType\": 3,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor _, outNode := range outNodes {\n\t\t\tdescription := fmt.Sprintf(\"出口(%s)->外网\", outNode.NodeName)\n\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\tfromNodeID: outNode.NodeID,\n\t\t\t\ttargetIP: \"www.bing.com\",\n\t\t\t\ttargetPort: 443,\n\t\t\t\tdescription: description,\n\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\"fromChainType\": 3,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\tdefault:\n\t\tfor _, inNode := range inNodes {\n\t\t\tdescription := fmt.Sprintf(\"入口(%s)->外网\", inNode.NodeName)\n\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n\t\t\t\tfromNodeID: inNode.NodeID,\n\t\t\t\ttargetIP: \"www.bing.com\",\n\t\t\t\ttargetPort: 443,\n\t\t\t\tdescription: description,\n\t\t\t\tmetadata: map[string]interface{}{\n\t\t\t\t\t\"fromChainType\": 1,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\t}\n\n\ttunnelType := map[bool]string{true: \"端口转发\", false: \"隧道转发\"}[tunnel.Type == 1]\n\treturn tunnelName, tunnelType, workItems, nil\n}\n\nfunc splitChainNodeGroups(rows []chainNodeRecord) ([]chainNodeRecord, [][]chainNodeRecord, []chainNodeRecord) {\n\tinNodes := make([]chainNodeRecord, 0)\n\toutNodes := make([]chainNodeRecord, 0)\n\tchainByInx := map[int64][]chainNodeRecord{}\n\thopOrder := make([]int64, 0)\n\n\tfor _, row := range rows {\n\t\tswitch row.ChainType {\n\t\tcase 1:\n\t\t\tinNodes = append(inNodes, row)\n\t\tcase 2:\n\t\t\tif _, ok := chainByInx[row.Inx]; !ok {\n\t\t\t\thopOrder = append(hopOrder, row.Inx)\n\t\t\t}\n\t\t\tchainByInx[row.Inx] = append(chainByInx[row.Inx], row)\n\t\tcase 3:\n\t\t\toutNodes = append(outNodes, row)\n\t\t}\n\t}\n\n\tsort.Slice(hopOrder, func(i, j int) bool { return hopOrder[i] < hopOrder[j] })\n\tchainHops := make([][]chainNodeRecord, 0, len(hopOrder))\n\tfor _, inx := range hopOrder {\n\t\tchainHops = append(chainHops, chainByInx[inx])\n\t}\n\n\treturn inNodes, chainHops, outNodes\n}\n\nfunc resolveDiagnosisTargets(remoteAddr string) ([]diagnosisTarget, error) {\n\trawTargets := splitRemoteTargets(remoteAddr)\n\tif len(rawTargets) == 0 {\n\t\treturn nil, errors.New(\"目标地址不能为空\")\n\t}\n\n\ttargets := make([]diagnosisTarget, 0, len(rawTargets))\n\tfor _, raw := range rawTargets {\n\t\tip, port, err := parseTargetAddress(raw)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\ttargets = append(targets, diagnosisTarget{Address: raw, IP: ip, Port: port})\n\t}\n\tif len(targets) == 0 {\n\t\treturn nil, errors.New(\"目标地址格式错误\")\n\t}\n\treturn targets, nil\n}\n\nfunc diagnosisContextMessage(ctx context.Context) string {\n\tif ctx == nil {\n\t\treturn diagnosisRequestTimeoutMsg\n\t}\n\tswitch ctx.Err() {\n\tcase context.DeadlineExceeded:\n\t\treturn diagnosisRequestTimeoutMsg\n\tcase context.Canceled:\n\t\treturn \"诊断已取消\"\n\tdefault:\n\t\treturn diagnosisRequestTimeoutMsg\n\t}\n}\n\nfunc diagnosisExecOptionsFromContext(ctx context.Context) diagnosisExecOptions {\n\ttimeout := diagnosisCommandTimeout\n\tif ctx != nil {\n\t\tif deadline, ok := ctx.Deadline(); ok {\n\t\t\tremaining := time.Until(deadline)\n\t\t\tif remaining <= 0 {\n\t\t\t\tremaining = 100 * time.Millisecond\n\t\t\t}\n\t\t\tif remaining < timeout {\n\t\t\t\ttimeout = remaining\n\t\t\t}\n\t\t}\n\t}\n\tif timeout <= 0 {\n\t\ttimeout = 100 * time.Millisecond\n\t}\n\tpingTimeoutMS := int(timeout / time.Millisecond)\n\tif pingTimeoutMS <= 0 {\n\t\tpingTimeoutMS = 100\n\t}\n\treturn diagnosisExecOptions{\n\t\tcommandTimeout: timeout,\n\t\tpingTimeoutMS: pingTimeoutMS,\n\t\ttimeoutMessage: diagnosisContextMessage(ctx),\n\t}\n}\n\nfunc newDiagnosisTimeoutItem(workItem diagnosisWorkItem, message string) map[string]interface{} {\n\ttargetPort := workItem.targetPort\n\tif targetPort <= 0 {\n\t\ttargetPort = workItem.toNode.Port\n\t}\n\titem := newDiagnosisResultItem(workItem.fromNodeID, workItem.targetIP, targetPort, workItem.description, workItem.metadata)\n\titem[\"success\"] = false\n\tif strings.TrimSpace(message) == \"\" {\n\t\tmessage = diagnosisCommandTimeoutMsg\n\t}\n\titem[\"message\"] = message\n\treturn item\n}\n\nfunc (h *Handler) executeDiagnosisWorkItem(workItem diagnosisWorkItem, options diagnosisExecOptions) map[string]interface{} {\n\tsingle := make([]map[string]interface{}, 0, 1)\n\tnodeCache := map[int64]*nodeRecord{}\n\tif workItem.hasChainHop {\n\t\th.appendChainHopDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.toNode, workItem.description, workItem.metadata, workItem.ipPreference, options)\n\t} else {\n\t\th.appendPathDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.targetIP, workItem.targetPort, workItem.description, workItem.metadata, options)\n\t}\n\n\tif len(single) == 0 {\n\t\treturn newDiagnosisTimeoutItem(workItem, \"诊断任务未返回结果\")\n\t}\n\treturn single[0]\n}\n\nfunc (h *Handler) runDiagnosisWorkItems(ctx context.Context, workItems []diagnosisWorkItem, emitter diagnosisItemEmitter) []map[string]interface{} {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tresults := make([]map[string]interface{}, len(workItems))\n\tif len(workItems) == 0 {\n\t\treturn results\n\t}\n\n\tworkerLimit := diagnosisMaxConcurrency\n\tif workerLimit < 1 {\n\t\tworkerLimit = 1\n\t}\n\tif workerLimit > len(workItems) {\n\t\tworkerLimit = len(workItems)\n\t}\n\n\ttype diagnosisWorkResult struct {\n\t\tindex int\n\t\titem map[string]interface{}\n\t}\n\n\tjobs := make(chan int)\n\tresultCh := make(chan diagnosisWorkResult, len(workItems))\n\n\tvar wg sync.WaitGroup\n\tfor i := 0; i < workerLimit; i++ {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tfor index := range jobs {\n\t\t\t\tselect {\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\tresultCh <- diagnosisWorkResult{index: index, item: newDiagnosisTimeoutItem(workItems[index], diagnosisContextMessage(ctx))}\n\t\t\t\t\tcontinue\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t\toptions := diagnosisExecOptionsFromContext(ctx)\n\t\t\t\tresultCh <- diagnosisWorkResult{index: index, item: h.executeDiagnosisWorkItem(workItems[index], options)}\n\t\t\t}\n\t\t}()\n\t}\n\nenqueueLoop:\n\tfor i := 0; i < len(workItems); i++ {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tmessage := diagnosisContextMessage(ctx)\n\t\t\tfor j := i; j < len(workItems); j++ {\n\t\t\t\tresultCh <- diagnosisWorkResult{index: j, item: newDiagnosisTimeoutItem(workItems[j], message)}\n\t\t\t}\n\t\t\tbreak enqueueLoop\n\t\tcase jobs <- i:\n\t\t}\n\t}\n\tclose(jobs)\n\tgo func() {\n\t\twg.Wait()\n\t\tclose(resultCh)\n\t}()\n\n\tprogress := diagnosisProgress{Total: len(workItems)}\n\tfor result := range resultCh {\n\t\tresults[result.index] = result.item\n\t\tprogress.Completed++\n\t\tif asBool(result.item[\"success\"], false) {\n\t\t\tprogress.Success++\n\t\t} else {\n\t\t\tprogress.Failed++\n\t\t}\n\t\tif emitter != nil {\n\t\t\temitter(result.index, result.item, progress)\n\t\t}\n\t}\n\n\tfor i := range results {\n\t\tif results[i] == nil {\n\t\t\tresults[i] = newDiagnosisTimeoutItem(workItems[i], diagnosisCommandTimeoutMsg)\n\t\t}\n\t}\n\treturn results\n}\n\nfunc (h *Handler) cachedNode(nodeCache map[int64]*nodeRecord, nodeID int64) (*nodeRecord, error) {\n\tif node, ok := nodeCache[nodeID]; ok {\n\t\treturn node, nil\n\t}\n\tnode, err := h.getNodeRecord(nodeID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tnodeCache[nodeID] = node\n\treturn node, nil\n}\n\nfunc newDiagnosisResultItem(fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}) map[string]interface{} {\n\titem := map[string]interface{}{\n\t\t\"nodeName\": fmt.Sprintf(\"node_%d\", fromNodeID),\n\t\t\"nodeId\": strconv.FormatInt(fromNodeID, 10),\n\t\t\"targetIp\": targetIP,\n\t\t\"targetPort\": targetPort,\n\t\t\"description\": description,\n\t\t\"averageTime\": 0,\n\t\t\"packetLoss\": 100,\n\t}\n\tfor k, v := range metadata {\n\t\titem[k] = v\n\t}\n\treturn item\n}\n\nfunc (h *Handler) appendFailedDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}, message string) {\n\titem := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)\n\tif node, err := h.cachedNode(nodeCache, fromNodeID); err == nil {\n\t\titem[\"nodeName\"] = node.Name\n\t}\n\tif strings.TrimSpace(message) == \"\" {\n\t\tmessage = \"TCP连接失败\"\n\t}\n\titem[\"success\"] = false\n\titem[\"message\"] = message\n\t*results = append(*results, item)\n}\n\nfunc (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}, options diagnosisExecOptions) {\n\titem := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)\n\n\tfromNode, err := h.cachedNode(nodeCache, fromNodeID)\n\tif err != nil {\n\t\titem[\"success\"] = false\n\t\titem[\"message\"] = err.Error()\n\t\t*results = append(*results, item)\n\t\treturn\n\t}\n\titem[\"nodeName\"] = fromNode.Name\n\n\tvar (\n\t\tpingData map[string]interface{}\n\t\tpingErr error\n\t)\n\tif fromNode.IsRemote == 1 {\n\t\tpingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort, options)\n\t} else {\n\t\tpingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort, options)\n\t}\n\tif pingErr != nil {\n\t\titem[\"success\"] = false\n\t\titem[\"message\"] = pingErr.Error()\n\t\t*results = append(*results, item)\n\t\treturn\n\t}\n\n\tsuccess := asBool(pingData[\"success\"], false)\n\titem[\"success\"] = success\n\titem[\"averageTime\"] = asFloat(pingData[\"averageTime\"], 0)\n\titem[\"packetLoss\"] = asFloat(pingData[\"packetLoss\"], 100)\n\n\tmessage := strings.TrimSpace(asString(pingData[\"message\"]))\n\tif success {\n\t\tif message == \"\" {\n\t\t\tmessage = \"TCP连接成功\"\n\t\t}\n\t} else {\n\t\tif message == \"\" {\n\t\t\tmessage = strings.TrimSpace(asString(pingData[\"errorMessage\"]))\n\t\t}\n\t\tif message == \"\" {\n\t\t\tmessage = \"TCP连接失败\"\n\t\t}\n\t}\n\titem[\"message\"] = message\n\t*results = append(*results, item)\n}\n\nfunc (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, toNode chainNodeRecord, description string, metadata map[string]interface{}, ipPreference string, options diagnosisExecOptions) {\n\tfromNode, _ := h.cachedNode(nodeCache, fromNodeID)\n\ttargetNode, err := h.cachedNode(nodeCache, toNode.NodeID)\n\tif err != nil {\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\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\t}\n\th.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata, options)\n}\n\nfunc 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, connectIp)\n\tif err != nil {\n\t\thost = strings.Trim(strings.TrimSpace(targetNode.ServerIP), \"[]\")\n\t}\n\tif host == \"\" {\n\t\treturn \"\", 0, errors.New(\"目标节点地址为空\")\n\t}\n\tport := preferredPort\n\tif port <= 0 {\n\t\tport = firstPortFromRange(targetNode.PortRange)\n\t}\n\tif port <= 0 {\n\t\tport = 443\n\t}\n\treturn host, port, nil\n}\n\nfunc firstPortFromRange(portRange string) int {\n\tportRange = strings.TrimSpace(portRange)\n\tif portRange == \"\" {\n\t\treturn 0\n\t}\n\tfirst := strings.Split(portRange, \",\")[0]\n\tfirst = strings.TrimSpace(first)\n\tif strings.Contains(first, \"-\") {\n\t\tparts := strings.SplitN(first, \"-\", 2)\n\t\tif len(parts) != 2 {\n\t\t\treturn 0\n\t\t}\n\t\tp, err := strconv.Atoi(strings.TrimSpace(parts[0]))\n\t\tif err != nil || p <= 0 {\n\t\t\treturn 0\n\t\t}\n\t\treturn p\n\t}\n\tp, err := strconv.Atoi(first)\n\tif err != nil || p <= 0 {\n\t\treturn 0\n\t}\n\treturn p\n}\n\nfunc (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, error) {\n\treturn h.repo.ListChainNodesForTunnel(tunnelID)\n}\n\nfunc (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {\n\tif options.commandTimeout <= 0 {\n\t\toptions.commandTimeout = diagnosisCommandTimeout\n\t}\n\tif options.pingTimeoutMS <= 0 {\n\t\toptions.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)\n\t}\n\tres, err := h.sendNodeCommandWithTimeout(nodeID, \"TcpPing\", map[string]interface{}{\n\t\t\"ip\": ip,\n\t\t\"port\": port,\n\t\t\"count\": 4,\n\t\t\"timeout\": options.pingTimeoutMS,\n\t}, options.commandTimeout, false, false)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif res.Data == nil {\n\t\treturn nil, errors.New(\"节点未返回诊断数据\")\n\t}\n\treturn res.Data, nil\n}\n\nfunc (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {\n\tif node == nil {\n\t\treturn nil, errors.New(\"节点不存在\")\n\t}\n\tremoteURL := strings.TrimSpace(node.RemoteURL)\n\tremoteToken := strings.TrimSpace(node.RemoteToken)\n\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\treturn nil, errors.New(\"远程节点缺少共享配置\")\n\t}\n\tif options.commandTimeout <= 0 {\n\t\toptions.commandTimeout = diagnosisCommandTimeout\n\t}\n\tif options.pingTimeoutMS <= 0 {\n\t\toptions.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)\n\t}\n\n\tfc := client.NewFederationClientWithTimeout(options.commandTimeout)\n\treturn fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{\n\t\tIP: strings.TrimSpace(ip),\n\t\tPort: port,\n\t\tCount: 4,\n\t\tTimeout: options.pingTimeoutMS,\n\t})\n}\n\nfunc splitRemoteTargets(remoteAddr string) []string {\n\tparts := strings.Split(remoteAddr, \",\")\n\tout := make([]string, 0, len(parts))\n\tfor _, part := range parts {\n\t\tpart = strings.TrimSpace(part)\n\t\tif part == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tout = append(out, processServerAddress(part))\n\t}\n\treturn out\n}\n\nfunc parseTargetAddress(addr string) (string, int, error) {\n\taddr = strings.TrimSpace(addr)\n\tif addr == \"\" {\n\t\treturn \"\", 0, errors.New(\"empty address\")\n\t}\n\thost, portStr, err := net.SplitHostPort(addr)\n\tif err != nil {\n\t\tidx := strings.LastIndex(addr, \":\")\n\t\tif idx <= 0 || idx >= len(addr)-1 {\n\t\t\treturn \"\", 0, err\n\t\t}\n\t\thost = strings.TrimSpace(addr[:idx])\n\t\tportStr = strings.TrimSpace(addr[idx+1:])\n\t}\n\tport, err := strconv.Atoi(strings.TrimSpace(portStr))\n\tif err != nil || port <= 0 || port > 65535 {\n\t\treturn \"\", 0, errors.New(\"invalid port\")\n\t}\n\thost = strings.Trim(strings.TrimSpace(host), \"[]\")\n\tif host == \"\" {\n\t\treturn \"\", 0, errors.New(\"invalid host\")\n\t}\n\treturn host, port, nil\n}\n\nfunc buildForwardServiceBase(forwardID, userID, userTunnelID int64) string {\n\treturn fmt.Sprintf(\"%d_%d_%d\", forwardID, userID, userTunnelID)\n}\n\nfunc buildForwardServiceBaseCandidates(forwardID, userID, preferredUserTunnelID int64, userTunnelIDs []int64) []string {\n\torderedIDs := make([]int64, 0, len(userTunnelIDs)+2)\n\tseen := make(map[int64]struct{}, len(userTunnelIDs)+2)\n\n\tappendID := func(id int64) {\n\t\tif _, ok := seen[id]; ok {\n\t\t\treturn\n\t\t}\n\t\tseen[id] = struct{}{}\n\t\torderedIDs = append(orderedIDs, id)\n\t}\n\n\tappendID(preferredUserTunnelID)\n\tfor _, id := range userTunnelIDs {\n\t\tappendID(id)\n\t}\n\tappendID(0)\n\n\tbases := make([]string, 0, len(orderedIDs))\n\tfor _, id := range orderedIDs {\n\t\tbases = append(bases, buildForwardServiceBase(forwardID, userID, id))\n\t}\n\treturn bases\n}\n\nfunc buildForwardControlServiceNames(base, commandType string) []string {\n\tnames := []string{base + \"_tcp\", base + \"_udp\"}\n\tif strings.EqualFold(strings.TrimSpace(commandType), \"DeleteService\") {\n\t\treturn append([]string{base}, names...)\n\t}\n\treturn names\n}\n\nfunc shouldTryLegacySingleService(commandType string) bool {\n\tcmd := strings.ToLower(strings.TrimSpace(commandType))\n\treturn cmd == \"pauseservice\" || cmd == \"resumeservice\"\n}\n\nfunc isNotFoundError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tmsg := strings.ToLower(strings.TrimSpace(err.Error()))\n\treturn strings.Contains(msg, \"not found\") || strings.Contains(msg, \"不存在\")\n}\n\nfunc isAlreadyExistsMessage(message string) bool {\n\tmsg := strings.ToLower(strings.TrimSpace(message))\n\tif msg == \"\" {\n\t\treturn false\n\t}\n\tif strings.Contains(msg, \"address already in use\") {\n\t\treturn false\n\t}\n\treturn strings.Contains(msg, \"already exists\") || strings.Contains(msg, \"已存在\")\n}\n\nfunc buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {\n\tprotocols := []string{\"tcp\", \"udp\"}\n\tservices := make([]map[string]interface{}, 0, 2)\n\ttargets := splitRemoteTargets(forward.RemoteAddr)\n\tstrategy := strings.TrimSpace(forward.Strategy)\n\tif strategy == \"\" {\n\t\tstrategy = \"fifo\"\n\t}\n\n\tfor _, protocol := range protocols {\n\t\tlistenerAddr := node.TCPListenAddr\n\t\tif protocol == \"udp\" {\n\t\t\tlistenerAddr = node.UDPListenAddr\n\t\t}\n\t\tif bindIP != \"\" {\n\t\t\tlistenerAddr = bindIP\n\t\t}\n\t\tservice := map[string]interface{}{\n\t\t\t\"name\": fmt.Sprintf(\"%s_%s\", baseName, protocol),\n\t\t\t\"addr\": processServerAddress(fmt.Sprintf(\"%s:%d\", listenerAddr, port)),\n\t\t\t\"handler\": map[string]interface{}{\n\t\t\t\t\"type\": protocol,\n\t\t\t},\n\t\t\t\"listener\": map[string]interface{}{\n\t\t\t\t\"type\": protocol,\n\t\t\t},\n\t\t\t\"forwarder\": map[string]interface{}{\n\t\t\t\t\"nodes\": buildForwarderNodes(targets),\n\t\t\t\t\"selector\": map[string]interface{}{\n\t\t\t\t\t\"strategy\": strategy,\n\t\t\t\t\t\"maxFails\": 1,\n\t\t\t\t\t\"failTimeout\": \"600s\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tif protocol == \"udp\" {\n\t\t\tlistenerMetadata := map[string]interface{}{\"keepAlive\": true}\n\t\t\tif tunnelTLSProtocol {\n\t\t\t\tlistenerMetadata[\"ttl\"] = \"10s\"\n\t\t\t}\n\t\t\tservice[\"listener\"].(map[string]interface{})[\"metadata\"] = listenerMetadata\n\t\t}\n\t\tif tunnel != nil && tunnel.Type == 2 {\n\t\t\tservice[\"handler\"].(map[string]interface{})[\"chain\"] = fmt.Sprintf(\"chains_%d\", forward.TunnelID)\n\t\t}\n\t\tif tunnel != nil && tunnel.Type == 1 && strings.TrimSpace(node.InterfaceName) != \"\" {\n\t\t\tservice[\"metadata\"] = map[string]interface{}{\"interface\": node.InterfaceName}\n\t\t}\n\t\tif limiterID != nil && *limiterID > 0 {\n\t\t\tservice[\"limiter\"] = strconv.FormatInt(*limiterID, 10)\n\t\t}\n\t\tservices = append(services, service)\n\t}\n\n\treturn services\n}\n\nfunc buildForwarderNodes(targets []string) []map[string]interface{} {\n\tnodes := make([]map[string]interface{}, 0, len(targets))\n\tfor i, addr := range targets {\n\t\tnodes = append(nodes, map[string]interface{}{\n\t\t\t\"name\": fmt.Sprintf(\"node_%d\", i+1),\n\t\t\t\"addr\": addr,\n\t\t})\n\t}\n\treturn nodes\n}\n\nfunc processServerAddress(serverAddr string) string {\n\tserverAddr = strings.TrimSpace(serverAddr)\n\tif serverAddr == \"\" {\n\t\treturn serverAddr\n\t}\n\tif strings.HasPrefix(serverAddr, \"[\") {\n\t\treturn serverAddr\n\t}\n\tidx := strings.LastIndex(serverAddr, \":\")\n\tif idx < 0 {\n\t\tif looksLikeIPv6(serverAddr) {\n\t\t\treturn \"[\" + serverAddr + \"]\"\n\t\t}\n\t\treturn serverAddr\n\t}\n\thost := strings.TrimSpace(serverAddr[:idx])\n\tport := strings.TrimSpace(serverAddr[idx+1:])\n\tif host == \"\" || port == \"\" {\n\t\treturn serverAddr\n\t}\n\tif looksLikeIPv6(host) {\n\t\treturn \"[\" + host + \"]:\" + port\n\t}\n\treturn serverAddr\n}\n\nfunc looksLikeIPv6(address string) bool {\n\treturn strings.Count(address, \":\") >= 2\n}\n\nfunc asBool(v interface{}, def bool) bool {\n\ts := strings.TrimSpace(strings.ToLower(asString(v)))\n\tif s == \"\" {\n\t\treturn def\n\t}\n\tswitch s {\n\tcase \"1\", \"t\", \"true\", \"yes\", \"y\":\n\t\treturn true\n\tcase \"0\", \"f\", \"false\", \"no\", \"n\":\n\t\treturn false\n\tdefault:\n\t\treturn def\n\t}\n}\n\nfunc (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) error {\n\tif err := h.upsertLimiterOnNode(nodeID, limiterID, speed); err != nil {\n\t\treturn fmt.Errorf(\"限速规则下发失败: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc buildLimiterAddPayload(limiterID int64, speed int) (string, map[string]interface{}) {\n\trate := float64(speed) / 8.0\n\tlimitStr := fmt.Sprintf(\"$ %.1fMB %.1fMB\", rate, rate)\n\tname := strconv.FormatInt(limiterID, 10)\n\n\treturn name, map[string]interface{}{\n\t\t\"name\": name,\n\t\t\"limits\": []string{limitStr},\n\t}\n}\n\nfunc buildLimiterUpdatePayload(name string, data map[string]interface{}) map[string]interface{} {\n\treturn map[string]interface{}{\n\t\t\"limiter\": name,\n\t\t\"data\": data,\n\t}\n}\n\nfunc (h *Handler) upsertLimiterOnNode(nodeID int64, limiterID int64, speed int) error {\n\tname, addPayload := buildLimiterAddPayload(limiterID, speed)\n\tif _, err := h.sendNodeCommand(nodeID, \"AddLimiters\", addPayload, false, false); err != nil {\n\t\tif !isAlreadyExistsMessage(err.Error()) {\n\t\t\treturn err\n\t\t}\n\t\tpayload := map[string]interface{}{\n\t\t\t\"name\": name,\n\t\t\t\"limits\": addPayload[\"limits\"],\n\t\t}\n\t\tif _, updateErr := h.sendNodeCommand(nodeID, \"UpdateLimiters\", buildLimiterUpdatePayload(name, payload), false, false); updateErr != nil {\n\t\t\treturn updateErr\n\t\t}\n\t}\n\n\treturn nil\n}\n",
|
||
"additions": 6,
|
||
"deletions": 3
|
||
}
|
||
],
|
||
"diagnostics": {
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 161,
|
||
"character": 13
|
||
},
|
||
"end": {
|
||
"line": 161,
|
||
"character": 24
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 356,
|
||
"character": 77
|
||
},
|
||
"end": {
|
||
"line": 356,
|
||
"character": 88
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 436,
|
||
"character": 74
|
||
},
|
||
"end": {
|
||
"line": 436,
|
||
"character": 85
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 573,
|
||
"character": 77
|
||
},
|
||
"end": {
|
||
"line": 573,
|
||
"character": 88
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 583,
|
||
"character": 77
|
||
},
|
||
"end": {
|
||
"line": 583,
|
||
"character": 88
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 596,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 596,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 603,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 603,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 611,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 611,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 624,
|
||
"character": 47
|
||
},
|
||
"end": {
|
||
"line": 624,
|
||
"character": 58
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 632,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 632,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 634,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 634,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 654,
|
||
"character": 47
|
||
},
|
||
"end": {
|
||
"line": 654,
|
||
"character": 58
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 662,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 662,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 664,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 664,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 676,
|
||
"character": 53
|
||
},
|
||
"end": {
|
||
"line": 676,
|
||
"character": 64
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 684,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 684,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 686,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 686,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 696,
|
||
"character": 50
|
||
},
|
||
"end": {
|
||
"line": 696,
|
||
"character": 61
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 728,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 728,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 734,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 734,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 750,
|
||
"character": 75
|
||
},
|
||
"end": {
|
||
"line": 750,
|
||
"character": 86
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 769,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 769,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 771,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 771,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 776,
|
||
"character": 64
|
||
},
|
||
"end": {
|
||
"line": 776,
|
||
"character": 75
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 790,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 790,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 792,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 792,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 797,
|
||
"character": 49
|
||
},
|
||
"end": {
|
||
"line": 797,
|
||
"character": 60
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 807,
|
||
"character": 40
|
||
},
|
||
"end": {
|
||
"line": 807,
|
||
"character": 51
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 811,
|
||
"character": 31
|
||
},
|
||
"end": {
|
||
"line": 811,
|
||
"character": 42
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 817,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 817,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 818,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 818,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 819,
|
||
"character": 39
|
||
},
|
||
"end": {
|
||
"line": 819,
|
||
"character": 50
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 839,
|
||
"character": 47
|
||
},
|
||
"end": {
|
||
"line": 839,
|
||
"character": 58
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 856,
|
||
"character": 24
|
||
},
|
||
"end": {
|
||
"line": 856,
|
||
"character": 35
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 873,
|
||
"character": 54
|
||
},
|
||
"end": {
|
||
"line": 873,
|
||
"character": 65
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 879,
|
||
"character": 51
|
||
},
|
||
"end": {
|
||
"line": 879,
|
||
"character": 62
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 883,
|
||
"character": 56
|
||
},
|
||
"end": {
|
||
"line": 883,
|
||
"character": 67
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 897,
|
||
"character": 33
|
||
},
|
||
"end": {
|
||
"line": 897,
|
||
"character": 44
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 910,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 910,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 921,
|
||
"character": 54
|
||
},
|
||
"end": {
|
||
"line": 921,
|
||
"character": 65
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 929,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 929,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 935,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 935,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 944,
|
||
"character": 52
|
||
},
|
||
"end": {
|
||
"line": 944,
|
||
"character": 63
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 952,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 952,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 958,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 958,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 967,
|
||
"character": 58
|
||
},
|
||
"end": {
|
||
"line": 967,
|
||
"character": 69
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 990,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 990,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 992,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 992,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1064,
|
||
"character": 84
|
||
},
|
||
"end": {
|
||
"line": 1064,
|
||
"character": 95
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1130,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 1130,
|
||
"character": 38
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1146,
|
||
"character": 83
|
||
},
|
||
"end": {
|
||
"line": 1146,
|
||
"character": 94
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1170,
|
||
"character": 90
|
||
},
|
||
"end": {
|
||
"line": 1170,
|
||
"character": 101
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1184,
|
||
"character": 85
|
||
},
|
||
"end": {
|
||
"line": 1184,
|
||
"character": 96
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1404,
|
||
"character": 85
|
||
},
|
||
"end": {
|
||
"line": 1404,
|
||
"character": 96
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1421,
|
||
"character": 130
|
||
},
|
||
"end": {
|
||
"line": 1421,
|
||
"character": 141
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1442,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 1442,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2382,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 2382,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2397,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 2397,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2503,
|
||
"character": 36
|
||
},
|
||
"end": {
|
||
"line": 2503,
|
||
"character": 47
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2591,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 2591,
|
||
"character": 38
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2713,
|
||
"character": 16
|
||
},
|
||
"end": {
|
||
"line": 2713,
|
||
"character": 29
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "stringscut",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/gopls/internal/analysis/modernize#stringscut"
|
||
},
|
||
"source": "stringscut",
|
||
"message": "strings.Index can be simplified using strings.Cut"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2757,
|
||
"character": 15
|
||
},
|
||
"end": {
|
||
"line": 2757,
|
||
"character": 28
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringsseq"
|
||
},
|
||
"source": "stringsseq",
|
||
"message": "Ranging over SplitSeq is more efficient"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2803,
|
||
"character": 38
|
||
},
|
||
"end": {
|
||
"line": 2803,
|
||
"character": 49
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2810,
|
||
"character": 38
|
||
},
|
||
"end": {
|
||
"line": 2810,
|
||
"character": 49
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2818,
|
||
"character": 36
|
||
},
|
||
"end": {
|
||
"line": 2818,
|
||
"character": 47
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_migrate_test.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 230,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 230,
|
||
"character": 85
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#omitzero"
|
||
},
|
||
"source": "omitzero",
|
||
"message": "Omitempty has no effect on nested struct fields"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 383,
|
||
"character": 25
|
||
},
|
||
"end": {
|
||
"line": 383,
|
||
"character": 36
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 392,
|
||
"character": 44
|
||
},
|
||
"end": {
|
||
"line": 392,
|
||
"character": 55
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 400,
|
||
"character": 38
|
||
},
|
||
"end": {
|
||
"line": 400,
|
||
"character": 49
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 410,
|
||
"character": 49
|
||
},
|
||
"end": {
|
||
"line": 410,
|
||
"character": 60
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 415,
|
||
"character": 33
|
||
},
|
||
"end": {
|
||
"line": 415,
|
||
"character": 44
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_mutations.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 78,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 78,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 96,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 96,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 113,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 113,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 156,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 156,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 163,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 163,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 172,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 172,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 198,
|
||
"character": 116
|
||
},
|
||
"end": {
|
||
"line": 198,
|
||
"character": 127
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 198,
|
||
"character": 277
|
||
},
|
||
"end": {
|
||
"line": 198,
|
||
"character": 288
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 241,
|
||
"character": 119
|
||
},
|
||
"end": {
|
||
"line": 241,
|
||
"character": 130
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 247,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 247,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 294,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 294,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 371,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 371,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 380,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 380,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 559,
|
||
"character": 107
|
||
},
|
||
"end": {
|
||
"line": 559,
|
||
"character": 118
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 565,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 565,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 601,
|
||
"character": 70
|
||
},
|
||
"end": {
|
||
"line": 601,
|
||
"character": 81
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 620,
|
||
"character": 62
|
||
},
|
||
"end": {
|
||
"line": 620,
|
||
"character": 73
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 626,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 626,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 648,
|
||
"character": 122
|
||
},
|
||
"end": {
|
||
"line": 648,
|
||
"character": 133
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 654,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 654,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 670,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 670,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 679,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 679,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 722,
|
||
"character": 149
|
||
},
|
||
"end": {
|
||
"line": 722,
|
||
"character": 160
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 728,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 728,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 786,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 786,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 837,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 837,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1179,
|
||
"character": 192
|
||
},
|
||
"end": {
|
||
"line": 1179,
|
||
"character": 203
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1228,
|
||
"character": 80
|
||
},
|
||
"end": {
|
||
"line": 1228,
|
||
"character": 91
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1237,
|
||
"character": 137
|
||
},
|
||
"end": {
|
||
"line": 1237,
|
||
"character": 148
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1265,
|
||
"character": 31
|
||
},
|
||
"end": {
|
||
"line": 1265,
|
||
"character": 42
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1291,
|
||
"character": 30
|
||
},
|
||
"end": {
|
||
"line": 1291,
|
||
"character": 41
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1334,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 1334,
|
||
"character": 38
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1363,
|
||
"character": 10
|
||
},
|
||
"end": {
|
||
"line": 1363,
|
||
"character": 23
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringsseq"
|
||
},
|
||
"source": "stringsseq",
|
||
"message": "Ranging over SplitSeq is more efficient"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_control.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane_test.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go": []
|
||
},
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772506318209,
|
||
"end": 1772506318439
|
||
}
|
||
},
|
||
"id": "prt_cb19bf132001ELxkU7aMJgOpvH",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19bdb46002pDoh87xGuapeO1"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "8cc406b7f6401d9dd934f782ff5c852a6118988e",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 61575,
|
||
"input": 61023,
|
||
"output": 552,
|
||
"reasoning": 111,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb19c066b001A9Z2X1u06EciYU",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19bdb46002pDoh87xGuapeO1"
|
||
},
|
||
{
|
||
"type": "patch",
|
||
"hash": "a9f81f5eb2b2f394bfbbb0512b6a054cce21cae2",
|
||
"files": [
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go"
|
||
],
|
||
"id": "prt_cb19c06b8001tol8tvUYs5Qxh0",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19bdb46002pDoh87xGuapeO1"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506318524,
|
||
"completed": 1772506325836
|
||
},
|
||
"parentID": "msg_cb19b9ae6001Tg0NlwB47EgTPl",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"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": 61729,
|
||
"input": 61491,
|
||
"output": 238,
|
||
"reasoning": 20,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb19c06bc002UREkIC6iFkpWuo",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "8cc406b7f6401d9dd934f782ff5c852a6118988e",
|
||
"id": "prt_cb19c17ef001bCDc9RpjgPBlfb",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c06bc002UREkIC6iFkpWuo"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Updating service config and tests**",
|
||
"time": {
|
||
"start": 1772506322927,
|
||
"end": 1772506325636
|
||
},
|
||
"id": "prt_cb19c17ef002X48zKnwzjjZjmb",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c06bc002UREkIC6iFkpWuo"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_YaqVZhgnnaM69Ffwv3ss1vWA",
|
||
"tool": "apply_patch",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"patchText": "*** Begin Patch\n*** Update File: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go\n@@\n \tservice := map[string]interface{}{\n \t\t\"name\": fmt.Sprintf(\"%d_tls\", tunnelID),\n-\t\t\"addr\": fmt.Sprintf(\"%s:%d\", node.TCPListenAddr, chainNode.Port),\n+\t\t\"addr\": processServerAddress(fmt.Sprintf(\"%s:%d\", defaultString(strings.TrimSpace(chainNode.ConnectIP), node.TCPListenAddr), chainNode.Port)),\n \t\t\"handler\": handlerCfg,\n \t\t\"listener\": map[string]interface{}{\n \t\t\t\"type\": protocol,\n \t\t},\n*** End Patch"
|
||
},
|
||
"output": "Success. Updated the following files:\nM go-backend/internal/http/handler/mutations.go",
|
||
"title": "Success. Updated the following files:\nM go-backend/internal/http/handler/mutations.go",
|
||
"metadata": {
|
||
"diff": "Index: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go\n===================================================================\n--- /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go\n+++ /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go\n@@ -2642,9 +2642,9 @@\n \thandlerCfg[\"metadata\"] = map[string]interface{}{\"nodelay\": true}\n }\n service := map[string]interface{}{\n \t\"name\": fmt.Sprintf(\"%d_tls\", tunnelID),\n-\t\"addr\": fmt.Sprintf(\"%s:%d\", node.TCPListenAddr, chainNode.Port),\n+\t\"addr\": processServerAddress(fmt.Sprintf(\"%s:%d\", defaultString(strings.TrimSpace(chainNode.ConnectIP), node.TCPListenAddr), chainNode.Port)),\n \t\"handler\": handlerCfg,\n \t\"listener\": map[string]interface{}{\n \t\t\"type\": protocol,\n \t},\n\n",
|
||
"files": [
|
||
{
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go",
|
||
"relativePath": "go-backend/internal/http/handler/mutations.go",
|
||
"type": "update",
|
||
"diff": "Index: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go\n===================================================================\n--- /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go\n+++ /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go\n@@ -2642,9 +2642,9 @@\n \thandlerCfg[\"metadata\"] = map[string]interface{}{\"nodelay\": true}\n }\n service := map[string]interface{}{\n \t\"name\": fmt.Sprintf(\"%d_tls\", tunnelID),\n-\t\"addr\": fmt.Sprintf(\"%s:%d\", node.TCPListenAddr, chainNode.Port),\n+\t\"addr\": processServerAddress(fmt.Sprintf(\"%s:%d\", defaultString(strings.TrimSpace(chainNode.ConnectIP), node.TCPListenAddr), chainNode.Port)),\n \t\"handler\": handlerCfg,\n \t\"listener\": map[string]interface{}{\n \t\t\"type\": protocol,\n \t},\n",
|
||
"before": "package handler\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"database/sql\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math/big\"\n\t\"net\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"go-backend/internal/http/client\"\n\t\"go-backend/internal/http/response\"\n\t\"go-backend/internal/security\"\n\t\"go-backend/internal/store/model\"\n\t\"go-backend/internal/store/repo\"\n\n\t\"gorm.io/gorm\"\n)\n\nfunc (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\n\tusername := asString(req[\"user\"])\n\tpwd := asString(req[\"pwd\"])\n\tif username == \"\" || pwd == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户名或密码不能为空\"))\n\t\treturn\n\t}\n\n\texists, err := h.repo.UserExists(username)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif exists {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户名已存在\"))\n\t\treturn\n\t}\n\n\tstatus := asInt(req[\"status\"], 1)\n\tflow := asInt64(req[\"flow\"], 100)\n\tnum := asInt(req[\"num\"], 10)\n\texpTime := asInt64(req[\"expTime\"], time.Now().Add(365*24*time.Hour).UnixMilli())\n\tflowResetTime := asInt64(req[\"flowResetTime\"], 1)\n\troleID := 1\n\tnow := time.Now().UnixMilli()\n\n\tuserID, err := h.repo.CreateUser(username, security.MD5(pwd), roleID, expTime, flow, flowResetTime, num, status, now)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tgroupIDs := asInt64Slice(req[\"groupIds\"])\n\tif len(groupIDs) > 0 {\n\t\tif addErr := h.repo.AddUserToGroups(userID, groupIDs, now); addErr == nil {\n\t\t\tfor _, gid := range groupIDs {\n\t\t\t\t_ = h.syncPermissionsByUserGroup(gid)\n\t\t\t}\n\t\t}\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户ID不能为空\"))\n\t\treturn\n\t}\n\tusername := asString(req[\"user\"])\n\tif username == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户名不能为空\"))\n\t\treturn\n\t}\n\n\troleID, err := h.repo.GetUserRoleID(id)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif roleID == 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请不要作死\"))\n\t\treturn\n\t}\n\n\tdup, err := h.repo.UserExistsExcluding(username, id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif dup {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户名已存在\"))\n\t\treturn\n\t}\n\n\tflow := asInt64(req[\"flow\"], 100)\n\tnum := asInt(req[\"num\"], 10)\n\texpTime := asInt64(req[\"expTime\"], time.Now().Add(365*24*time.Hour).UnixMilli())\n\tflowResetTime := asInt64(req[\"flowResetTime\"], 1)\n\tstatus := asInt(req[\"status\"], 1)\n\tnow := time.Now().UnixMilli()\n\n\tpwd := asString(req[\"pwd\"])\n\tif strings.TrimSpace(pwd) == \"\" {\n\t\tif err := h.repo.UpdateUserWithoutPassword(id, username, flow, num, expTime, flowResetTime, status, now); err != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tif err := h.repo.UpdateUserWithPassword(id, username, security.MD5(pwd), flow, num, expTime, flowResetTime, status, now); err != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\n\th.repo.PropagateUserFlowToTunnels(id, flow, num, expTime, flowResetTime)\n\n\tif groupIDsRaw, ok := req[\"groupIds\"]; ok {\n\t\tnewGroupIDs := asInt64Slice(groupIDsRaw)\n\t\tif affected, replaceErr := h.repo.ReplaceUserGroupsByUserID(id, newGroupIDs, now); replaceErr == nil {\n\t\t\tfor _, gid := range affected {\n\t\t\t\t_ = h.syncPermissionsByUserGroup(gid)\n\t\t\t}\n\t\t}\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userGroups(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tids, err := h.repo.GetUserGroupIDsByUserID(id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OK(ids))\n}\n\nfunc (h *Handler) userDelete(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\n\troleID, err := h.repo.GetUserRoleID(id)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif roleID == 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请不要作死\"))\n\t\treturn\n\t}\n\n\tif err := h.repo.DeleteUserCascade(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userResetFlow(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\ttypeVal := asInt(req[\"type\"], 0)\n\tif id <= 0 || (typeVal != 1 && typeVal != 2) {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\n\tif typeVal == 1 {\n\t\th.repo.ResetUserFlowByUser(id, time.Now().UnixMilli())\n\t} else {\n\t\th.repo.ResetUserFlowByUserTunnel(id)\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tname := asString(req[\"name\"])\n\tserverIP := asString(req[\"serverIp\"])\n\tif name == \"\" || serverIP == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"节点名称和地址不能为空\"))\n\t\treturn\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tinx := h.repo.NextIndex(\"node\")\n\tif err := h.repo.CreateNode(\n\t\tname,\n\t\trandomToken(16),\n\t\tserverIP,\n\t\tnullableText(asString(req[\"serverIpV4\"])),\n\t\tnullableText(asString(req[\"serverIpV6\"])),\n\t\tdefaultString(asString(req[\"port\"]), \"1000-65535\"),\n\t\tnullableText(asString(req[\"interfaceName\"])),\n\t\tnullableText(\"\"),\n\t\tasInt(req[\"http\"], 0),\n\t\tasInt(req[\"tls\"], 0),\n\t\tasInt(req[\"socks\"], 0),\n\t\tnow,\n\t\t0,\n\t\tdefaultString(asString(req[\"tcpListenAddr\"]), \"[::]\"),\n\t\tdefaultString(asString(req[\"udpListenAddr\"]), \"[::]\"),\n\t\tinx,\n\t\tasInt(req[\"isRemote\"], 0),\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\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"节点ID不能为空\"))\n\t\treturn\n\t}\n\n\tcurrentStatus, currentHTTP, currentTLS, currentSocks, err := h.repo.GetNodeStatusFields(id)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"节点不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tnewHTTP := asInt(req[\"http\"], currentHTTP)\n\tnewTLS := asInt(req[\"tls\"], currentTLS)\n\tnewSocks := asInt(req[\"socks\"], currentSocks)\n\tif currentStatus == 1 && (newHTTP != currentHTTP || newTLS != currentTLS || newSocks != currentSocks) {\n\t\tif err := h.applyNodeProtocolChange(id, newHTTP, newTLS, newSocks); err != nil {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tif err := h.repo.UpdateNode(id,\n\t\tasString(req[\"name\"]),\n\t\tasString(req[\"serverIp\"]),\n\t\tnullableText(asString(req[\"serverIpV4\"])),\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\t\tdefaultString(asString(req[\"tcpListenAddr\"]), \"[::]\"),\n\t\tdefaultString(asString(req[\"udpListenAddr\"]), \"[::]\"),\n\t\tnow,\n\t); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeDelete(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tif err := h.deleteNodeByID(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\n\tvar req struct {\n\t\tID int64 `json:\"id\"`\n\t\tChannel string `json:\"channel\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tif req.ID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"参数错误\"))\n\t\treturn\n\t}\n\n\tchannel := normalizeReleaseChannel(req.Channel)\n\tversion, err := resolveLatestReleaseByChannel(channel)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, fmt.Sprintf(\"获取最新%s失败: %v\", releaseChannelLabel(channel), err)))\n\t\treturn\n\t}\n\n\tsecret, err := h.repo.GetNodeSecret(req.ID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"节点不存在\"))\n\t\treturn\n\t}\n\tpanelAddr, err := h.repo.GetViteConfigValue(\"ip\")\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"请先前往网站配置中设置ip\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tcmd := fmt.Sprintf(\"curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && VERSION=%s ./install.sh -a %s -s %s\", version, version, processServerAddress(panelAddr), secret)\n\tresponse.WriteJSON(w, response.OK(cmd))\n}\n\nfunc (h *Handler) nodeUpdateOrder(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req struct {\n\t\tNodes []struct {\n\t\t\tID int64 `json:\"id\"`\n\t\t\tInx int `json:\"inx\"`\n\t\t} `json:\"nodes\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tfor _, n := range req.Nodes {\n\t\th.repo.UpdateNodeOrder(n.ID, n.Inx, time.Now().UnixMilli())\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeBatchDelete(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tfor _, id := range ids {\n\t\t_ = h.deleteNodeByID(id)\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeCheckStatus(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\titems, err := h.repo.ListNodes()\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OK(items))\n}\n\nfunc (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tname := asString(req[\"name\"])\n\tif name == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道名称不能为空\"))\n\t\treturn\n\t}\n\tnameDup, err := h.repo.TunnelNameExists(name)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif nameDup {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道名称重复\"))\n\t\treturn\n\t}\n\n\ttypeVal := asInt(req[\"type\"], 1)\n\tflow := asInt64(req[\"flow\"], 1)\n\tstatus := asInt(req[\"status\"], 1)\n\ttrafficRatio := asFloat(req[\"trafficRatio\"], 1.0)\n\tinIP := asString(req[\"inIp\"])\n\tipPreference := asString(req[\"ipPreference\"])\n\tnow := time.Now().UnixMilli()\n\tinx := h.repo.NextIndex(\"tunnel\")\n\tlocalDomain := h.federationLocalDomain()\n\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\n\truntimeState, err := h.prepareTunnelCreateState(tx, req, typeVal, 0)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\truntimeState.IPPreference = ipPreference\n\tif strings.TrimSpace(inIP) == \"\" {\n\t\tinIP = buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes, ipPreference)\n\t}\n\n\tif len(runtimeState.InNodes) > 0 {\n\t\tfirstNodeID := runtimeState.InNodes[0].NodeID\n\t\tisRemote, rUrl, rToken, _ := h.repo.GetNodeRemoteFieldsTx(tx, firstNodeID)\n\t\tif isRemote == 1 {\n\t\t\tfc := client.NewFederationClient()\n\n\t\t\ttargetProto := \"tcp\"\n\t\t\ttargetPort := 0\n\t\t\ttargetAddr := \"\"\n\n\t\t\tif typeVal == 1 {\n\t\t\t\tif len(runtimeState.OutNodes) > 0 {\n\t\t\t\t\toutNode := runtimeState.OutNodes[0]\n\t\t\t\t\toutNodeRec := runtimeState.Nodes[outNode.NodeID]\n\t\t\t\t\ttargetAddr = processServerAddress(outNodeRec.ServerIP)\n\t\t\t\t\tif outNode.Port > 0 {\n\t\t\t\t\t\ttargetAddr = fmt.Sprintf(\"%s:%d\", targetAddr, outNode.Port)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif len(runtimeState.InNodes) > 0 {\n\t\t\t\t\tinNodesRaw := asMapSlice(req[\"inNodeId\"])\n\t\t\t\t\tif len(inNodesRaw) > 0 {\n\t\t\t\t\t\ttargetPort = asInt(inNodesRaw[0][\"port\"], 0)\n\t\t\t\t\t\ttargetProto = defaultString(asString(inNodesRaw[0][\"protocol\"]), \"tcp\")\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif targetPort > 0 && targetAddr != \"\" {\n\t\t\t\t\tinNodeRec := runtimeState.Nodes[firstNodeID]\n\t\t\t\t\tif err := validateRemoteNodePort(inNodeRec, targetPort); err != nil {\n\t\t\t\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tdomainCfg, _ := h.repo.GetConfigByName(\"panel_domain\")\n\t\t\t\t\tlocalDomain := \"\"\n\t\t\t\t\tif domainCfg != nil {\n\t\t\t\t\t\tlocalDomain = domainCfg.Value\n\t\t\t\t\t}\n\t\t\t\t\t_, err := fc.CreateTunnel(rUrl.String, rToken.String, localDomain, targetProto, targetPort, targetAddr)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"Remote tunnel creation failed: \"+err.Error()))\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tvar tunnelInIP sql.NullString\n\tif trimmed := strings.TrimSpace(inIP); trimmed != \"\" {\n\t\ttunnelInIP = sql.NullString{String: trimmed, Valid: true}\n\t}\n\ttunnel := model.Tunnel{\n\t\tName: name,\n\t\tTrafficRatio: trafficRatio,\n\t\tType: typeVal,\n\t\tProtocol: \"tls\",\n\t\tFlow: flow,\n\t\tCreatedTime: now,\n\t\tUpdatedTime: now,\n\t\tStatus: status,\n\t\tInIP: tunnelInIP,\n\t\tInx: inx,\n\t\tIPPreference: ipPreference,\n\t}\n\tif err := tx.Create(&tunnel).Error; err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\ttunnelID := tunnel.ID\n\truntimeState.TunnelID = tunnelID\n\tvar federationBindings []repo.FederationTunnelBinding\n\tvar federationReleaseRefs []federationRuntimeReleaseRef\n\tfederationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState, localDomain)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tapplyTunnelPortsToRequest(req, runtimeState)\n\tif err := h.replaceTunnelChainsTx(tx, tunnelID, req); err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := tx.Commit().Error; err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif typeVal == 2 {\n\t\tcreatedChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)\n\t\tif applyErr != nil {\n\t\t\th.rollbackTunnelRuntime(createdChains, createdServices, tunnelID)\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\t_ = h.deleteTunnelByID(tunnelID)\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(applyErr.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) cleanupTunnelRuntime(tunnelID int64) {\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil || tunnel.Type != 2 {\n\t\treturn\n\t}\n\tchainRows, err := h.listChainNodesForTunnel(tunnelID)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tserviceName := fmt.Sprintf(\"%d_tls\", tunnelID)\n\tchainName := fmt.Sprintf(\"chains_%d\", tunnelID)\n\n\tfor _, row := range chainRows {\n\t\tif row.ChainType == 1 {\n\t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteChains\", map[string]interface{}{\"chain\": chainName}, false, true)\n\t\t} else if row.ChainType == 2 {\n\t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteChains\", map[string]interface{}{\"chain\": chainName}, false, true)\n\t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteService\", map[string]interface{}{\"services\": []string{serviceName}}, false, true)\n\t\t} else if row.ChainType == 3 {\n\t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteService\", map[string]interface{}{\"services\": []string{serviceName}}, false, true)\n\t\t}\n\t}\n}\n\nfunc (h *Handler) tunnelGet(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\titems, err := h.repo.ListTunnels()\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tfor _, it := range items {\n\t\tif asInt64(it[\"id\"], 0) == id {\n\t\t\tresponse.WriteJSON(w, response.OK(it))\n\t\t\treturn\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.ErrDefault(\"隧道不存在\"))\n}\n\nfunc (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道ID不能为空\"))\n\t\treturn\n\t}\n\n\th.cleanupTunnelRuntime(id)\n\th.cleanupFederationRuntime(id)\n\n\tnow := time.Now().UnixMilli()\n\ttypeVal := asInt(req[\"type\"], 1)\n\tipPreference := asString(req[\"ipPreference\"])\n\tlocalDomain := h.federationLocalDomain()\n\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\n\truntimeState, err := h.prepareTunnelCreateState(tx, req, typeVal, id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\truntimeState.TunnelID = id\n\truntimeState.IPPreference = ipPreference\n\n\tinIp := buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes, ipPreference)\n\n\tvar federationBindings []repo.FederationTunnelBinding\n\tvar federationReleaseRefs []federationRuntimeReleaseRef\n\tfederationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState, localDomain)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tapplyTunnelPortsToRequest(req, runtimeState)\n\n\tif err := h.repo.UpdateTunnelTx(\n\t\ttx,\n\t\tid,\n\t\tasString(req[\"name\"]),\n\t\ttypeVal,\n\t\tasInt64(req[\"flow\"], 1),\n\t\tasFloat(req[\"trafficRatio\"], 1.0),\n\t\tasInt(req[\"status\"], 1),\n\t\tinIp,\n\t\tipPreference,\n\t\tnow,\n\t); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tif err := h.repo.DeleteChainTunnelsByTunnelTx(tx, id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.replaceTunnelChainsTx(tx, id, req); err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.repo.ReplaceFederationTunnelBindingsTx(tx, id, federationBindings); err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := tx.Commit().Error; err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tif typeVal == 2 {\n\t\tcreatedChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)\n\t\tif applyErr != nil {\n\t\t\th.rollbackTunnelRuntime(createdChains, createdServices, id)\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\t_ = h.repo.DeleteFederationTunnelBindingsByTunnel(id)\n\t\t\tif len(federationReleaseRefs) == 0 && shouldDeferTunnelRuntimeApplyError(applyErr) {\n\t\t\t\tresponse.WriteJSON(w, response.OKEmpty())\n\t\t\t\treturn\n\t\t\t}\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(applyErr.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\n\tif forwards, fwdErr := h.listForwardsByTunnel(id); fwdErr == nil {\n\t\tfor i := range forwards {\n\t\t\t_ = h.syncForwardServices(&forwards[i], \"UpdateService\", true)\n\t\t}\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\th.cleanupTunnelRuntime(id)\n\th.cleanupFederationRuntime(id)\n\tif err := h.deleteTunnelByID(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) tunnelDiagnose(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := asInt64FromBodyKey(r, w, \"tunnelId\")\n\tif id <= 0 {\n\t\treturn\n\t}\n\tctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)\n\tdefer cancel()\n\tresult, err := h.diagnoseTunnelRuntime(ctx, id)\n\tif err != nil {\n\t\tif strings.Contains(err.Error(), \"不存在\") || strings.Contains(err.Error(), \"不完整\") {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OK(result))\n}\n\nfunc (h *Handler) tunnelUpdateOrder(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req struct {\n\t\tTunnels []struct {\n\t\t\tID int64 `json:\"id\"`\n\t\t\tInx int `json:\"inx\"`\n\t\t} `json:\"tunnels\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tfor _, t := range req.Tunnels {\n\t\th.repo.UpdateTunnelOrder(t.ID, t.Inx, time.Now().UnixMilli())\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tsuccess := 0\n\tfail := 0\n\tfor _, id := range ids {\n\t\th.cleanupTunnelRuntime(id)\n\t\th.cleanupFederationRuntime(id)\n\t\tif err := h.deleteTunnelByID(id); err != nil {\n\t\t\tfail++\n\t\t} else {\n\t\t\tsuccess++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": success, \"failCount\": fail}))\n}\n\nfunc (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, error) {\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tchainRows, err := h.listChainNodesForTunnel(tunnelID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tipPreference := h.repo.GetTunnelIPPreference(tunnelID)\n\n\tstate := &tunnelCreateState{\n\t\tTunnelID: tunnelID,\n\t\tType: tunnel.Type,\n\t\tIPPreference: ipPreference,\n\t\tInNodes: make([]tunnelRuntimeNode, 0),\n\t\tChainHops: make([][]tunnelRuntimeNode, 0),\n\t\tOutNodes: make([]tunnelRuntimeNode, 0),\n\t\tNodes: make(map[int64]*nodeRecord),\n\t\tNodeIDList: make([]int64, 0),\n\t}\n\n\tinNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)\n\n\tfor _, r := range inNodes {\n\t\tstate.InNodes = append(state.InNodes, tunnelRuntimeNode{\n\t\t\tNodeID: r.NodeID,\n\t\t\tProtocol: r.Protocol,\n\t\t\tStrategy: r.Strategy,\n\t\t\tChainType: 1,\n\t\t})\n\t\tstate.NodeIDList = append(state.NodeIDList, r.NodeID)\n\t}\n\n\tfor _, r := range outNodes {\n\t\tstate.OutNodes = append(state.OutNodes, tunnelRuntimeNode{\n\t\t\tNodeID: r.NodeID,\n\t\t\tProtocol: r.Protocol,\n\t\t\tStrategy: r.Strategy,\n\t\t\tChainType: 3,\n\t\t\tPort: r.Port,\n\t\t})\n\t\tstate.NodeIDList = append(state.NodeIDList, r.NodeID)\n\t}\n\n\tfor _, hop := range chainHops {\n\t\tstateHop := make([]tunnelRuntimeNode, 0)\n\t\tfor _, r := range hop {\n\t\t\tstateHop = append(stateHop, tunnelRuntimeNode{\n\t\t\t\tNodeID: r.NodeID,\n\t\t\t\tProtocol: r.Protocol,\n\t\t\t\tStrategy: r.Strategy,\n\t\t\t\tChainType: 2,\n\t\t\t\tInx: int(r.Inx),\n\t\t\t\tPort: r.Port,\n\t\t\t})\n\t\t\tstate.NodeIDList = append(state.NodeIDList, r.NodeID)\n\t\t}\n\t\tstate.ChainHops = append(state.ChainHops, stateHop)\n\t}\n\n\tseen := make(map[int64]struct{})\n\tfor _, id := range state.NodeIDList {\n\t\tif _, ok := seen[id]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[id] = struct{}{}\n\t\tnode, err := h.getNodeRecord(id)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tstate.Nodes[id] = node\n\t}\n\n\treturn state, nil\n}\n\nfunc (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif tunnel.Type == 2 {\n\t\th.cleanupTunnelRuntime(tunnelID)\n\t\th.cleanupFederationRuntime(tunnelID)\n\t\tstate, err := h.reconstructTunnelState(tunnelID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfederationBindings, federationReleaseRefs, fedErr := h.applyFederationRuntime(state, h.federationLocalDomain())\n\t\tif fedErr != nil {\n\t\t\treturn fedErr\n\t\t}\n\t\ttx := h.repo.BeginTx()\n\t\tif tx.Error != nil {\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\treturn tx.Error\n\t\t}\n\t\tif replaceErr := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); replaceErr != nil {\n\t\t\ttx.Rollback()\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\treturn replaceErr\n\t\t}\n\t\tif commitErr := tx.Commit().Error; commitErr != nil {\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\treturn commitErr\n\t\t}\n\t\t_, _, applyErr := h.applyTunnelRuntime(state)\n\t\tif applyErr != nil {\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\t_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)\n\t\t\treturn applyErr\n\t\t}\n\t}\n\n\tforwards, err := h.listForwardsByTunnel(tunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor i := range forwards {\n\t\tif err := h.syncForwardServices(&forwards[i], \"UpdateService\", true); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tsuccess := 0\n\tfail := 0\n\tfor _, tunnelID := range ids {\n\t\tif err := h.redeployTunnelAndForwards(tunnelID); err != nil {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tsuccess++\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": success, \"failCount\": fail}))\n}\n\nfunc (h *Handler) userTunnelAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tif err := h.upsertUserTunnel(req); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userTunnelBatchAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tUserID int64 `json:\"userId\"`\n\t\tTunnels []struct {\n\t\t\tTunnelID int64 `json:\"tunnelId\"`\n\t\t\tSpeedID *int64 `json:\"speedId\"`\n\t\t} `json:\"tunnels\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.UserID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tfor _, t := range req.Tunnels {\n\t\tm := map[string]interface{}{\"userId\": req.UserID, \"tunnelId\": t.TunnelID}\n\t\tif t.SpeedID != nil {\n\t\t\tm[\"speedId\"] = *t.SpeedID\n\t\t}\n\t\tif err := h.upsertUserTunnel(m); err != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userTunnelRemove(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tif err := h.repo.DeleteUserTunnel(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userTunnelUpdate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"权限ID不能为空\"))\n\t\treturn\n\t}\n\n\tspeedID := asAnyToInt64Ptr(req[\"speedId\"])\n\tif err := h.validateSpeedLimitReference(speedID); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tuserID, tunnelID, utErr := h.repo.GetUserTunnelUserAndTunnel(id)\n\tif utErr != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, utErr.Error()))\n\t\treturn\n\t}\n\n\t_, oldFlow, oldNum, oldExpTime, oldFlowReset, oldSpeedID, oldStatus, oldErr :=\n\t\th.repo.GetExistingUserTunnel(userID, tunnelID)\n\tif oldErr != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, oldErr.Error()))\n\t\treturn\n\t}\n\n\tif err := h.repo.UpdateUserTunnel(id,\n\t\tasInt64(req[\"flow\"], 0),\n\t\tasInt(req[\"num\"], 0),\n\t\tasInt64(req[\"expTime\"], time.Now().Add(365*24*time.Hour).UnixMilli()),\n\t\tasInt64(req[\"flowResetTime\"], 1),\n\t\tnullableInt(speedID),\n\t\tasInt(req[\"status\"], 1),\n\t); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tif syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {\n\t\trollbackErr := h.repo.UpdateUserTunnel(\n\t\t\tid,\n\t\t\toldFlow,\n\t\t\tint(oldNum),\n\t\t\toldExpTime,\n\t\t\toldFlowReset,\n\t\t\toldSpeedID,\n\t\t\toldStatus,\n\t\t)\n\t\tif rollbackErr != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, fmt.Sprintf(\"下发失败且回滚失败: %v; 回滚错误: %v\", syncErr, rollbackErr)))\n\t\t\treturn\n\t\t}\n\n\t\tresponse.WriteJSON(w, response.Err(-2, fmt.Sprintf(\"下发失败,已回滚: %v\", syncErr)))\n\t\treturn\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tuserID, roleID, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ttunnelID := asInt64(req[\"tunnelId\"], 0)\n\tif tunnelID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道ID不能为空\"))\n\t\treturn\n\t}\n\tif err := h.ensureTunnelPermission(userID, roleID, tunnelID); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道不存在\"))\n\t\treturn\n\t}\n\tif tunnel.Status != 1 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道已禁用,无法创建转发\"))\n\t\treturn\n\t}\n\tname := asString(req[\"name\"])\n\tremoteAddr := asString(req[\"remoteAddr\"])\n\tif name == \"\" || remoteAddr == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发名称和目标地址不能为空\"))\n\t\treturn\n\t}\n\tspeedID := asAnyToInt64Ptr(req[\"speedId\"])\n\tif speedID != nil {\n\t\texists, speedErr := h.repo.SpeedLimitExists(*speedID)\n\t\tif speedErr != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, speedErr.Error()))\n\t\t\treturn\n\t\t}\n\t\tif !exists {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"限速规则不存在\"))\n\t\t\treturn\n\t\t}\n\t}\n\tport := asInt(req[\"inPort\"], 0)\n\tif port <= 0 {\n\t\tport = h.pickTunnelPort(tunnelID)\n\t}\n\tif port <= 0 {\n\t\tport = 10000\n\t}\n\tentryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)\n\tfor _, nodeID := range entryNodes {\n\t\tnode, nodeErr := h.getNodeRecord(nodeID)\n\t\tif nodeErr != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif err := validateRemoteNodePort(node, port); err != nil {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tnow := time.Now().UnixMilli()\n\tinx := h.repo.NextIndex(\"forward\")\n\tuserName := h.repo.GetUsernameByID(userID)\n\tif userName == \"\" {\n\t\tuserName = \"user\"\n\t}\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\t}\n\tcreatedForward, err := h.getForwardRecord(forwardID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.syncForwardServices(createdForward, \"UpdateService\", true); err != nil {\n\t\t_ = h.deleteForwardByID(forwardID)\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发ID不能为空\"))\n\t\treturn\n\t}\n\tforward, actorUserID, actorRole, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\toldPorts, err := h.listForwardPorts(id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\ttunnelID := asInt64(req[\"tunnelId\"], forward.TunnelID)\n\tif tunnelID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道ID不能为空\"))\n\t\treturn\n\t}\n\tif err := h.ensureTunnelPermission(actorUserID, actorRole, tunnelID); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道不存在\"))\n\t\treturn\n\t}\n\tif tunnel.Status != 1 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道已禁用,无法更新转发\"))\n\t\treturn\n\t}\n\n\tname := strings.TrimSpace(asString(req[\"name\"]))\n\tif name == \"\" {\n\t\tname = forward.Name\n\t}\n\tremoteAddr := strings.TrimSpace(asString(req[\"remoteAddr\"]))\n\tif remoteAddr == \"\" {\n\t\tremoteAddr = forward.RemoteAddr\n\t}\n\tstrategy := strings.TrimSpace(asString(req[\"strategy\"]))\n\tif strategy == \"\" {\n\t\tstrategy = forward.Strategy\n\t}\n\tspeedID := asAnyToInt64Ptr(req[\"speedId\"])\n\tif speedID != nil {\n\t\texists, speedErr := h.repo.SpeedLimitExists(*speedID)\n\t\tif speedErr != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, speedErr.Error()))\n\t\t\treturn\n\t\t}\n\t\tif !exists {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"限速规则不存在\"))\n\t\t\treturn\n\t\t}\n\t}\n\tnewSpeedID := forward.SpeedID\n\tif speedID != nil {\n\t\tnewSpeedID = sql.NullInt64{Int64: *speedID, Valid: true}\n\t} else if _, ok := req[\"speedId\"]; ok {\n\t\tnewSpeedID = sql.NullInt64{Valid: false}\n\t}\n\n\tport := asInt(req[\"inPort\"], 0)\n\tif port <= 0 {\n\t\tminPort := h.repo.GetMinForwardPort(id)\n\t\tif minPort.Valid {\n\t\t\tport = int(minPort.Int64)\n\t\t}\n\t\tif port <= 0 {\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\t\tif nodeErr != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif err := validateRemoteNodePort(node, port); err != nil {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tnow := time.Now().UnixMilli()\n\tif err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\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\t}\n\tupdatedForward, err := h.getForwardRecord(id)\n\tif err != nil {\n\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.syncForwardServices(updatedForward, \"UpdateService\", true); err != nil {\n\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardDelete(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tforward, _, _, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.controlForwardServices(forward, \"DeleteService\", true); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tif err := h.deleteForwardByID(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardForceDelete(w http.ResponseWriter, r *http.Request) {\n\th.forwardDelete(w, r)\n}\n\nfunc (h *Handler) forwardPause(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tforward, _, _, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.controlForwardServices(forward, \"PauseService\", false); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\t_ = h.repo.UpdateForwardStatus(id, 0, time.Now().UnixMilli())\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardResume(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tforward, _, _, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.controlForwardServices(forward, \"ResumeService\", false); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\t_ = h.repo.UpdateForwardStatus(id, 1, time.Now().UnixMilli())\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardDiagnose(w http.ResponseWriter, r *http.Request) {\n\tid := asInt64FromBodyKey(r, w, \"forwardId\")\n\tif id <= 0 {\n\t\treturn\n\t}\n\tforward, _, _, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)\n\tdefer cancel()\n\tpayload, err := h.diagnoseForwardRuntime(ctx, forward)\n\tif err != nil {\n\t\tif strings.Contains(err.Error(), \"不存在\") || strings.Contains(err.Error(), \"不能为空\") || strings.Contains(err.Error(), \"错误\") {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OK(payload))\n}\n\nfunc (h *Handler) forwardUpdateOrder(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tForwards []struct {\n\t\t\tID int64 `json:\"id\"`\n\t\t\tInx int `json:\"inx\"`\n\t\t} `json:\"forwards\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tfor _, f := range req.Forwards {\n\t\th.repo.UpdateForwardOrder(f.ID, f.Inx, time.Now().UnixMilli())\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardBatchDelete(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.controlForwardServices(forward, \"DeleteService\", true); err != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.deleteForwardByID(id); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": s, \"failCount\": f}))\n}\n\nfunc (h *Handler) forwardBatchPause(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.controlForwardServices(forward, \"PauseService\", false); err != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.repo.UpdateForwardStatus(id, 0, time.Now().UnixMilli()); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": s, \"failCount\": f}))\n}\n\nfunc (h *Handler) forwardBatchResume(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.controlForwardServices(forward, \"ResumeService\", false); err != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.repo.UpdateForwardStatus(id, 1, time.Now().UnixMilli()); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": s, \"failCount\": f}))\n}\n\nfunc (h *Handler) forwardBatchRedeploy(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.syncForwardServices(forward, \"UpdateService\", true); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": s, \"failCount\": f}))\n}\n\nfunc (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tForwardIDs []int64 `json:\"forwardIds\"`\n\t\tTargetTunnelID int64 `json:\"targetTunnelId\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.TargetTunnelID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\tif err := h.ensureTunnelPermission(actorUserID, actorRole, req.TargetTunnelID); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\ttargetTunnel, err := h.getTunnelRecord(req.TargetTunnelID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"目标隧道不存在\"))\n\t\treturn\n\t}\n\tif targetTunnel.Status != 1 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"目标隧道已禁用\"))\n\t\treturn\n\t}\n\tsuccess := 0\n\tfail := 0\n\tfor _, id := range req.ForwardIDs {\n\t\tif id <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tif forward.TunnelID == req.TargetTunnelID {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\toldPorts, listPortsErr := h.listForwardPorts(id)\n\t\tif listPortsErr != nil {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tport := h.repo.GetMinForwardPort(id)\n\t\tif err := h.repo.UpdateForwardTunnel(id, req.TargetTunnelID, time.Now().UnixMilli()); err != nil {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tp := 0\n\t\tif port.Valid {\n\t\t\tp = int(port.Int64)\n\t\t}\n\t\tif p <= 0 {\n\t\t\tp = h.pickTunnelPort(req.TargetTunnelID)\n\t\t}\n\t\tbctEntryNodes, _ := h.tunnelEntryNodeIDs(req.TargetTunnelID)\n\t\tportRangeOk := true\n\t\tfor _, nid := range bctEntryNodes {\n\t\t\tnd, ndErr := h.getNodeRecord(nid)\n\t\t\tif ndErr != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif validateRemoteNodePort(nd, p) != nil {\n\t\t\t\tportRangeOk = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !portRangeOk {\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\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tupdatedForward, fetchErr := h.getForwardRecord(id)\n\t\tif fetchErr != nil {\n\t\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.syncForwardServices(updatedForward, \"UpdateService\", true); err != nil {\n\t\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tsuccess++\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": success, \"failCount\": fail}))\n}\n\nfunc (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\n\tname := asString(req[\"name\"])\n\tif name == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"名称不能为空\"))\n\t\treturn\n\t}\n\n\tspeed := asInt(req[\"speed\"], 100)\n\n\tnow := time.Now().UnixMilli()\n\t_, err := h.repo.CreateSpeedLimit(name, speed, now, asInt(req[\"status\"], 1))\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) speedLimitUpdate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"限速规则ID不能为空\"))\n\t\treturn\n\t}\n\n\tname := asString(req[\"name\"])\n\tif name == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"名称不能为空\"))\n\t\treturn\n\t}\n\n\tspeed := asInt(req[\"speed\"], 100)\n\n\tif err := h.repo.UpdateSpeedLimit(id, name, speed, asInt(req[\"status\"], 1), time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) speedLimitDelete(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\n\tif err := h.repo.DeleteSpeedLimit(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupTunnelCreate(w http.ResponseWriter, r *http.Request) {\n\th.groupCreate(w, r, \"tunnel_group\")\n}\n\nfunc (h *Handler) groupTunnelUpdate(w http.ResponseWriter, r *http.Request) {\n\th.groupUpdate(w, r, \"tunnel_group\")\n}\n\nfunc (h *Handler) groupTunnelDelete(w http.ResponseWriter, r *http.Request) {\n\th.groupDelete(w, r, \"tunnel_group\")\n}\n\nfunc (h *Handler) groupUserCreate(w http.ResponseWriter, r *http.Request) {\n\th.groupCreate(w, r, \"user_group\")\n}\n\nfunc (h *Handler) groupUserUpdate(w http.ResponseWriter, r *http.Request) {\n\th.groupUpdate(w, r, \"user_group\")\n}\n\nfunc (h *Handler) groupUserDelete(w http.ResponseWriter, r *http.Request) {\n\th.groupDelete(w, r, \"user_group\")\n}\n\nfunc (h *Handler) groupTunnelAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tGroupID int64 `json:\"groupId\"`\n\t\tTunnelIDs []int64 `json:\"tunnelIds\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.GroupID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\tif err := h.repo.ReplaceTunnelGroupMembersTx(tx, req.GroupID, req.TunnelIDs, time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := tx.Commit().Error; err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\t_ = h.syncPermissionsByTunnelGroup(req.GroupID)\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupUserAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tGroupID int64 `json:\"groupId\"`\n\t\tUserIDs []int64 `json:\"userIds\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.GroupID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\tpreviousUserIDs, err := h.repo.ListUserIDsByUserGroupTx(tx, req.GroupID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.repo.ReplaceUserGroupMembersTx(tx, req.GroupID, req.UserIDs, time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.repo.RevokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := tx.Commit().Error; err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\t_ = h.syncPermissionsByUserGroup(req.GroupID)\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupPermissionAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tUserGroupID int64 `json:\"userGroupId\"`\n\t\tTunnelGroupID int64 `json:\"tunnelGroupId\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.UserGroupID <= 0 || req.TunnelGroupID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tif err := h.repo.InsertGroupPermission(req.UserGroupID, req.TunnelGroupID, time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\t_ = h.applyGroupPermission(req.UserGroupID, req.TunnelGroupID)\n\tresponse.WriteJSON(w, response.OK(\"权限分配成功\"))\n}\n\nfunc (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\n\tug, tg, exists, err := h.repo.GetGroupPermissionPairByIDTx(tx, id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tif err := h.repo.DeleteGroupPermissionByIDTx(tx, id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif exists {\n\t\tif err := h.repo.RevokeGroupPermissionPairTx(tx, ug, tg); err != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\n\tif err := tx.Commit().Error; err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupCreate(w http.ResponseWriter, r *http.Request, table string) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tname := asString(req[\"name\"])\n\tif name == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"分组名称不能为空\"))\n\t\treturn\n\t}\n\tnow := time.Now().UnixMilli()\n\tif err := h.repo.GroupCreate(table, name, asInt(req[\"status\"], 1), now); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupUpdate(w http.ResponseWriter, r *http.Request, table string) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"分组ID不能为空\"))\n\t\treturn\n\t}\n\tif err := h.repo.GroupUpdate(table, id, asString(req[\"name\"]), asInt(req[\"status\"], 1), time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupDelete(w http.ResponseWriter, r *http.Request, table string) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tif err := h.repo.GroupDeleteCascade(table, id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) applyGroupPermission(userGroupID, tunnelGroupID int64) error {\n\tuserIDs, _ := h.repo.ListUserIDsByUserGroup(userGroupID)\n\ttunnelIDs, _ := h.repo.ListTunnelIDsByTunnelGroup(tunnelGroupID)\n\tfor _, uid := range userIDs {\n\t\tfor _, tid := range tunnelIDs {\n\t\t\tutID, created, err := h.repo.EnsureUserTunnelGrant(uid, tid)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcreatedByGroup := 0\n\t\t\tif created {\n\t\t\t\tcreatedByGroup = 1\n\t\t\t}\n\t\t\th.repo.InsertGroupPermissionGrant(userGroupID, tunnelGroupID, utID, createdByGroup, time.Now().UnixMilli())\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) syncPermissionsByUserGroup(userGroupID int64) error {\n\tpairs, err := h.repo.ListGroupPermissionPairsByUserGroup(userGroupID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, p := range pairs {\n\t\t_ = h.applyGroupPermission(p[0], p[1])\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) syncPermissionsByTunnelGroup(tunnelGroupID int64) error {\n\tpairs, err := h.repo.ListGroupPermissionPairsByTunnelGroup(tunnelGroupID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, p := range pairs {\n\t\t_ = h.applyGroupPermission(p[0], p[1])\n\t}\n\treturn nil\n}\n\ntype tunnelRuntimeNode struct {\n\tNodeID int64\n\tProtocol string\n\tStrategy string\n\tInx int\n\tChainType int\n\tPort int\n\tConnectIP string\n}\n\ntype tunnelCreateState struct {\n\tTunnelID int64\n\tType int\n\tIPPreference string // \"\" = auto, \"v4\" = prefer IPv4, \"v6\" = prefer IPv6\n\tInNodes []tunnelRuntimeNode\n\tChainHops [][]tunnelRuntimeNode\n\tOutNodes []tunnelRuntimeNode\n\tNodes map[int64]*nodeRecord\n\tNodeIDList []int64\n}\n\nfunc (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) {\n\tstate := &tunnelCreateState{\n\t\tType: tunnelType,\n\t\tInNodes: make([]tunnelRuntimeNode, 0),\n\t\tChainHops: make([][]tunnelRuntimeNode, 0),\n\t\tOutNodes: make([]tunnelRuntimeNode, 0),\n\t\tNodes: make(map[int64]*nodeRecord),\n\t}\n\tnodeIDs := make([]int64, 0)\n\n\tfor _, item := range asMapSlice(req[\"inNodeId\"]) {\n\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\tif nodeID <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tnodeIDs = append(nodeIDs, nodeID)\n\t\tstate.InNodes = append(state.InNodes, tunnelRuntimeNode{\n\t\t\tNodeID: nodeID,\n\t\t\tProtocol: defaultString(asString(item[\"protocol\"]), \"tls\"),\n\t\t\tStrategy: defaultString(asString(item[\"strategy\"]), \"round\"),\n\t\t\tChainType: 1,\n\t\t})\n\t}\n\tif len(state.InNodes) == 0 {\n\t\treturn nil, errors.New(\"入口不能为空\")\n\t}\n\n\tif tunnelType == 2 {\n\t\toutNodesRaw := asMapSlice(req[\"outNodeId\"])\n\t\tif len(outNodesRaw) == 0 {\n\t\t\treturn nil, errors.New(\"出口不能为空\")\n\t\t}\n\n\t\tallocated := map[int64]int{}\n\t\tfor _, item := range outNodesRaw {\n\t\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\t\tif nodeID <= 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tnodeIDs = append(nodeIDs, nodeID)\n\t\t\tport := asInt(item[\"port\"], 0)\n\t\t\tif port <= 0 {\n\t\t\t\tisRemote, remoteErr := h.repo.IsRemoteNodeTx(tx, nodeID)\n\t\t\t\tif remoteErr != nil {\n\t\t\t\t\treturn nil, remoteErr\n\t\t\t\t}\n\t\t\t\tif !isRemote {\n\t\t\t\t\tvar err error\n\t\t\t\t\tport, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn nil, err\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tstate.OutNodes = append(state.OutNodes, tunnelRuntimeNode{\n\t\t\t\tNodeID: nodeID,\n\t\t\t\tProtocol: defaultString(asString(item[\"protocol\"]), \"tls\"),\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\t\t\treturn nil, errors.New(\"出口不能为空\")\n\t\t}\n\n\t\tfor hopIdx, hopRaw := range asAnySlice(req[\"chainNodes\"]) {\n\t\t\thop := make([]tunnelRuntimeNode, 0)\n\t\t\tfor _, item := range asMapSlice(hopRaw) {\n\t\t\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\t\t\tif nodeID <= 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tnodeIDs = append(nodeIDs, nodeID)\n\t\t\t\tport := asInt(item[\"port\"], 0)\n\t\t\t\tif port <= 0 {\n\t\t\t\t\tisRemote, remoteErr := h.repo.IsRemoteNodeTx(tx, nodeID)\n\t\t\t\t\tif remoteErr != nil {\n\t\t\t\t\t\treturn nil, remoteErr\n\t\t\t\t\t}\n\t\t\t\t\tif !isRemote {\n\t\t\t\t\t\tvar err error\n\t\t\t\t\t\tport, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\treturn nil, err\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thop = append(hop, tunnelRuntimeNode{\n\t\t\t\t\tNodeID: nodeID,\n\t\t\t\t\tProtocol: defaultString(asString(item[\"protocol\"]), \"tls\"),\n\t\t\t\t\tStrategy: defaultString(asString(item[\"strategy\"]), \"round\"),\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\t\t\t\tstate.ChainHops = append(state.ChainHops, hop)\n\t\t\t}\n\t\t}\n\t}\n\n\tseen := make(map[int64]struct{}, len(nodeIDs))\n\tfor _, nodeID := range nodeIDs {\n\t\tif _, ok := seen[nodeID]; ok {\n\t\t\treturn nil, errors.New(\"节点重复\")\n\t\t}\n\t\tseen[nodeID] = struct{}{}\n\t\tstate.NodeIDList = append(state.NodeIDList, nodeID)\n\t\tnode, err := h.repo.GetNodeRecordTx(tx, nodeID)\n\t\tif err != nil {\n\t\t\tif strings.Contains(err.Error(), \"不存在\") {\n\t\t\t\treturn nil, errors.New(\"节点不存在\")\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tif node == nil {\n\t\t\treturn nil, errors.New(\"节点不存在\")\n\t\t}\n\t\tif node.IsRemote != 1 && node.Status != 1 {\n\t\t\treturn nil, errors.New(\"部分节点不在线\")\n\t\t}\n\t\tstate.Nodes[nodeID] = node\n\t}\n\n\tfor _, outNode := range state.OutNodes {\n\t\tif err := validateRemoteNodePort(state.Nodes[outNode.NodeID], outNode.Port); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tfor _, hop := range state.ChainHops {\n\t\tfor _, chainNode := range hop {\n\t\t\tif err := validateRemoteNodePort(state.Nodes[chainNode.NodeID], chainNode.Port); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn state, nil\n}\n\nfunc buildTunnelInIP(inNodes []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) string {\n\tset := make(map[string]struct{})\n\tordered := make([]string, 0)\n\tpreferV6 := strings.TrimSpace(ipPreference) == \"v6\"\n\tfor _, inNode := range inNodes {\n\t\tnode := nodes[inNode.NodeID]\n\t\tif node == nil {\n\t\t\tcontinue\n\t\t}\n\t\tv4 := strings.TrimSpace(node.ServerIPv4)\n\t\tv6 := strings.TrimSpace(node.ServerIPv6)\n\t\tvar addrs []string\n\t\tif preferV6 {\n\t\t\tif v6 != \"\" {\n\t\t\t\taddrs = append(addrs, v6)\n\t\t\t}\n\t\t\tif v4 != \"\" {\n\t\t\t\taddrs = append(addrs, v4)\n\t\t\t}\n\t\t} else {\n\t\t\tif v4 != \"\" {\n\t\t\t\taddrs = append(addrs, v4)\n\t\t\t}\n\t\t\tif v6 != \"\" {\n\t\t\t\taddrs = append(addrs, v6)\n\t\t\t}\n\t\t}\n\t\tif len(addrs) == 0 {\n\t\t\tif v := strings.TrimSpace(node.ServerIP); v != \"\" {\n\t\t\t\taddrs = append(addrs, v)\n\t\t\t}\n\t\t}\n\t\tfor _, a := range addrs {\n\t\t\tif _, ok := set[a]; !ok {\n\t\t\t\tset[a] = struct{}{}\n\t\t\t\tordered = append(ordered, a)\n\t\t\t}\n\t\t}\n\t}\n\treturn strings.Join(ordered, \",\")\n}\n\nfunc applyTunnelPortsToRequest(req map[string]interface{}, state *tunnelCreateState) {\n\tif req == nil || state == nil {\n\t\treturn\n\t}\n\toutPorts := make(map[int64]int)\n\tfor _, n := range state.OutNodes {\n\t\toutPorts[n.NodeID] = n.Port\n\t}\n\tfor _, item := range asMapSlice(req[\"outNodeId\"]) {\n\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\tif port, ok := outPorts[nodeID]; ok && port > 0 {\n\t\t\titem[\"port\"] = port\n\t\t}\n\t}\n\n\tchainPorts := make(map[int64]int)\n\tfor _, hop := range state.ChainHops {\n\t\tfor _, n := range hop {\n\t\t\tchainPorts[n.NodeID] = n.Port\n\t\t}\n\t}\n\tfor _, hopRaw := range asAnySlice(req[\"chainNodes\"]) {\n\t\tfor _, item := range asMapSlice(hopRaw) {\n\t\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\t\tif port, ok := chainPorts[nodeID]; ok && port > 0 {\n\t\t\t\titem[\"port\"] = port\n\t\t\t}\n\t\t}\n\t}\n}\n\ntype federationRuntimeReleaseRef struct {\n\tRemoteURL string\n\tRemoteToken string\n\tBindingID string\n\tReservationID string\n\tResourceKey string\n}\n\nfunc federationRuntimeResourceKey(tunnelID int64, nodeID int64, chainType int, hopInx int) string {\n\treturn fmt.Sprintf(\"tunnel:%d:node:%d:type:%d:hop:%d\", tunnelID, nodeID, chainType, hopInx)\n}\n\nfunc remoteShareIDFromConfig(raw string) int64 {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn 0\n\t}\n\tvar cfg map[string]interface{}\n\tif err := json.Unmarshal([]byte(raw), &cfg); err != nil {\n\t\treturn 0\n\t}\n\treturn asInt64(cfg[\"shareId\"], 0)\n}\n\nfunc (h *Handler) federationLocalDomain() string {\n\tcfg, _ := h.repo.GetConfigByName(\"panel_domain\")\n\tif cfg == nil {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(cfg.Value)\n}\n\nfunc (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain string) ([]repo.FederationTunnelBinding, []federationRuntimeReleaseRef, error) {\n\tbindings := make([]repo.FederationTunnelBinding, 0)\n\treleaseRefs := make([]federationRuntimeReleaseRef, 0)\n\tif h == nil || state == nil {\n\t\treturn bindings, releaseRefs, nil\n\t}\n\tfc := client.NewFederationClient()\n\tnow := time.Now().UnixMilli()\n\n\tfor outIdx := range state.OutNodes {\n\t\toutNode := state.OutNodes[outIdx]\n\t\tnode := state.Nodes[outNode.NodeID]\n\t\tif node == nil || node.IsRemote != 1 {\n\t\t\tcontinue\n\t\t}\n\t\tremoteURL := strings.TrimSpace(node.RemoteURL)\n\t\tremoteToken := strings.TrimSpace(node.RemoteToken)\n\t\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 缺少共享配置\", nodeDisplayName(node))\n\t\t}\n\n\t\tresourceKey := federationRuntimeResourceKey(state.TunnelID, outNode.NodeID, 3, 0)\n\t\treserveReq := client.RuntimeReservePortRequest{\n\t\t\tResourceKey: resourceKey,\n\t\t\tProtocol: defaultString(outNode.Protocol, \"tls\"),\n\t\t\tRequestedPort: outNode.Port,\n\t\t}\n\t\treserveRes, err := fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n\t\tif err != nil && reserveReq.RequestedPort > 0 {\n\t\t\treserveReq.RequestedPort = 0\n\t\t\treserveRes, err = fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n\t\t}\n\t\tif err != nil {\n\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 端口分配失败: %w\", nodeDisplayName(node), err)\n\t\t}\n\n\t\tstate.OutNodes[outIdx].Port = reserveRes.AllocatedPort\n\t\toutNode = state.OutNodes[outIdx]\n\n\t\tapplyReq := client.RuntimeApplyRoleRequest{\n\t\t\tReservationID: reserveRes.ReservationID,\n\t\t\tResourceKey: resourceKey,\n\t\t\tRole: \"exit\",\n\t\t\tProtocol: defaultString(outNode.Protocol, \"tls\"),\n\t\t\tStrategy: defaultString(outNode.Strategy, \"round\"),\n\t\t}\n\t\tapplyRes, err := fc.ApplyRole(remoteURL, remoteToken, localDomain, applyReq)\n\t\tif err != nil {\n\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 运行时下发失败: %w\", nodeDisplayName(node), err)\n\t\t}\n\t\tif applyRes.AllocatedPort > 0 {\n\t\t\tstate.OutNodes[outIdx].Port = applyRes.AllocatedPort\n\t\t\toutNode = state.OutNodes[outIdx]\n\t\t}\n\n\t\tbindings = append(bindings, repo.FederationTunnelBinding{\n\t\t\tTunnelID: state.TunnelID,\n\t\t\tNodeID: outNode.NodeID,\n\t\t\tChainType: 3,\n\t\t\tHopInx: 0,\n\t\t\tRemoteURL: remoteURL,\n\t\t\tResourceKey: resourceKey,\n\t\t\tRemoteBindingID: defaultString(applyRes.BindingID, reserveRes.BindingID),\n\t\t\tAllocatedPort: outNode.Port,\n\t\t\tStatus: 1,\n\t\t\tCreatedTime: now,\n\t\t\tUpdatedTime: now,\n\t\t})\n\t\treleaseRefs = append(releaseRefs, federationRuntimeReleaseRef{\n\t\t\tRemoteURL: remoteURL,\n\t\t\tRemoteToken: remoteToken,\n\t\t\tBindingID: applyRes.BindingID,\n\t\t\tReservationID: reserveRes.ReservationID,\n\t\t\tResourceKey: resourceKey,\n\t\t})\n\t}\n\n\tfor hopIdx := len(state.ChainHops) - 1; hopIdx >= 0; hopIdx-- {\n\t\tfor nodeIdx := range state.ChainHops[hopIdx] {\n\t\t\tchainNode := state.ChainHops[hopIdx][nodeIdx]\n\t\t\tnode := state.Nodes[chainNode.NodeID]\n\t\t\tif node == nil || node.IsRemote != 1 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tremoteURL := strings.TrimSpace(node.RemoteURL)\n\t\t\tremoteToken := strings.TrimSpace(node.RemoteToken)\n\t\t\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 缺少共享配置\", nodeDisplayName(node))\n\t\t\t}\n\n\t\t\tresourceKey := federationRuntimeResourceKey(state.TunnelID, chainNode.NodeID, 2, hopIdx+1)\n\t\t\treserveReq := client.RuntimeReservePortRequest{\n\t\t\t\tResourceKey: resourceKey,\n\t\t\t\tProtocol: defaultString(chainNode.Protocol, \"tls\"),\n\t\t\t\tRequestedPort: chainNode.Port,\n\t\t\t}\n\t\t\treserveRes, err := fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n\t\t\tif err != nil && reserveReq.RequestedPort > 0 {\n\t\t\t\treserveReq.RequestedPort = 0\n\t\t\t\treserveRes, err = fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 端口分配失败: %w\", nodeDisplayName(node), err)\n\t\t\t}\n\n\t\t\tstate.ChainHops[hopIdx][nodeIdx].Port = reserveRes.AllocatedPort\n\t\t\tchainNode = state.ChainHops[hopIdx][nodeIdx]\n\n\t\t\tnextTargets := state.OutNodes\n\t\t\tif hopIdx+1 < len(state.ChainHops) {\n\t\t\t\tnextTargets = state.ChainHops[hopIdx+1]\n\t\t\t}\n\t\t\tapplyTargets := make([]client.RuntimeTarget, 0, len(nextTargets))\n\t\t\tfor _, target := range nextTargets {\n\t\t\t\ttargetNode := state.Nodes[target.NodeID]\n\t\t\t\tif targetNode == nil {\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, 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\t\t\t\t}\n\t\t\t\tif target.Port <= 0 {\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\tapplyTargets = append(applyTargets, client.RuntimeTarget{\n\t\t\t\t\tHost: host,\n\t\t\t\t\tPort: target.Port,\n\t\t\t\t\tProtocol: defaultString(target.Protocol, \"tls\"),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tapplyReq := client.RuntimeApplyRoleRequest{\n\t\t\t\tReservationID: reserveRes.ReservationID,\n\t\t\t\tResourceKey: resourceKey,\n\t\t\t\tRole: \"middle\",\n\t\t\t\tProtocol: defaultString(chainNode.Protocol, \"tls\"),\n\t\t\t\tStrategy: defaultString(chainNode.Strategy, \"round\"),\n\t\t\t\tTargets: applyTargets,\n\t\t\t}\n\t\t\tapplyRes, err := fc.ApplyRole(remoteURL, remoteToken, localDomain, applyReq)\n\t\t\tif err != nil {\n\t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 运行时下发失败: %w\", nodeDisplayName(node), err)\n\t\t\t}\n\t\t\tif applyRes.AllocatedPort > 0 {\n\t\t\t\tstate.ChainHops[hopIdx][nodeIdx].Port = applyRes.AllocatedPort\n\t\t\t\tchainNode = state.ChainHops[hopIdx][nodeIdx]\n\t\t\t}\n\n\t\t\tbindings = append(bindings, repo.FederationTunnelBinding{\n\t\t\t\tTunnelID: state.TunnelID,\n\t\t\t\tNodeID: chainNode.NodeID,\n\t\t\t\tChainType: 2,\n\t\t\t\tHopInx: hopIdx + 1,\n\t\t\t\tRemoteURL: remoteURL,\n\t\t\t\tResourceKey: resourceKey,\n\t\t\t\tRemoteBindingID: defaultString(applyRes.BindingID, reserveRes.BindingID),\n\t\t\t\tAllocatedPort: chainNode.Port,\n\t\t\t\tStatus: 1,\n\t\t\t\tCreatedTime: now,\n\t\t\t\tUpdatedTime: now,\n\t\t\t})\n\t\t\treleaseRefs = append(releaseRefs, federationRuntimeReleaseRef{\n\t\t\t\tRemoteURL: remoteURL,\n\t\t\t\tRemoteToken: remoteToken,\n\t\t\t\tBindingID: applyRes.BindingID,\n\t\t\t\tReservationID: reserveRes.ReservationID,\n\t\t\t\tResourceKey: resourceKey,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn bindings, releaseRefs, nil\n}\n\nfunc (h *Handler) releaseFederationRuntimeRefs(refs []federationRuntimeReleaseRef) {\n\tif h == nil || len(refs) == 0 {\n\t\treturn\n\t}\n\tfc := client.NewFederationClient()\n\tlocalDomain := h.federationLocalDomain()\n\tfor i := len(refs) - 1; i >= 0; i-- {\n\t\tref := refs[i]\n\t\tif strings.TrimSpace(ref.RemoteURL) == \"\" || strings.TrimSpace(ref.RemoteToken) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\treq := client.RuntimeReleaseRoleRequest{\n\t\t\tBindingID: ref.BindingID,\n\t\t\tReservationID: ref.ReservationID,\n\t\t\tResourceKey: ref.ResourceKey,\n\t\t}\n\t\t_ = fc.ReleaseRole(ref.RemoteURL, ref.RemoteToken, localDomain, req)\n\t}\n}\n\nfunc (h *Handler) cleanupFederationRuntime(tunnelID int64) {\n\tif h == nil || tunnelID <= 0 {\n\t\treturn\n\t}\n\tbindings, err := h.repo.ListActiveFederationTunnelBindingsByTunnel(tunnelID)\n\tif err != nil || len(bindings) == 0 {\n\t\treturn\n\t}\n\n\tfc := client.NewFederationClient()\n\tlocalDomain := h.federationLocalDomain()\n\tfor _, b := range bindings {\n\t\tnode, nodeErr := h.repo.GetNodeByID(b.NodeID)\n\t\tif nodeErr != nil || node == nil {\n\t\t\tcontinue\n\t\t}\n\t\tremoteURL := strings.TrimSpace(node.RemoteURL.String)\n\t\tif remoteURL == \"\" {\n\t\t\tremoteURL = strings.TrimSpace(b.RemoteURL)\n\t\t}\n\t\tremoteToken := strings.TrimSpace(node.RemoteToken.String)\n\t\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\treq := client.RuntimeReleaseRoleRequest{\n\t\t\tBindingID: strings.TrimSpace(b.RemoteBindingID),\n\t\t\tResourceKey: strings.TrimSpace(b.ResourceKey),\n\t\t}\n\t\t_ = fc.ReleaseRole(remoteURL, remoteToken, localDomain, req)\n\t}\n\t_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)\n}\n\nfunc (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64, error) {\n\tif h == nil || state == nil {\n\t\treturn nil, nil, errors.New(\"invalid tunnel runtime state\")\n\t}\n\tcreatedChains := make([]int64, 0)\n\tcreatedServices := make([]int64, 0)\n\tif state.Type != 2 {\n\t\treturn createdChains, createdServices, nil\n\t}\n\n\tfor _, inNode := range state.InNodes {\n\t\tnode := state.Nodes[inNode.NodeID]\n\t\ttargets := state.OutNodes\n\t\tif len(state.ChainHops) > 0 {\n\t\t\ttargets = state.ChainHops[0]\n\t\t}\n\t\tchainData, err := buildTunnelChainConfig(state.TunnelID, inNode.NodeID, targets, state.Nodes, state.IPPreference)\n\t\tif err != nil {\n\t\t\treturn createdChains, createdServices, err\n\t\t}\n\t\tif _, err := h.sendNodeCommand(inNode.NodeID, \"AddChains\", chainData, true, false); err != nil {\n\t\t\tif node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn createdChains, createdServices, fmt.Errorf(\"入口节点 %s 下发转发链失败: %w\", nodeDisplayName(state.Nodes[inNode.NodeID]), err)\n\t\t}\n\t\tcreatedChains = append(createdChains, inNode.NodeID)\n\t}\n\n\tfor i, hop := range state.ChainHops {\n\t\tnextTargets := state.OutNodes\n\t\tif i+1 < len(state.ChainHops) {\n\t\t\tnextTargets = state.ChainHops[i+1]\n\t\t}\n\t\tfor _, chainNode := range hop {\n\t\t\tif node := state.Nodes[chainNode.NodeID]; node != nil && node.IsRemote == 1 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tchainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)\n\t\t\tif err != nil {\n\t\t\t\treturn createdChains, createdServices, err\n\t\t\t}\n\t\t\tif _, err := h.sendNodeCommand(chainNode.NodeID, \"AddChains\", chainData, true, false); err != nil {\n\t\t\t\treturn createdChains, createdServices, fmt.Errorf(\"转发链节点 %s 下发转发链失败: %w\", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)\n\t\t\t}\n\t\t\tcreatedChains = append(createdChains, chainNode.NodeID)\n\n\t\t\tserviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID])\n\t\t\tif _, err := h.sendNodeCommand(chainNode.NodeID, \"AddService\", serviceData, true, false); err != nil {\n\t\t\t\treturn createdChains, createdServices, fmt.Errorf(\"转发链节点 %s 下发服务失败: %w\", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)\n\t\t\t}\n\t\t\tcreatedServices = append(createdServices, chainNode.NodeID)\n\t\t}\n\t}\n\n\tfor _, outNode := range state.OutNodes {\n\t\tif node := state.Nodes[outNode.NodeID]; node != nil && node.IsRemote == 1 {\n\t\t\tcontinue\n\t\t}\n\t\tserviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID])\n\t\tif _, err := h.sendNodeCommand(outNode.NodeID, \"AddService\", serviceData, true, false); err != nil {\n\t\t\treturn createdChains, createdServices, fmt.Errorf(\"出口节点 %s 下发服务失败: %w\", nodeDisplayName(state.Nodes[outNode.NodeID]), err)\n\t\t}\n\t\tcreatedServices = append(createdServices, outNode.NodeID)\n\t}\n\n\treturn createdChains, createdServices, nil\n}\n\nfunc (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tunnelID int64) {\n\tif h == nil || tunnelID <= 0 {\n\t\treturn\n\t}\n\tseenServices := make(map[int64]struct{})\n\tserviceName := fmt.Sprintf(\"%d_tls\", tunnelID)\n\tfor i := len(serviceNodeIDs) - 1; i >= 0; i-- {\n\t\tnodeID := serviceNodeIDs[i]\n\t\tif _, ok := seenServices[nodeID]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseenServices[nodeID] = struct{}{}\n\t\t_, _ = h.sendNodeCommand(nodeID, \"DeleteService\", map[string]interface{}{\"services\": []string{serviceName}}, false, true)\n\t}\n\n\tseenChains := make(map[int64]struct{})\n\tchainName := fmt.Sprintf(\"chains_%d\", tunnelID)\n\tfor i := len(chainNodeIDs) - 1; i >= 0; i-- {\n\t\tnodeID := chainNodeIDs[i]\n\t\tif _, ok := seenChains[nodeID]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseenChains[nodeID] = struct{}{}\n\t\t_, _ = h.sendNodeCommand(nodeID, \"DeleteChains\", map[string]interface{}{\"chain\": chainName}, false, true)\n\t}\n}\n\nfunc shouldDeferTunnelRuntimeApplyError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tmsg := strings.ToLower(strings.TrimSpace(err.Error()))\n\tif msg == \"\" {\n\t\treturn false\n\t}\n\tif strings.Contains(msg, \"节点不在线\") {\n\t\treturn true\n\t}\n\tif strings.Contains(msg, \"等待节点响应超时\") || strings.Contains(msg, \"timeout\") || strings.Contains(msg, \"超时\") {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) (map[string]interface{}, error) {\n\tfromNode := nodes[fromNodeID]\n\tif fromNode == nil {\n\t\treturn nil, errors.New(\"节点不存在\")\n\t}\n\tif len(targets) == 0 {\n\t\treturn nil, errors.New(\"转发链目标不能为空\")\n\t}\n\tnodeItems := make([]map[string]interface{}, 0, len(targets))\n\tfor idx, target := range targets {\n\t\ttargetNode := nodes[target.NodeID]\n\t\tif targetNode == nil {\n\t\t\treturn nil, errors.New(\"节点不存在\")\n\t\t}\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\t\tport := target.Port\n\t\tif port <= 0 {\n\t\t\treturn nil, errors.New(\"节点端口不能为空\")\n\t\t}\n\t\tprotocol := defaultString(target.Protocol, \"tls\")\n\t\tconnector := map[string]interface{}{\n\t\t\t\"type\": \"relay\",\n\t\t}\n\t\tif isTLSTunnelProtocol(protocol) {\n\t\t\tconnector[\"metadata\"] = map[string]interface{}{\"nodelay\": true}\n\t\t}\n\t\tnodeItems = append(nodeItems, map[string]interface{}{\n\t\t\t\"name\": fmt.Sprintf(\"node_%d\", idx+1),\n\t\t\t\"addr\": processServerAddress(fmt.Sprintf(\"%s:%d\", host, port)),\n\t\t\t\"connector\": connector,\n\t\t\t\"dialer\": map[string]interface{}{\n\t\t\t\t\"type\": protocol,\n\t\t\t},\n\t\t})\n\t}\n\n\tstrategy := defaultString(strings.TrimSpace(targets[0].Strategy), \"round\")\n\thop := map[string]interface{}{\n\t\t\"name\": fmt.Sprintf(\"hop_%d\", tunnelID),\n\t\t\"selector\": map[string]interface{}{\n\t\t\t\"strategy\": strategy,\n\t\t\t\"maxFails\": 1,\n\t\t\t\"failTimeout\": int64(600000000000),\n\t\t},\n\t\t\"nodes\": nodeItems,\n\t}\n\tif strings.TrimSpace(fromNode.InterfaceName) != \"\" {\n\t\thop[\"interface\"] = fromNode.InterfaceName\n\t}\n\n\treturn map[string]interface{}{\n\t\t\"name\": fmt.Sprintf(\"chains_%d\", tunnelID),\n\t\t\"hops\": []map[string]interface{}{hop},\n\t}, nil\n}\n\nfunc buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode, node *nodeRecord) []map[string]interface{} {\n\tif node == nil {\n\t\treturn nil\n\t}\n\tprotocol := defaultString(chainNode.Protocol, \"tls\")\n\thandlerCfg := map[string]interface{}{\n\t\t\"type\": \"relay\",\n\t}\n\tif isTLSTunnelProtocol(protocol) {\n\t\thandlerCfg[\"metadata\"] = map[string]interface{}{\"nodelay\": true}\n\t}\n\tservice := map[string]interface{}{\n\t\t\"name\": fmt.Sprintf(\"%d_tls\", tunnelID),\n\t\t\"addr\": fmt.Sprintf(\"%s:%d\", node.TCPListenAddr, chainNode.Port),\n\t\t\"handler\": handlerCfg,\n\t\t\"listener\": map[string]interface{}{\n\t\t\t\"type\": protocol,\n\t\t},\n\t}\n\tif chainNode.ChainType == 2 {\n\t\tservice[\"handler\"].(map[string]interface{})[\"chain\"] = fmt.Sprintf(\"chains_%d\", tunnelID)\n\t}\n\tif chainNode.ChainType == 3 && strings.TrimSpace(node.InterfaceName) != \"\" {\n\t\tservice[\"metadata\"] = map[string]interface{}{\"interface\": node.InterfaceName}\n\t}\n\treturn []map[string]interface{}{service}\n}\n\nfunc 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\ttoV6 := nodeSupportsV6(toNode)\n\n\tswitch strings.TrimSpace(ipPreference) {\n\tcase \"v6\":\n\t\tif fromV6 && toV6 {\n\t\t\tif host := pickNodeAddressV6(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\t\tif fromV4 && toV4 {\n\t\t\tif host := pickNodeAddressV4(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\tcase \"v4\":\n\t\tif fromV4 && toV4 {\n\t\t\tif host := pickNodeAddressV4(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\t\tif fromV6 && toV6 {\n\t\t\tif host := pickNodeAddressV6(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\tdefault:\n\t\tif fromV4 && toV4 {\n\t\t\tif host := pickNodeAddressV4(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\t\tif fromV6 && toV6 {\n\t\t\tif host := pickNodeAddressV6(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"节点链路不兼容:%s(v4=%t,v6=%t) -> %s(v4=%t,v6=%t)\", nodeDisplayName(fromNode), fromV4, fromV6, nodeDisplayName(toNode), toV4, toV6)\n}\n\nfunc nodeDisplayName(node *nodeRecord) string {\n\tif node == nil {\n\t\treturn \"node\"\n\t}\n\tif strings.TrimSpace(node.Name) != \"\" {\n\t\treturn strings.TrimSpace(node.Name)\n\t}\n\treturn fmt.Sprintf(\"node_%d\", node.ID)\n}\n\nfunc isTLSTunnelProtocol(protocol string) bool {\n\treturn strings.EqualFold(strings.TrimSpace(defaultString(protocol, \"tls\")), \"tls\")\n}\n\nfunc nodeSupportsV4(node *nodeRecord) bool {\n\tif node == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(node.ServerIPv4) != \"\" {\n\t\treturn true\n\t}\n\tif strings.TrimSpace(node.ServerIPv6) != \"\" {\n\t\treturn false\n\t}\n\tlegacy := strings.Trim(strings.TrimSpace(node.ServerIP), \"[]\")\n\tif legacy == \"\" {\n\t\treturn false\n\t}\n\tif ip := net.ParseIP(legacy); ip != nil {\n\t\treturn ip.To4() != nil\n\t}\n\treturn true\n}\n\nfunc nodeSupportsV6(node *nodeRecord) bool {\n\tif node == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(node.ServerIPv6) != \"\" {\n\t\treturn true\n\t}\n\tif strings.TrimSpace(node.ServerIPv4) != \"\" {\n\t\treturn false\n\t}\n\tlegacy := strings.Trim(strings.TrimSpace(node.ServerIP), \"[]\")\n\tif legacy == \"\" {\n\t\treturn false\n\t}\n\tif ip := net.ParseIP(legacy); ip != nil {\n\t\treturn ip.To4() == nil\n\t}\n\treturn true\n}\n\nfunc pickNodeAddressV4(node *nodeRecord) string {\n\tif node == nil {\n\t\treturn \"\"\n\t}\n\tif v := strings.TrimSpace(node.ServerIPv4); v != \"\" {\n\t\treturn v\n\t}\n\treturn strings.TrimSpace(node.ServerIP)\n}\n\nfunc pickNodeAddressV6(node *nodeRecord) string {\n\tif node == nil {\n\t\treturn \"\"\n\t}\n\tif v := strings.TrimSpace(node.ServerIPv6); v != \"\" {\n\t\treturn v\n\t}\n\treturn strings.TrimSpace(node.ServerIP)\n}\n\nfunc (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[string]interface{}) error {\n\tallocated := map[int64]int{}\n\tinNodes := asMapSlice(req[\"inNodeId\"])\n\tfor i, n := range inNodes {\n\t\tnodeID := asInt64(n[\"nodeId\"], 0)\n\t\tif nodeID <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.repo.CreateChainTunnelTx(\n\t\t\ttx,\n\t\t\ttunnelID,\n\t\t\t\"1\",\n\t\t\tnodeID,\n\t\t\tsql.NullInt64{},\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\t}\n\tfor i, n := range asMapSlice(req[\"outNodeId\"]) {\n\t\tnodeID := asInt64(n[\"nodeId\"], 0)\n\t\tif nodeID <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tport := asInt(n[\"port\"], 0)\n\t\tif port <= 0 {\n\t\t\tvar pickErr error\n\t\t\tport, pickErr = h.repo.PickNodePortTx(tx, nodeID, allocated, 0)\n\t\t\tif pickErr != nil {\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\t\t\t\"3\",\n\t\t\tnodeID,\n\t\t\tsql.NullInt64{Int64: int64(port), Valid: true},\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\t}\n\tchainNodes := asAnySlice(req[\"chainNodes\"])\n\tfor i, grp := range chainNodes {\n\t\tfor _, n := range asMapSlice(grp) {\n\t\t\tnodeID := asInt64(n[\"nodeId\"], 0)\n\t\t\tif nodeID <= 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tport := asInt(n[\"port\"], 0)\n\t\t\tif port <= 0 {\n\t\t\t\tvar pickErr error\n\t\t\t\tport, pickErr = h.repo.PickNodePortTx(tx, nodeID, allocated, 0)\n\t\t\t\tif pickErr != nil {\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\t\t\t\t\"2\",\n\t\t\t\tnodeID,\n\t\t\t\tsql.NullInt64{Int64: int64(port), Valid: true},\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\t\t}\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) deleteNodeByID(id int64) error {\n\treturn h.repo.DeleteNodeCascade(id)\n}\n\nfunc (h *Handler) deleteTunnelByID(id int64) error {\n\tif h == nil || h.repo == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\n\ttunnelName, _ := h.repo.GetTunnelName(id)\n\tif err := h.repo.DeleteTunnelCascade(id); err != nil {\n\t\treturn err\n\t}\n\n\tshareID, port, ok := parsePeerShareInfoFromFederationTunnelName(tunnelName)\n\tif !ok {\n\t\treturn nil\n\t}\n\n\treturn h.repo.MarkPeerShareRuntimeReleasedByPort(shareID, port, time.Now().UnixMilli())\n}\n\nfunc (h *Handler) deleteForwardByID(id int64) error {\n\treturn h.repo.DeleteForwardCascade(id)\n}\n\nfunc (h *Handler) batchForwardDelete(ids []int64) (int, int) {\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tif err := h.deleteForwardByID(id); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\treturn s, f\n}\n\nfunc (h *Handler) batchForwardStatus(ids []int64, status int) (int, int) {\n\treturn h.repo.BatchUpdateForwardStatus(ids, status)\n}\n\nfunc (h *Handler) tunnelEntryNodeIDs(tunnelID int64) ([]int64, error) {\n\treturn h.repo.TunnelEntryNodeIDs(tunnelID)\n}\n\nfunc (h *Handler) pickTunnelPort(tunnelID int64) int {\n\tentryNodes, err := h.tunnelEntryNodeIDs(tunnelID)\n\tif err != nil || len(entryNodes) == 0 {\n\t\treturn 10000\n\t}\n\n\tvar commonAvailable []int\n\tfirstNode := true\n\n\tfor _, nodeID := range entryNodes {\n\t\tportRange, err := h.repo.GetNodePortRange(nodeID)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif portRange == \"\" {\n\t\t\tportRange = \"1000-65535\"\n\t\t}\n\n\t\tnodePorts, err := parsePorts(portRange)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tused, err := h.getUsedPorts(nodeID)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar available []int\n\t\tfor _, p := range nodePorts {\n\t\t\tif !used[p] {\n\t\t\t\tavailable = append(available, p)\n\t\t\t}\n\t\t}\n\n\t\tif firstNode {\n\t\t\tcommonAvailable = available\n\t\t\tfirstNode = false\n\t\t} else {\n\t\t\tset := make(map[int]bool)\n\t\t\tfor _, p := range available {\n\t\t\t\tset[p] = true\n\t\t\t}\n\t\t\tvar newCommon []int\n\t\t\tfor _, p := range commonAvailable {\n\t\t\t\tif set[p] {\n\t\t\t\t\tnewCommon = append(newCommon, p)\n\t\t\t\t}\n\t\t\t}\n\t\t\tcommonAvailable = newCommon\n\t\t}\n\n\t\tif len(commonAvailable) == 0 {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif len(commonAvailable) > 0 {\n\t\tidx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(commonAvailable))))\n\t\treturn commonAvailable[idx.Int64()]\n\t}\n\n\treturn 10000\n}\n\nfunc (h *Handler) getUsedPorts(nodeID int64) (map[int]bool, error) {\n\treturn h.repo.GetUsedPortsOnNodeAsMap(nodeID)\n}\n\nfunc parsePorts(portRange string) ([]int, error) {\n\tvar ports []int\n\tparts := strings.Split(portRange, \",\")\n\tfor _, part := range parts {\n\t\tpart = strings.TrimSpace(part)\n\t\tif part == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.Contains(part, \"-\") {\n\t\t\trangeParts := strings.Split(part, \"-\")\n\t\t\tif len(rangeParts) != 2 {\n\t\t\t\treturn nil, errors.New(\"invalid port range format\")\n\t\t\t}\n\t\t\tstart, err1 := strconv.Atoi(strings.TrimSpace(rangeParts[0]))\n\t\t\tend, err2 := strconv.Atoi(strings.TrimSpace(rangeParts[1]))\n\t\t\tif err1 != nil || err2 != nil || start > end {\n\t\t\t\treturn nil, errors.New(\"invalid port range values\")\n\t\t\t}\n\t\t\tfor i := start; i <= end; i++ {\n\t\t\t\tports = append(ports, i)\n\t\t\t}\n\t\t} else {\n\t\t\tp, err := strconv.Atoi(part)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, errors.New(\"invalid port value\")\n\t\t\t}\n\t\t\tports = append(ports, p)\n\t\t}\n\t}\n\treturn ports, nil\n}\n\nfunc (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\t}\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\tInIP string\n\t\t}{NodeID: nid, Port: port, InIP: inIp}\n\t}\n\treturn h.repo.ReplaceForwardPorts(forwardID, entries)\n}\n\nfunc (h *Handler) replaceForwardPortsWithRecords(forwardID int64, ports []forwardPortRecord) error {\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\tInIP string\n\t\t}{NodeID: fp.NodeID, Port: fp.Port, InIP: fp.InIP}\n\t}\n\treturn h.repo.ReplaceForwardPorts(forwardID, entries)\n}\n\nfunc (h *Handler) rollbackForwardMutation(oldForward *forwardRecord, oldPorts []forwardPortRecord) {\n\tif h == nil || oldForward == nil || h.repo == nil {\n\t\treturn\n\t}\n\n\th.repo.RollbackForwardFields(\n\t\toldForward.ID, oldForward.UserID, oldForward.UserName, oldForward.Name,\n\t\toldForward.TunnelID, oldForward.RemoteAddr, oldForward.Strategy, oldForward.Status,\n\t\toldForward.SpeedID,\n\t\ttime.Now().UnixMilli(),\n\t)\n\n\tif err := h.replaceForwardPortsWithRecords(oldForward.ID, oldPorts); err != nil {\n\t\treturn\n\t}\n\n\t_ = h.syncForwardServices(oldForward, \"UpdateService\", true)\n}\n\nfunc (h *Handler) upsertUserTunnel(req map[string]interface{}) error {\n\tuserID := asInt64(req[\"userId\"], 0)\n\ttunnelID := asInt64(req[\"tunnelId\"], 0)\n\tif userID <= 0 || tunnelID <= 0 {\n\t\treturn fmt.Errorf(\"userId or tunnelId missing\")\n\t}\n\n\texistingID, currentFlow, currentNum, currentExpTime, currentFlowReset, currentSpeedID, currentStatus, err :=\n\t\th.repo.GetExistingUserTunnel(userID, tunnelID)\n\n\tspeedID := asAnyToInt64Ptr(req[\"speedId\"])\n\tif err := h.validateSpeedLimitReference(speedID); err != nil {\n\t\treturn err\n\t}\n\n\treqFlow := asInt64(req[\"flow\"], -1)\n\treqNum := asInt(req[\"num\"], -1)\n\treqExpTime := asInt64(req[\"expTime\"], -1)\n\treqFlowReset := asInt64(req[\"flowResetTime\"], -1)\n\treqStatus := asInt(req[\"status\"], -1)\n\n\tif err == sql.ErrNoRows {\n\t\tif reqFlow < 0 || reqNum < 0 || reqExpTime < 0 || reqFlowReset < 0 {\n\t\t\tuFlow, uNum, uExp, uReset, uErr := h.repo.GetUserDefaultsForTunnel(userID)\n\t\t\tif uErr == nil {\n\t\t\t\tif reqFlow < 0 {\n\t\t\t\t\treqFlow = uFlow\n\t\t\t\t}\n\t\t\t\tif reqNum < 0 {\n\t\t\t\t\treqNum = uNum\n\t\t\t\t}\n\t\t\t\tif reqExpTime < 0 {\n\t\t\t\t\treqExpTime = uExp\n\t\t\t\t}\n\t\t\t\tif reqFlowReset < 0 {\n\t\t\t\t\treqFlowReset = uReset\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif reqFlow < 0 {\n\t\t\treqFlow = 0\n\t\t}\n\t\tif reqNum < 0 {\n\t\t\treqNum = 0\n\t\t}\n\t\tif reqExpTime < 0 {\n\t\t\treqExpTime = time.Now().Add(365 * 24 * time.Hour).UnixMilli()\n\t\t}\n\t\tif reqFlowReset < 0 {\n\t\t\treqFlowReset = 1\n\t\t}\n\t\tif reqStatus < 0 {\n\t\t\treqStatus = 1\n\t\t}\n\n\t\tif err := h.repo.InsertUserTunnel(userID, tunnelID, nullableInt(speedID), reqNum, reqFlow, reqFlowReset, reqExpTime, reqStatus); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {\n\t\t\tinsertedID, _, _, _, _, _, _, lookupErr := h.repo.GetExistingUserTunnel(userID, tunnelID)\n\t\t\tif lookupErr != nil {\n\t\t\t\treturn fmt.Errorf(\"下发失败且回滚失败: %v; 回滚查询错误: %w\", syncErr, lookupErr)\n\t\t\t}\n\n\t\t\tif rollbackErr := h.repo.DeleteUserTunnel(insertedID); rollbackErr != nil {\n\t\t\t\treturn fmt.Errorf(\"下发失败且回滚失败: %v; 回滚删除错误: %w\", syncErr, rollbackErr)\n\t\t\t}\n\n\t\t\treturn fmt.Errorf(\"下发失败,已回滚: %w\", syncErr)\n\t\t}\n\n\t\treturn nil\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tnewFlow := currentFlow\n\tif reqFlow >= 0 {\n\t\tnewFlow = reqFlow\n\t}\n\n\tnewNum := int(currentNum)\n\tif reqNum >= 0 {\n\t\tnewNum = reqNum\n\t}\n\n\tnewExpTime := currentExpTime\n\tif reqExpTime >= 0 {\n\t\tnewExpTime = reqExpTime\n\t}\n\n\tnewFlowReset := currentFlowReset\n\tif reqFlowReset >= 0 {\n\t\tnewFlowReset = reqFlowReset\n\t}\n\n\tnewStatus := currentStatus\n\tif reqStatus >= 0 {\n\t\tnewStatus = reqStatus\n\t}\n\n\tnewSpeedID := currentSpeedID\n\tif speedID != nil {\n\t\tnewSpeedID = sql.NullInt64{Int64: *speedID, Valid: true}\n\t} else if _, ok := req[\"speedId\"]; ok {\n\t\tnewSpeedID = sql.NullInt64{Valid: false}\n\t}\n\n\tif err := h.repo.UpdateUserTunnelFields(existingID, newSpeedID, newFlow, newNum, newExpTime, newFlowReset, newStatus); err != nil {\n\t\treturn err\n\t}\n\n\tif syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {\n\t\trollbackErr := h.repo.UpdateUserTunnelFields(\n\t\t\texistingID,\n\t\t\tcurrentSpeedID,\n\t\t\tcurrentFlow,\n\t\t\tint(currentNum),\n\t\t\tcurrentExpTime,\n\t\t\tcurrentFlowReset,\n\t\t\tcurrentStatus,\n\t\t)\n\t\tif rollbackErr != nil {\n\t\t\treturn fmt.Errorf(\"下发失败且回滚失败: %v; 回滚错误: %w\", syncErr, rollbackErr)\n\t\t}\n\n\t\treturn fmt.Errorf(\"下发失败,已回滚: %w\", syncErr)\n\t}\n\n\treturn nil\n}\n\nfunc (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) error {\n\tforwards, err := h.listForwardsByTunnel(tunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor i := range forwards {\n\t\tf := &forwards[i]\n\t\tif f.UserID == userID {\n\t\t\tif err := h.syncForwardServices(f, \"UpdateService\", true); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (h *Handler) validateSpeedLimitReference(speedID *int64) error {\n\tif speedID == nil {\n\t\treturn nil\n\t}\n\n\texists, err := h.repo.SpeedLimitExists(*speedID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !exists {\n\t\treturn errors.New(\"限速规则不存在\")\n\t}\n\n\treturn nil\n}\n\nfunc asAnySlice(v interface{}) []interface{} {\n\tif v == nil {\n\t\treturn nil\n\t}\n\tif arr, ok := v.([]interface{}); ok {\n\t\treturn arr\n\t}\n\treturn nil\n}\n\nfunc asMapSlice(v interface{}) []map[string]interface{} {\n\tarr := asAnySlice(v)\n\tif arr == nil {\n\t\treturn nil\n\t}\n\tout := make([]map[string]interface{}, 0, len(arr))\n\tfor _, it := range arr {\n\t\tif m, ok := it.(map[string]interface{}); ok {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc asString(v interface{}) string {\n\tswitch t := v.(type) {\n\tcase nil:\n\t\treturn \"\"\n\tcase string:\n\t\treturn strings.TrimSpace(t)\n\tcase float64:\n\t\tif t == float64(int64(t)) {\n\t\t\treturn strconv.FormatInt(int64(t), 10)\n\t\t}\n\t\treturn strconv.FormatFloat(t, 'f', -1, 64)\n\tcase int, int32, int64:\n\t\treturn fmt.Sprintf(\"%v\", t)\n\tdefault:\n\t\tb, _ := json.Marshal(t)\n\t\treturn strings.Trim(string(b), \"\\\"\")\n\t}\n}\n\nfunc asInt(v interface{}, def int) int {\n\ts := asString(v)\n\tif s == \"\" {\n\t\treturn def\n\t}\n\ti, err := strconv.Atoi(s)\n\tif err != nil {\n\t\treturn def\n\t}\n\treturn i\n}\n\nfunc asInt64(v interface{}, def int64) int64 {\n\ts := asString(v)\n\tif s == \"\" {\n\t\treturn def\n\t}\n\ti, err := strconv.ParseInt(s, 10, 64)\n\tif err != nil {\n\t\treturn def\n\t}\n\treturn i\n}\n\nfunc asFloat(v interface{}, def float64) float64 {\n\ts := asString(v)\n\tif s == \"\" {\n\t\treturn def\n\t}\n\tf, err := strconv.ParseFloat(s, 64)\n\tif err != nil {\n\t\treturn def\n\t}\n\treturn f\n}\n\nfunc asAnyToInt64Ptr(v interface{}) *int64 {\n\ts := asString(v)\n\tif s == \"\" || strings.EqualFold(s, \"null\") {\n\t\treturn nil\n\t}\n\ti, err := strconv.ParseInt(s, 10, 64)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &i\n}\n\nfunc idFromBody(r *http.Request, w http.ResponseWriter) int64 {\n\treturn asInt64FromBodyKey(r, w, \"id\")\n}\n\nfunc asInt64FromBodyKey(r *http.Request, w http.ResponseWriter, key string) int64 {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn 0\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn 0\n\t}\n\tid := asInt64(req[key], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"参数错误\"))\n\t\treturn 0\n\t}\n\treturn id\n}\n\nfunc idsFromBody(r *http.Request, w http.ResponseWriter) []int64 {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn nil\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn nil\n\t}\n\tarr := asAnySlice(req[\"ids\"])\n\tif len(arr) == 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"ids不能为空\"))\n\t\treturn nil\n\t}\n\tids := make([]int64, 0, len(arr))\n\tfor _, x := range arr {\n\t\tid := asInt64(x, 0)\n\t\tif id > 0 {\n\t\t\tids = append(ids, id)\n\t\t}\n\t}\n\tsort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })\n\treturn ids\n}\n\nfunc nullableText(s string) interface{} {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn nil\n\t}\n\treturn s\n}\n\nfunc nullableInt(v *int64) interface{} {\n\tif v == nil {\n\t\treturn nil\n\t}\n\treturn *v\n}\n\nfunc defaultString(v, def string) string {\n\tif strings.TrimSpace(v) == \"\" {\n\t\treturn def\n\t}\n\treturn v\n}\n\nfunc randomToken(n int) string {\n\tbuf := make([]byte, n)\n\tif _, err := rand.Read(buf); err != nil {\n\t\treturn strconv.FormatInt(time.Now().UnixNano(), 16)\n\t}\n\treturn hex.EncodeToString(buf)\n}\n\nfunc asInt64Slice(v interface{}) []int64 {\n\tarr := asAnySlice(v)\n\tif len(arr) == 0 {\n\t\treturn nil\n\t}\n\tids := make([]int64, 0, len(arr))\n\tfor _, x := range arr {\n\t\tif id := asInt64(x, 0); id > 0 {\n\t\t\tids = append(ids, id)\n\t\t}\n\t}\n\treturn ids\n}\n",
|
||
"after": "package handler\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"database/sql\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math/big\"\n\t\"net\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"go-backend/internal/http/client\"\n\t\"go-backend/internal/http/response\"\n\t\"go-backend/internal/security\"\n\t\"go-backend/internal/store/model\"\n\t\"go-backend/internal/store/repo\"\n\n\t\"gorm.io/gorm\"\n)\n\nfunc (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\n\tusername := asString(req[\"user\"])\n\tpwd := asString(req[\"pwd\"])\n\tif username == \"\" || pwd == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户名或密码不能为空\"))\n\t\treturn\n\t}\n\n\texists, err := h.repo.UserExists(username)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif exists {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户名已存在\"))\n\t\treturn\n\t}\n\n\tstatus := asInt(req[\"status\"], 1)\n\tflow := asInt64(req[\"flow\"], 100)\n\tnum := asInt(req[\"num\"], 10)\n\texpTime := asInt64(req[\"expTime\"], time.Now().Add(365*24*time.Hour).UnixMilli())\n\tflowResetTime := asInt64(req[\"flowResetTime\"], 1)\n\troleID := 1\n\tnow := time.Now().UnixMilli()\n\n\tuserID, err := h.repo.CreateUser(username, security.MD5(pwd), roleID, expTime, flow, flowResetTime, num, status, now)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tgroupIDs := asInt64Slice(req[\"groupIds\"])\n\tif len(groupIDs) > 0 {\n\t\tif addErr := h.repo.AddUserToGroups(userID, groupIDs, now); addErr == nil {\n\t\t\tfor _, gid := range groupIDs {\n\t\t\t\t_ = h.syncPermissionsByUserGroup(gid)\n\t\t\t}\n\t\t}\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户ID不能为空\"))\n\t\treturn\n\t}\n\tusername := asString(req[\"user\"])\n\tif username == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户名不能为空\"))\n\t\treturn\n\t}\n\n\troleID, err := h.repo.GetUserRoleID(id)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif roleID == 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请不要作死\"))\n\t\treturn\n\t}\n\n\tdup, err := h.repo.UserExistsExcluding(username, id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif dup {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户名已存在\"))\n\t\treturn\n\t}\n\n\tflow := asInt64(req[\"flow\"], 100)\n\tnum := asInt(req[\"num\"], 10)\n\texpTime := asInt64(req[\"expTime\"], time.Now().Add(365*24*time.Hour).UnixMilli())\n\tflowResetTime := asInt64(req[\"flowResetTime\"], 1)\n\tstatus := asInt(req[\"status\"], 1)\n\tnow := time.Now().UnixMilli()\n\n\tpwd := asString(req[\"pwd\"])\n\tif strings.TrimSpace(pwd) == \"\" {\n\t\tif err := h.repo.UpdateUserWithoutPassword(id, username, flow, num, expTime, flowResetTime, status, now); err != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tif err := h.repo.UpdateUserWithPassword(id, username, security.MD5(pwd), flow, num, expTime, flowResetTime, status, now); err != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\n\th.repo.PropagateUserFlowToTunnels(id, flow, num, expTime, flowResetTime)\n\n\tif groupIDsRaw, ok := req[\"groupIds\"]; ok {\n\t\tnewGroupIDs := asInt64Slice(groupIDsRaw)\n\t\tif affected, replaceErr := h.repo.ReplaceUserGroupsByUserID(id, newGroupIDs, now); replaceErr == nil {\n\t\t\tfor _, gid := range affected {\n\t\t\t\t_ = h.syncPermissionsByUserGroup(gid)\n\t\t\t}\n\t\t}\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userGroups(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tids, err := h.repo.GetUserGroupIDsByUserID(id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OK(ids))\n}\n\nfunc (h *Handler) userDelete(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\n\troleID, err := h.repo.GetUserRoleID(id)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"用户不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif roleID == 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请不要作死\"))\n\t\treturn\n\t}\n\n\tif err := h.repo.DeleteUserCascade(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userResetFlow(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\ttypeVal := asInt(req[\"type\"], 0)\n\tif id <= 0 || (typeVal != 1 && typeVal != 2) {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\n\tif typeVal == 1 {\n\t\th.repo.ResetUserFlowByUser(id, time.Now().UnixMilli())\n\t} else {\n\t\th.repo.ResetUserFlowByUserTunnel(id)\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tname := asString(req[\"name\"])\n\tserverIP := asString(req[\"serverIp\"])\n\tif name == \"\" || serverIP == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"节点名称和地址不能为空\"))\n\t\treturn\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tinx := h.repo.NextIndex(\"node\")\n\tif err := h.repo.CreateNode(\n\t\tname,\n\t\trandomToken(16),\n\t\tserverIP,\n\t\tnullableText(asString(req[\"serverIpV4\"])),\n\t\tnullableText(asString(req[\"serverIpV6\"])),\n\t\tdefaultString(asString(req[\"port\"]), \"1000-65535\"),\n\t\tnullableText(asString(req[\"interfaceName\"])),\n\t\tnullableText(\"\"),\n\t\tasInt(req[\"http\"], 0),\n\t\tasInt(req[\"tls\"], 0),\n\t\tasInt(req[\"socks\"], 0),\n\t\tnow,\n\t\t0,\n\t\tdefaultString(asString(req[\"tcpListenAddr\"]), \"[::]\"),\n\t\tdefaultString(asString(req[\"udpListenAddr\"]), \"[::]\"),\n\t\tinx,\n\t\tasInt(req[\"isRemote\"], 0),\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\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"节点ID不能为空\"))\n\t\treturn\n\t}\n\n\tcurrentStatus, currentHTTP, currentTLS, currentSocks, err := h.repo.GetNodeStatusFields(id)\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"节点不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tnewHTTP := asInt(req[\"http\"], currentHTTP)\n\tnewTLS := asInt(req[\"tls\"], currentTLS)\n\tnewSocks := asInt(req[\"socks\"], currentSocks)\n\tif currentStatus == 1 && (newHTTP != currentHTTP || newTLS != currentTLS || newSocks != currentSocks) {\n\t\tif err := h.applyNodeProtocolChange(id, newHTTP, newTLS, newSocks); err != nil {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tif err := h.repo.UpdateNode(id,\n\t\tasString(req[\"name\"]),\n\t\tasString(req[\"serverIp\"]),\n\t\tnullableText(asString(req[\"serverIpV4\"])),\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\t\tdefaultString(asString(req[\"tcpListenAddr\"]), \"[::]\"),\n\t\tdefaultString(asString(req[\"udpListenAddr\"]), \"[::]\"),\n\t\tnow,\n\t); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeDelete(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tif err := h.deleteNodeByID(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\n\tvar req struct {\n\t\tID int64 `json:\"id\"`\n\t\tChannel string `json:\"channel\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tif req.ID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"参数错误\"))\n\t\treturn\n\t}\n\n\tchannel := normalizeReleaseChannel(req.Channel)\n\tversion, err := resolveLatestReleaseByChannel(channel)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, fmt.Sprintf(\"获取最新%s失败: %v\", releaseChannelLabel(channel), err)))\n\t\treturn\n\t}\n\n\tsecret, err := h.repo.GetNodeSecret(req.ID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"节点不存在\"))\n\t\treturn\n\t}\n\tpanelAddr, err := h.repo.GetViteConfigValue(\"ip\")\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"请先前往网站配置中设置ip\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tcmd := fmt.Sprintf(\"curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && VERSION=%s ./install.sh -a %s -s %s\", version, version, processServerAddress(panelAddr), secret)\n\tresponse.WriteJSON(w, response.OK(cmd))\n}\n\nfunc (h *Handler) nodeUpdateOrder(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req struct {\n\t\tNodes []struct {\n\t\t\tID int64 `json:\"id\"`\n\t\t\tInx int `json:\"inx\"`\n\t\t} `json:\"nodes\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tfor _, n := range req.Nodes {\n\t\th.repo.UpdateNodeOrder(n.ID, n.Inx, time.Now().UnixMilli())\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeBatchDelete(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tfor _, id := range ids {\n\t\t_ = h.deleteNodeByID(id)\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) nodeCheckStatus(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\titems, err := h.repo.ListNodes()\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OK(items))\n}\n\nfunc (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tname := asString(req[\"name\"])\n\tif name == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道名称不能为空\"))\n\t\treturn\n\t}\n\tnameDup, err := h.repo.TunnelNameExists(name)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif nameDup {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道名称重复\"))\n\t\treturn\n\t}\n\n\ttypeVal := asInt(req[\"type\"], 1)\n\tflow := asInt64(req[\"flow\"], 1)\n\tstatus := asInt(req[\"status\"], 1)\n\ttrafficRatio := asFloat(req[\"trafficRatio\"], 1.0)\n\tinIP := asString(req[\"inIp\"])\n\tipPreference := asString(req[\"ipPreference\"])\n\tnow := time.Now().UnixMilli()\n\tinx := h.repo.NextIndex(\"tunnel\")\n\tlocalDomain := h.federationLocalDomain()\n\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\n\truntimeState, err := h.prepareTunnelCreateState(tx, req, typeVal, 0)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\truntimeState.IPPreference = ipPreference\n\tif strings.TrimSpace(inIP) == \"\" {\n\t\tinIP = buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes, ipPreference)\n\t}\n\n\tif len(runtimeState.InNodes) > 0 {\n\t\tfirstNodeID := runtimeState.InNodes[0].NodeID\n\t\tisRemote, rUrl, rToken, _ := h.repo.GetNodeRemoteFieldsTx(tx, firstNodeID)\n\t\tif isRemote == 1 {\n\t\t\tfc := client.NewFederationClient()\n\n\t\t\ttargetProto := \"tcp\"\n\t\t\ttargetPort := 0\n\t\t\ttargetAddr := \"\"\n\n\t\t\tif typeVal == 1 {\n\t\t\t\tif len(runtimeState.OutNodes) > 0 {\n\t\t\t\t\toutNode := runtimeState.OutNodes[0]\n\t\t\t\t\toutNodeRec := runtimeState.Nodes[outNode.NodeID]\n\t\t\t\t\ttargetAddr = processServerAddress(outNodeRec.ServerIP)\n\t\t\t\t\tif outNode.Port > 0 {\n\t\t\t\t\t\ttargetAddr = fmt.Sprintf(\"%s:%d\", targetAddr, outNode.Port)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif len(runtimeState.InNodes) > 0 {\n\t\t\t\t\tinNodesRaw := asMapSlice(req[\"inNodeId\"])\n\t\t\t\t\tif len(inNodesRaw) > 0 {\n\t\t\t\t\t\ttargetPort = asInt(inNodesRaw[0][\"port\"], 0)\n\t\t\t\t\t\ttargetProto = defaultString(asString(inNodesRaw[0][\"protocol\"]), \"tcp\")\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif targetPort > 0 && targetAddr != \"\" {\n\t\t\t\t\tinNodeRec := runtimeState.Nodes[firstNodeID]\n\t\t\t\t\tif err := validateRemoteNodePort(inNodeRec, targetPort); err != nil {\n\t\t\t\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tdomainCfg, _ := h.repo.GetConfigByName(\"panel_domain\")\n\t\t\t\t\tlocalDomain := \"\"\n\t\t\t\t\tif domainCfg != nil {\n\t\t\t\t\t\tlocalDomain = domainCfg.Value\n\t\t\t\t\t}\n\t\t\t\t\t_, err := fc.CreateTunnel(rUrl.String, rToken.String, localDomain, targetProto, targetPort, targetAddr)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"Remote tunnel creation failed: \"+err.Error()))\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tvar tunnelInIP sql.NullString\n\tif trimmed := strings.TrimSpace(inIP); trimmed != \"\" {\n\t\ttunnelInIP = sql.NullString{String: trimmed, Valid: true}\n\t}\n\ttunnel := model.Tunnel{\n\t\tName: name,\n\t\tTrafficRatio: trafficRatio,\n\t\tType: typeVal,\n\t\tProtocol: \"tls\",\n\t\tFlow: flow,\n\t\tCreatedTime: now,\n\t\tUpdatedTime: now,\n\t\tStatus: status,\n\t\tInIP: tunnelInIP,\n\t\tInx: inx,\n\t\tIPPreference: ipPreference,\n\t}\n\tif err := tx.Create(&tunnel).Error; err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\ttunnelID := tunnel.ID\n\truntimeState.TunnelID = tunnelID\n\tvar federationBindings []repo.FederationTunnelBinding\n\tvar federationReleaseRefs []federationRuntimeReleaseRef\n\tfederationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState, localDomain)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tapplyTunnelPortsToRequest(req, runtimeState)\n\tif err := h.replaceTunnelChainsTx(tx, tunnelID, req); err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := tx.Commit().Error; err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif typeVal == 2 {\n\t\tcreatedChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)\n\t\tif applyErr != nil {\n\t\t\th.rollbackTunnelRuntime(createdChains, createdServices, tunnelID)\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\t_ = h.deleteTunnelByID(tunnelID)\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(applyErr.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) cleanupTunnelRuntime(tunnelID int64) {\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil || tunnel.Type != 2 {\n\t\treturn\n\t}\n\tchainRows, err := h.listChainNodesForTunnel(tunnelID)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tserviceName := fmt.Sprintf(\"%d_tls\", tunnelID)\n\tchainName := fmt.Sprintf(\"chains_%d\", tunnelID)\n\n\tfor _, row := range chainRows {\n\t\tif row.ChainType == 1 {\n\t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteChains\", map[string]interface{}{\"chain\": chainName}, false, true)\n\t\t} else if row.ChainType == 2 {\n\t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteChains\", map[string]interface{}{\"chain\": chainName}, false, true)\n\t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteService\", map[string]interface{}{\"services\": []string{serviceName}}, false, true)\n\t\t} else if row.ChainType == 3 {\n\t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteService\", map[string]interface{}{\"services\": []string{serviceName}}, false, true)\n\t\t}\n\t}\n}\n\nfunc (h *Handler) tunnelGet(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\titems, err := h.repo.ListTunnels()\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tfor _, it := range items {\n\t\tif asInt64(it[\"id\"], 0) == id {\n\t\t\tresponse.WriteJSON(w, response.OK(it))\n\t\t\treturn\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.ErrDefault(\"隧道不存在\"))\n}\n\nfunc (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道ID不能为空\"))\n\t\treturn\n\t}\n\n\th.cleanupTunnelRuntime(id)\n\th.cleanupFederationRuntime(id)\n\n\tnow := time.Now().UnixMilli()\n\ttypeVal := asInt(req[\"type\"], 1)\n\tipPreference := asString(req[\"ipPreference\"])\n\tlocalDomain := h.federationLocalDomain()\n\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\n\truntimeState, err := h.prepareTunnelCreateState(tx, req, typeVal, id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\truntimeState.TunnelID = id\n\truntimeState.IPPreference = ipPreference\n\n\tinIp := buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes, ipPreference)\n\n\tvar federationBindings []repo.FederationTunnelBinding\n\tvar federationReleaseRefs []federationRuntimeReleaseRef\n\tfederationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState, localDomain)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tapplyTunnelPortsToRequest(req, runtimeState)\n\n\tif err := h.repo.UpdateTunnelTx(\n\t\ttx,\n\t\tid,\n\t\tasString(req[\"name\"]),\n\t\ttypeVal,\n\t\tasInt64(req[\"flow\"], 1),\n\t\tasFloat(req[\"trafficRatio\"], 1.0),\n\t\tasInt(req[\"status\"], 1),\n\t\tinIp,\n\t\tipPreference,\n\t\tnow,\n\t); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tif err := h.repo.DeleteChainTunnelsByTunnelTx(tx, id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.replaceTunnelChainsTx(tx, id, req); err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.repo.ReplaceFederationTunnelBindingsTx(tx, id, federationBindings); err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := tx.Commit().Error; err != nil {\n\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tif typeVal == 2 {\n\t\tcreatedChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)\n\t\tif applyErr != nil {\n\t\t\th.rollbackTunnelRuntime(createdChains, createdServices, id)\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\t_ = h.repo.DeleteFederationTunnelBindingsByTunnel(id)\n\t\t\tif len(federationReleaseRefs) == 0 && shouldDeferTunnelRuntimeApplyError(applyErr) {\n\t\t\t\tresponse.WriteJSON(w, response.OKEmpty())\n\t\t\t\treturn\n\t\t\t}\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(applyErr.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\n\tif forwards, fwdErr := h.listForwardsByTunnel(id); fwdErr == nil {\n\t\tfor i := range forwards {\n\t\t\t_ = h.syncForwardServices(&forwards[i], \"UpdateService\", true)\n\t\t}\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\th.cleanupTunnelRuntime(id)\n\th.cleanupFederationRuntime(id)\n\tif err := h.deleteTunnelByID(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) tunnelDiagnose(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tid := asInt64FromBodyKey(r, w, \"tunnelId\")\n\tif id <= 0 {\n\t\treturn\n\t}\n\tctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)\n\tdefer cancel()\n\tresult, err := h.diagnoseTunnelRuntime(ctx, id)\n\tif err != nil {\n\t\tif strings.Contains(err.Error(), \"不存在\") || strings.Contains(err.Error(), \"不完整\") {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OK(result))\n}\n\nfunc (h *Handler) tunnelUpdateOrder(w http.ResponseWriter, r *http.Request) {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn\n\t}\n\tvar req struct {\n\t\tTunnels []struct {\n\t\t\tID int64 `json:\"id\"`\n\t\t\tInx int `json:\"inx\"`\n\t\t} `json:\"tunnels\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tfor _, t := range req.Tunnels {\n\t\th.repo.UpdateTunnelOrder(t.ID, t.Inx, time.Now().UnixMilli())\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tsuccess := 0\n\tfail := 0\n\tfor _, id := range ids {\n\t\th.cleanupTunnelRuntime(id)\n\t\th.cleanupFederationRuntime(id)\n\t\tif err := h.deleteTunnelByID(id); err != nil {\n\t\t\tfail++\n\t\t} else {\n\t\t\tsuccess++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": success, \"failCount\": fail}))\n}\n\nfunc (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, error) {\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tchainRows, err := h.listChainNodesForTunnel(tunnelID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tipPreference := h.repo.GetTunnelIPPreference(tunnelID)\n\n\tstate := &tunnelCreateState{\n\t\tTunnelID: tunnelID,\n\t\tType: tunnel.Type,\n\t\tIPPreference: ipPreference,\n\t\tInNodes: make([]tunnelRuntimeNode, 0),\n\t\tChainHops: make([][]tunnelRuntimeNode, 0),\n\t\tOutNodes: make([]tunnelRuntimeNode, 0),\n\t\tNodes: make(map[int64]*nodeRecord),\n\t\tNodeIDList: make([]int64, 0),\n\t}\n\n\tinNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)\n\n\tfor _, r := range inNodes {\n\t\tstate.InNodes = append(state.InNodes, tunnelRuntimeNode{\n\t\t\tNodeID: r.NodeID,\n\t\t\tProtocol: r.Protocol,\n\t\t\tStrategy: r.Strategy,\n\t\t\tChainType: 1,\n\t\t})\n\t\tstate.NodeIDList = append(state.NodeIDList, r.NodeID)\n\t}\n\n\tfor _, r := range outNodes {\n\t\tstate.OutNodes = append(state.OutNodes, tunnelRuntimeNode{\n\t\t\tNodeID: r.NodeID,\n\t\t\tProtocol: r.Protocol,\n\t\t\tStrategy: r.Strategy,\n\t\t\tChainType: 3,\n\t\t\tPort: r.Port,\n\t\t})\n\t\tstate.NodeIDList = append(state.NodeIDList, r.NodeID)\n\t}\n\n\tfor _, hop := range chainHops {\n\t\tstateHop := make([]tunnelRuntimeNode, 0)\n\t\tfor _, r := range hop {\n\t\t\tstateHop = append(stateHop, tunnelRuntimeNode{\n\t\t\t\tNodeID: r.NodeID,\n\t\t\t\tProtocol: r.Protocol,\n\t\t\t\tStrategy: r.Strategy,\n\t\t\t\tChainType: 2,\n\t\t\t\tInx: int(r.Inx),\n\t\t\t\tPort: r.Port,\n\t\t\t})\n\t\t\tstate.NodeIDList = append(state.NodeIDList, r.NodeID)\n\t\t}\n\t\tstate.ChainHops = append(state.ChainHops, stateHop)\n\t}\n\n\tseen := make(map[int64]struct{})\n\tfor _, id := range state.NodeIDList {\n\t\tif _, ok := seen[id]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[id] = struct{}{}\n\t\tnode, err := h.getNodeRecord(id)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tstate.Nodes[id] = node\n\t}\n\n\treturn state, nil\n}\n\nfunc (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif tunnel.Type == 2 {\n\t\th.cleanupTunnelRuntime(tunnelID)\n\t\th.cleanupFederationRuntime(tunnelID)\n\t\tstate, err := h.reconstructTunnelState(tunnelID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfederationBindings, federationReleaseRefs, fedErr := h.applyFederationRuntime(state, h.federationLocalDomain())\n\t\tif fedErr != nil {\n\t\t\treturn fedErr\n\t\t}\n\t\ttx := h.repo.BeginTx()\n\t\tif tx.Error != nil {\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\treturn tx.Error\n\t\t}\n\t\tif replaceErr := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); replaceErr != nil {\n\t\t\ttx.Rollback()\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\treturn replaceErr\n\t\t}\n\t\tif commitErr := tx.Commit().Error; commitErr != nil {\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\treturn commitErr\n\t\t}\n\t\t_, _, applyErr := h.applyTunnelRuntime(state)\n\t\tif applyErr != nil {\n\t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n\t\t\t_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)\n\t\t\treturn applyErr\n\t\t}\n\t}\n\n\tforwards, err := h.listForwardsByTunnel(tunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor i := range forwards {\n\t\tif err := h.syncForwardServices(&forwards[i], \"UpdateService\", true); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tsuccess := 0\n\tfail := 0\n\tfor _, tunnelID := range ids {\n\t\tif err := h.redeployTunnelAndForwards(tunnelID); err != nil {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tsuccess++\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": success, \"failCount\": fail}))\n}\n\nfunc (h *Handler) userTunnelAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tif err := h.upsertUserTunnel(req); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userTunnelBatchAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tUserID int64 `json:\"userId\"`\n\t\tTunnels []struct {\n\t\t\tTunnelID int64 `json:\"tunnelId\"`\n\t\t\tSpeedID *int64 `json:\"speedId\"`\n\t\t} `json:\"tunnels\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.UserID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tfor _, t := range req.Tunnels {\n\t\tm := map[string]interface{}{\"userId\": req.UserID, \"tunnelId\": t.TunnelID}\n\t\tif t.SpeedID != nil {\n\t\t\tm[\"speedId\"] = *t.SpeedID\n\t\t}\n\t\tif err := h.upsertUserTunnel(m); err != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userTunnelRemove(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tif err := h.repo.DeleteUserTunnel(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) userTunnelUpdate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"权限ID不能为空\"))\n\t\treturn\n\t}\n\n\tspeedID := asAnyToInt64Ptr(req[\"speedId\"])\n\tif err := h.validateSpeedLimitReference(speedID); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tuserID, tunnelID, utErr := h.repo.GetUserTunnelUserAndTunnel(id)\n\tif utErr != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, utErr.Error()))\n\t\treturn\n\t}\n\n\t_, oldFlow, oldNum, oldExpTime, oldFlowReset, oldSpeedID, oldStatus, oldErr :=\n\t\th.repo.GetExistingUserTunnel(userID, tunnelID)\n\tif oldErr != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, oldErr.Error()))\n\t\treturn\n\t}\n\n\tif err := h.repo.UpdateUserTunnel(id,\n\t\tasInt64(req[\"flow\"], 0),\n\t\tasInt(req[\"num\"], 0),\n\t\tasInt64(req[\"expTime\"], time.Now().Add(365*24*time.Hour).UnixMilli()),\n\t\tasInt64(req[\"flowResetTime\"], 1),\n\t\tnullableInt(speedID),\n\t\tasInt(req[\"status\"], 1),\n\t); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tif syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {\n\t\trollbackErr := h.repo.UpdateUserTunnel(\n\t\t\tid,\n\t\t\toldFlow,\n\t\t\tint(oldNum),\n\t\t\toldExpTime,\n\t\t\toldFlowReset,\n\t\t\toldSpeedID,\n\t\t\toldStatus,\n\t\t)\n\t\tif rollbackErr != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, fmt.Sprintf(\"下发失败且回滚失败: %v; 回滚错误: %v\", syncErr, rollbackErr)))\n\t\t\treturn\n\t\t}\n\n\t\tresponse.WriteJSON(w, response.Err(-2, fmt.Sprintf(\"下发失败,已回滚: %v\", syncErr)))\n\t\treturn\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tuserID, roleID, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ttunnelID := asInt64(req[\"tunnelId\"], 0)\n\tif tunnelID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道ID不能为空\"))\n\t\treturn\n\t}\n\tif err := h.ensureTunnelPermission(userID, roleID, tunnelID); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道不存在\"))\n\t\treturn\n\t}\n\tif tunnel.Status != 1 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道已禁用,无法创建转发\"))\n\t\treturn\n\t}\n\tname := asString(req[\"name\"])\n\tremoteAddr := asString(req[\"remoteAddr\"])\n\tif name == \"\" || remoteAddr == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发名称和目标地址不能为空\"))\n\t\treturn\n\t}\n\tspeedID := asAnyToInt64Ptr(req[\"speedId\"])\n\tif speedID != nil {\n\t\texists, speedErr := h.repo.SpeedLimitExists(*speedID)\n\t\tif speedErr != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, speedErr.Error()))\n\t\t\treturn\n\t\t}\n\t\tif !exists {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"限速规则不存在\"))\n\t\t\treturn\n\t\t}\n\t}\n\tport := asInt(req[\"inPort\"], 0)\n\tif port <= 0 {\n\t\tport = h.pickTunnelPort(tunnelID)\n\t}\n\tif port <= 0 {\n\t\tport = 10000\n\t}\n\tentryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)\n\tfor _, nodeID := range entryNodes {\n\t\tnode, nodeErr := h.getNodeRecord(nodeID)\n\t\tif nodeErr != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif err := validateRemoteNodePort(node, port); err != nil {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tnow := time.Now().UnixMilli()\n\tinx := h.repo.NextIndex(\"forward\")\n\tuserName := h.repo.GetUsernameByID(userID)\n\tif userName == \"\" {\n\t\tuserName = \"user\"\n\t}\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\t}\n\tcreatedForward, err := h.getForwardRecord(forwardID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.syncForwardServices(createdForward, \"UpdateService\", true); err != nil {\n\t\t_ = h.deleteForwardByID(forwardID)\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发ID不能为空\"))\n\t\treturn\n\t}\n\tforward, actorUserID, actorRole, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\toldPorts, err := h.listForwardPorts(id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\ttunnelID := asInt64(req[\"tunnelId\"], forward.TunnelID)\n\tif tunnelID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道ID不能为空\"))\n\t\treturn\n\t}\n\tif err := h.ensureTunnelPermission(actorUserID, actorRole, tunnelID); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\ttunnel, err := h.getTunnelRecord(tunnelID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道不存在\"))\n\t\treturn\n\t}\n\tif tunnel.Status != 1 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道已禁用,无法更新转发\"))\n\t\treturn\n\t}\n\n\tname := strings.TrimSpace(asString(req[\"name\"]))\n\tif name == \"\" {\n\t\tname = forward.Name\n\t}\n\tremoteAddr := strings.TrimSpace(asString(req[\"remoteAddr\"]))\n\tif remoteAddr == \"\" {\n\t\tremoteAddr = forward.RemoteAddr\n\t}\n\tstrategy := strings.TrimSpace(asString(req[\"strategy\"]))\n\tif strategy == \"\" {\n\t\tstrategy = forward.Strategy\n\t}\n\tspeedID := asAnyToInt64Ptr(req[\"speedId\"])\n\tif speedID != nil {\n\t\texists, speedErr := h.repo.SpeedLimitExists(*speedID)\n\t\tif speedErr != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, speedErr.Error()))\n\t\t\treturn\n\t\t}\n\t\tif !exists {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"限速规则不存在\"))\n\t\t\treturn\n\t\t}\n\t}\n\tnewSpeedID := forward.SpeedID\n\tif speedID != nil {\n\t\tnewSpeedID = sql.NullInt64{Int64: *speedID, Valid: true}\n\t} else if _, ok := req[\"speedId\"]; ok {\n\t\tnewSpeedID = sql.NullInt64{Valid: false}\n\t}\n\n\tport := asInt(req[\"inPort\"], 0)\n\tif port <= 0 {\n\t\tminPort := h.repo.GetMinForwardPort(id)\n\t\tif minPort.Valid {\n\t\t\tport = int(minPort.Int64)\n\t\t}\n\t\tif port <= 0 {\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\t\tif nodeErr != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif err := validateRemoteNodePort(node, port); err != nil {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tnow := time.Now().UnixMilli()\n\tif err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\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\t}\n\tupdatedForward, err := h.getForwardRecord(id)\n\tif err != nil {\n\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.syncForwardServices(updatedForward, \"UpdateService\", true); err != nil {\n\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardDelete(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tforward, _, _, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.controlForwardServices(forward, \"DeleteService\", true); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\tif err := h.deleteForwardByID(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardForceDelete(w http.ResponseWriter, r *http.Request) {\n\th.forwardDelete(w, r)\n}\n\nfunc (h *Handler) forwardPause(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tforward, _, _, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.controlForwardServices(forward, \"PauseService\", false); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\t_ = h.repo.UpdateForwardStatus(id, 0, time.Now().UnixMilli())\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardResume(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tforward, _, _, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.controlForwardServices(forward, \"ResumeService\", false); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\t_ = h.repo.UpdateForwardStatus(id, 1, time.Now().UnixMilli())\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardDiagnose(w http.ResponseWriter, r *http.Request) {\n\tid := asInt64FromBodyKey(r, w, \"forwardId\")\n\tif id <= 0 {\n\t\treturn\n\t}\n\tforward, _, _, err := h.resolveForwardAccess(r, id)\n\tif err != nil {\n\t\tif errors.Is(err, errForwardNotFound) {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"转发不存在\"))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tctx, cancel := context.WithTimeout(r.Context(), diagnosisRequestTimeout)\n\tdefer cancel()\n\tpayload, err := h.diagnoseForwardRuntime(ctx, forward)\n\tif err != nil {\n\t\tif strings.Contains(err.Error(), \"不存在\") || strings.Contains(err.Error(), \"不能为空\") || strings.Contains(err.Error(), \"错误\") {\n\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\t\treturn\n\t\t}\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OK(payload))\n}\n\nfunc (h *Handler) forwardUpdateOrder(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tForwards []struct {\n\t\t\tID int64 `json:\"id\"`\n\t\t\tInx int `json:\"inx\"`\n\t\t} `json:\"forwards\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tfor _, f := range req.Forwards {\n\t\th.repo.UpdateForwardOrder(f.ID, f.Inx, time.Now().UnixMilli())\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) forwardBatchDelete(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.controlForwardServices(forward, \"DeleteService\", true); err != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.deleteForwardByID(id); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": s, \"failCount\": f}))\n}\n\nfunc (h *Handler) forwardBatchPause(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.controlForwardServices(forward, \"PauseService\", false); err != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.repo.UpdateForwardStatus(id, 0, time.Now().UnixMilli()); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": s, \"failCount\": f}))\n}\n\nfunc (h *Handler) forwardBatchResume(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.controlForwardServices(forward, \"ResumeService\", false); err != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.repo.UpdateForwardStatus(id, 1, time.Now().UnixMilli()); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": s, \"failCount\": f}))\n}\n\nfunc (h *Handler) forwardBatchRedeploy(w http.ResponseWriter, r *http.Request) {\n\tids := idsFromBody(r, w)\n\tif ids == nil {\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tf++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.syncForwardServices(forward, \"UpdateService\", true); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": s, \"failCount\": f}))\n}\n\nfunc (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tForwardIDs []int64 `json:\"forwardIds\"`\n\t\tTargetTunnelID int64 `json:\"targetTunnelId\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.TargetTunnelID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tactorUserID, actorRole, err := userRoleFromRequest(r)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(401, \"无效的token或token已过期\"))\n\t\treturn\n\t}\n\tif err := h.ensureTunnelPermission(actorUserID, actorRole, req.TargetTunnelID); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n\t\treturn\n\t}\n\ttargetTunnel, err := h.getTunnelRecord(req.TargetTunnelID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"目标隧道不存在\"))\n\t\treturn\n\t}\n\tif targetTunnel.Status != 1 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"目标隧道已禁用\"))\n\t\treturn\n\t}\n\tsuccess := 0\n\tfail := 0\n\tfor _, id := range req.ForwardIDs {\n\t\tif id <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tforward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)\n\t\tif accessErr != nil {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tif forward.TunnelID == req.TargetTunnelID {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\toldPorts, listPortsErr := h.listForwardPorts(id)\n\t\tif listPortsErr != nil {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tport := h.repo.GetMinForwardPort(id)\n\t\tif err := h.repo.UpdateForwardTunnel(id, req.TargetTunnelID, time.Now().UnixMilli()); err != nil {\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tp := 0\n\t\tif port.Valid {\n\t\t\tp = int(port.Int64)\n\t\t}\n\t\tif p <= 0 {\n\t\t\tp = h.pickTunnelPort(req.TargetTunnelID)\n\t\t}\n\t\tbctEntryNodes, _ := h.tunnelEntryNodeIDs(req.TargetTunnelID)\n\t\tportRangeOk := true\n\t\tfor _, nid := range bctEntryNodes {\n\t\t\tnd, ndErr := h.getNodeRecord(nid)\n\t\t\tif ndErr != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif validateRemoteNodePort(nd, p) != nil {\n\t\t\t\tportRangeOk = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !portRangeOk {\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\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tupdatedForward, fetchErr := h.getForwardRecord(id)\n\t\tif fetchErr != nil {\n\t\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.syncForwardServices(updatedForward, \"UpdateService\", true); err != nil {\n\t\t\th.rollbackForwardMutation(forward, oldPorts)\n\t\t\tfail++\n\t\t\tcontinue\n\t\t}\n\t\tsuccess++\n\t}\n\tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": success, \"failCount\": fail}))\n}\n\nfunc (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\n\tname := asString(req[\"name\"])\n\tif name == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"名称不能为空\"))\n\t\treturn\n\t}\n\n\tspeed := asInt(req[\"speed\"], 100)\n\n\tnow := time.Now().UnixMilli()\n\t_, err := h.repo.CreateSpeedLimit(name, speed, now, asInt(req[\"status\"], 1))\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) speedLimitUpdate(w http.ResponseWriter, r *http.Request) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"限速规则ID不能为空\"))\n\t\treturn\n\t}\n\n\tname := asString(req[\"name\"])\n\tif name == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"名称不能为空\"))\n\t\treturn\n\t}\n\n\tspeed := asInt(req[\"speed\"], 100)\n\n\tif err := h.repo.UpdateSpeedLimit(id, name, speed, asInt(req[\"status\"], 1), time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) speedLimitDelete(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\n\tif err := h.repo.DeleteSpeedLimit(id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupTunnelCreate(w http.ResponseWriter, r *http.Request) {\n\th.groupCreate(w, r, \"tunnel_group\")\n}\n\nfunc (h *Handler) groupTunnelUpdate(w http.ResponseWriter, r *http.Request) {\n\th.groupUpdate(w, r, \"tunnel_group\")\n}\n\nfunc (h *Handler) groupTunnelDelete(w http.ResponseWriter, r *http.Request) {\n\th.groupDelete(w, r, \"tunnel_group\")\n}\n\nfunc (h *Handler) groupUserCreate(w http.ResponseWriter, r *http.Request) {\n\th.groupCreate(w, r, \"user_group\")\n}\n\nfunc (h *Handler) groupUserUpdate(w http.ResponseWriter, r *http.Request) {\n\th.groupUpdate(w, r, \"user_group\")\n}\n\nfunc (h *Handler) groupUserDelete(w http.ResponseWriter, r *http.Request) {\n\th.groupDelete(w, r, \"user_group\")\n}\n\nfunc (h *Handler) groupTunnelAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tGroupID int64 `json:\"groupId\"`\n\t\tTunnelIDs []int64 `json:\"tunnelIds\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.GroupID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\tif err := h.repo.ReplaceTunnelGroupMembersTx(tx, req.GroupID, req.TunnelIDs, time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := tx.Commit().Error; err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\t_ = h.syncPermissionsByTunnelGroup(req.GroupID)\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupUserAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tGroupID int64 `json:\"groupId\"`\n\t\tUserIDs []int64 `json:\"userIds\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.GroupID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\tpreviousUserIDs, err := h.repo.ListUserIDsByUserGroupTx(tx, req.GroupID)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.repo.ReplaceUserGroupMembersTx(tx, req.GroupID, req.UserIDs, time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := h.repo.RevokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif err := tx.Commit().Error; err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\t_ = h.syncPermissionsByUserGroup(req.GroupID)\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupPermissionAssign(w http.ResponseWriter, r *http.Request) {\n\tvar req struct {\n\t\tUserGroupID int64 `json:\"userGroupId\"`\n\t\tTunnelGroupID int64 `json:\"tunnelGroupId\"`\n\t}\n\tif err := decodeJSON(r.Body, &req); err != nil || req.UserGroupID <= 0 || req.TunnelGroupID <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tif err := h.repo.InsertGroupPermission(req.UserGroupID, req.TunnelGroupID, time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\t_ = h.applyGroupPermission(req.UserGroupID, req.TunnelGroupID)\n\tresponse.WriteJSON(w, response.OK(\"权限分配成功\"))\n}\n\nfunc (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\ttx := h.repo.BeginTx()\n\tif tx.Error != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n\t\treturn\n\t}\n\tdefer func() { tx.Rollback() }()\n\n\tug, tg, exists, err := h.repo.GetGroupPermissionPairByIDTx(tx, id)\n\tif err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\n\tif err := h.repo.DeleteGroupPermissionByIDTx(tx, id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tif exists {\n\t\tif err := h.repo.RevokeGroupPermissionPairTx(tx, ug, tg); err != nil {\n\t\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\n\tif err := tx.Commit().Error; err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupCreate(w http.ResponseWriter, r *http.Request, table string) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tname := asString(req[\"name\"])\n\tif name == \"\" {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"分组名称不能为空\"))\n\t\treturn\n\t}\n\tnow := time.Now().UnixMilli()\n\tif err := h.repo.GroupCreate(table, name, asInt(req[\"status\"], 1), now); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupUpdate(w http.ResponseWriter, r *http.Request, table string) {\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn\n\t}\n\tid := asInt64(req[\"id\"], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"分组ID不能为空\"))\n\t\treturn\n\t}\n\tif err := h.repo.GroupUpdate(table, id, asString(req[\"name\"]), asInt(req[\"status\"], 1), time.Now().UnixMilli()); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) groupDelete(w http.ResponseWriter, r *http.Request, table string) {\n\tid := idFromBody(r, w)\n\tif id <= 0 {\n\t\treturn\n\t}\n\tif err := h.repo.GroupDeleteCascade(table, id); err != nil {\n\t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n\t\treturn\n\t}\n\tresponse.WriteJSON(w, response.OKEmpty())\n}\n\nfunc (h *Handler) applyGroupPermission(userGroupID, tunnelGroupID int64) error {\n\tuserIDs, _ := h.repo.ListUserIDsByUserGroup(userGroupID)\n\ttunnelIDs, _ := h.repo.ListTunnelIDsByTunnelGroup(tunnelGroupID)\n\tfor _, uid := range userIDs {\n\t\tfor _, tid := range tunnelIDs {\n\t\t\tutID, created, err := h.repo.EnsureUserTunnelGrant(uid, tid)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcreatedByGroup := 0\n\t\t\tif created {\n\t\t\t\tcreatedByGroup = 1\n\t\t\t}\n\t\t\th.repo.InsertGroupPermissionGrant(userGroupID, tunnelGroupID, utID, createdByGroup, time.Now().UnixMilli())\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) syncPermissionsByUserGroup(userGroupID int64) error {\n\tpairs, err := h.repo.ListGroupPermissionPairsByUserGroup(userGroupID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, p := range pairs {\n\t\t_ = h.applyGroupPermission(p[0], p[1])\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) syncPermissionsByTunnelGroup(tunnelGroupID int64) error {\n\tpairs, err := h.repo.ListGroupPermissionPairsByTunnelGroup(tunnelGroupID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, p := range pairs {\n\t\t_ = h.applyGroupPermission(p[0], p[1])\n\t}\n\treturn nil\n}\n\ntype tunnelRuntimeNode struct {\n\tNodeID int64\n\tProtocol string\n\tStrategy string\n\tInx int\n\tChainType int\n\tPort int\n\tConnectIP string\n}\n\ntype tunnelCreateState struct {\n\tTunnelID int64\n\tType int\n\tIPPreference string // \"\" = auto, \"v4\" = prefer IPv4, \"v6\" = prefer IPv6\n\tInNodes []tunnelRuntimeNode\n\tChainHops [][]tunnelRuntimeNode\n\tOutNodes []tunnelRuntimeNode\n\tNodes map[int64]*nodeRecord\n\tNodeIDList []int64\n}\n\nfunc (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) {\n\tstate := &tunnelCreateState{\n\t\tType: tunnelType,\n\t\tInNodes: make([]tunnelRuntimeNode, 0),\n\t\tChainHops: make([][]tunnelRuntimeNode, 0),\n\t\tOutNodes: make([]tunnelRuntimeNode, 0),\n\t\tNodes: make(map[int64]*nodeRecord),\n\t}\n\tnodeIDs := make([]int64, 0)\n\n\tfor _, item := range asMapSlice(req[\"inNodeId\"]) {\n\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\tif nodeID <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tnodeIDs = append(nodeIDs, nodeID)\n\t\tstate.InNodes = append(state.InNodes, tunnelRuntimeNode{\n\t\t\tNodeID: nodeID,\n\t\t\tProtocol: defaultString(asString(item[\"protocol\"]), \"tls\"),\n\t\t\tStrategy: defaultString(asString(item[\"strategy\"]), \"round\"),\n\t\t\tChainType: 1,\n\t\t})\n\t}\n\tif len(state.InNodes) == 0 {\n\t\treturn nil, errors.New(\"入口不能为空\")\n\t}\n\n\tif tunnelType == 2 {\n\t\toutNodesRaw := asMapSlice(req[\"outNodeId\"])\n\t\tif len(outNodesRaw) == 0 {\n\t\t\treturn nil, errors.New(\"出口不能为空\")\n\t\t}\n\n\t\tallocated := map[int64]int{}\n\t\tfor _, item := range outNodesRaw {\n\t\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\t\tif nodeID <= 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tnodeIDs = append(nodeIDs, nodeID)\n\t\t\tport := asInt(item[\"port\"], 0)\n\t\t\tif port <= 0 {\n\t\t\t\tisRemote, remoteErr := h.repo.IsRemoteNodeTx(tx, nodeID)\n\t\t\t\tif remoteErr != nil {\n\t\t\t\t\treturn nil, remoteErr\n\t\t\t\t}\n\t\t\t\tif !isRemote {\n\t\t\t\t\tvar err error\n\t\t\t\t\tport, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn nil, err\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tstate.OutNodes = append(state.OutNodes, tunnelRuntimeNode{\n\t\t\t\tNodeID: nodeID,\n\t\t\t\tProtocol: defaultString(asString(item[\"protocol\"]), \"tls\"),\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\t\t\treturn nil, errors.New(\"出口不能为空\")\n\t\t}\n\n\t\tfor hopIdx, hopRaw := range asAnySlice(req[\"chainNodes\"]) {\n\t\t\thop := make([]tunnelRuntimeNode, 0)\n\t\t\tfor _, item := range asMapSlice(hopRaw) {\n\t\t\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\t\t\tif nodeID <= 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tnodeIDs = append(nodeIDs, nodeID)\n\t\t\t\tport := asInt(item[\"port\"], 0)\n\t\t\t\tif port <= 0 {\n\t\t\t\t\tisRemote, remoteErr := h.repo.IsRemoteNodeTx(tx, nodeID)\n\t\t\t\t\tif remoteErr != nil {\n\t\t\t\t\t\treturn nil, remoteErr\n\t\t\t\t\t}\n\t\t\t\t\tif !isRemote {\n\t\t\t\t\t\tvar err error\n\t\t\t\t\t\tport, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\treturn nil, err\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\thop = append(hop, tunnelRuntimeNode{\n\t\t\t\t\tNodeID: nodeID,\n\t\t\t\t\tProtocol: defaultString(asString(item[\"protocol\"]), \"tls\"),\n\t\t\t\t\tStrategy: defaultString(asString(item[\"strategy\"]), \"round\"),\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\t\t\t\tstate.ChainHops = append(state.ChainHops, hop)\n\t\t\t}\n\t\t}\n\t}\n\n\tseen := make(map[int64]struct{}, len(nodeIDs))\n\tfor _, nodeID := range nodeIDs {\n\t\tif _, ok := seen[nodeID]; ok {\n\t\t\treturn nil, errors.New(\"节点重复\")\n\t\t}\n\t\tseen[nodeID] = struct{}{}\n\t\tstate.NodeIDList = append(state.NodeIDList, nodeID)\n\t\tnode, err := h.repo.GetNodeRecordTx(tx, nodeID)\n\t\tif err != nil {\n\t\t\tif strings.Contains(err.Error(), \"不存在\") {\n\t\t\t\treturn nil, errors.New(\"节点不存在\")\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tif node == nil {\n\t\t\treturn nil, errors.New(\"节点不存在\")\n\t\t}\n\t\tif node.IsRemote != 1 && node.Status != 1 {\n\t\t\treturn nil, errors.New(\"部分节点不在线\")\n\t\t}\n\t\tstate.Nodes[nodeID] = node\n\t}\n\n\tfor _, outNode := range state.OutNodes {\n\t\tif err := validateRemoteNodePort(state.Nodes[outNode.NodeID], outNode.Port); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tfor _, hop := range state.ChainHops {\n\t\tfor _, chainNode := range hop {\n\t\t\tif err := validateRemoteNodePort(state.Nodes[chainNode.NodeID], chainNode.Port); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn state, nil\n}\n\nfunc buildTunnelInIP(inNodes []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) string {\n\tset := make(map[string]struct{})\n\tordered := make([]string, 0)\n\tpreferV6 := strings.TrimSpace(ipPreference) == \"v6\"\n\tfor _, inNode := range inNodes {\n\t\tnode := nodes[inNode.NodeID]\n\t\tif node == nil {\n\t\t\tcontinue\n\t\t}\n\t\tv4 := strings.TrimSpace(node.ServerIPv4)\n\t\tv6 := strings.TrimSpace(node.ServerIPv6)\n\t\tvar addrs []string\n\t\tif preferV6 {\n\t\t\tif v6 != \"\" {\n\t\t\t\taddrs = append(addrs, v6)\n\t\t\t}\n\t\t\tif v4 != \"\" {\n\t\t\t\taddrs = append(addrs, v4)\n\t\t\t}\n\t\t} else {\n\t\t\tif v4 != \"\" {\n\t\t\t\taddrs = append(addrs, v4)\n\t\t\t}\n\t\t\tif v6 != \"\" {\n\t\t\t\taddrs = append(addrs, v6)\n\t\t\t}\n\t\t}\n\t\tif len(addrs) == 0 {\n\t\t\tif v := strings.TrimSpace(node.ServerIP); v != \"\" {\n\t\t\t\taddrs = append(addrs, v)\n\t\t\t}\n\t\t}\n\t\tfor _, a := range addrs {\n\t\t\tif _, ok := set[a]; !ok {\n\t\t\t\tset[a] = struct{}{}\n\t\t\t\tordered = append(ordered, a)\n\t\t\t}\n\t\t}\n\t}\n\treturn strings.Join(ordered, \",\")\n}\n\nfunc applyTunnelPortsToRequest(req map[string]interface{}, state *tunnelCreateState) {\n\tif req == nil || state == nil {\n\t\treturn\n\t}\n\toutPorts := make(map[int64]int)\n\tfor _, n := range state.OutNodes {\n\t\toutPorts[n.NodeID] = n.Port\n\t}\n\tfor _, item := range asMapSlice(req[\"outNodeId\"]) {\n\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\tif port, ok := outPorts[nodeID]; ok && port > 0 {\n\t\t\titem[\"port\"] = port\n\t\t}\n\t}\n\n\tchainPorts := make(map[int64]int)\n\tfor _, hop := range state.ChainHops {\n\t\tfor _, n := range hop {\n\t\t\tchainPorts[n.NodeID] = n.Port\n\t\t}\n\t}\n\tfor _, hopRaw := range asAnySlice(req[\"chainNodes\"]) {\n\t\tfor _, item := range asMapSlice(hopRaw) {\n\t\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n\t\t\tif port, ok := chainPorts[nodeID]; ok && port > 0 {\n\t\t\t\titem[\"port\"] = port\n\t\t\t}\n\t\t}\n\t}\n}\n\ntype federationRuntimeReleaseRef struct {\n\tRemoteURL string\n\tRemoteToken string\n\tBindingID string\n\tReservationID string\n\tResourceKey string\n}\n\nfunc federationRuntimeResourceKey(tunnelID int64, nodeID int64, chainType int, hopInx int) string {\n\treturn fmt.Sprintf(\"tunnel:%d:node:%d:type:%d:hop:%d\", tunnelID, nodeID, chainType, hopInx)\n}\n\nfunc remoteShareIDFromConfig(raw string) int64 {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn 0\n\t}\n\tvar cfg map[string]interface{}\n\tif err := json.Unmarshal([]byte(raw), &cfg); err != nil {\n\t\treturn 0\n\t}\n\treturn asInt64(cfg[\"shareId\"], 0)\n}\n\nfunc (h *Handler) federationLocalDomain() string {\n\tcfg, _ := h.repo.GetConfigByName(\"panel_domain\")\n\tif cfg == nil {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(cfg.Value)\n}\n\nfunc (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain string) ([]repo.FederationTunnelBinding, []federationRuntimeReleaseRef, error) {\n\tbindings := make([]repo.FederationTunnelBinding, 0)\n\treleaseRefs := make([]federationRuntimeReleaseRef, 0)\n\tif h == nil || state == nil {\n\t\treturn bindings, releaseRefs, nil\n\t}\n\tfc := client.NewFederationClient()\n\tnow := time.Now().UnixMilli()\n\n\tfor outIdx := range state.OutNodes {\n\t\toutNode := state.OutNodes[outIdx]\n\t\tnode := state.Nodes[outNode.NodeID]\n\t\tif node == nil || node.IsRemote != 1 {\n\t\t\tcontinue\n\t\t}\n\t\tremoteURL := strings.TrimSpace(node.RemoteURL)\n\t\tremoteToken := strings.TrimSpace(node.RemoteToken)\n\t\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 缺少共享配置\", nodeDisplayName(node))\n\t\t}\n\n\t\tresourceKey := federationRuntimeResourceKey(state.TunnelID, outNode.NodeID, 3, 0)\n\t\treserveReq := client.RuntimeReservePortRequest{\n\t\t\tResourceKey: resourceKey,\n\t\t\tProtocol: defaultString(outNode.Protocol, \"tls\"),\n\t\t\tRequestedPort: outNode.Port,\n\t\t}\n\t\treserveRes, err := fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n\t\tif err != nil && reserveReq.RequestedPort > 0 {\n\t\t\treserveReq.RequestedPort = 0\n\t\t\treserveRes, err = fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n\t\t}\n\t\tif err != nil {\n\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 端口分配失败: %w\", nodeDisplayName(node), err)\n\t\t}\n\n\t\tstate.OutNodes[outIdx].Port = reserveRes.AllocatedPort\n\t\toutNode = state.OutNodes[outIdx]\n\n\t\tapplyReq := client.RuntimeApplyRoleRequest{\n\t\t\tReservationID: reserveRes.ReservationID,\n\t\t\tResourceKey: resourceKey,\n\t\t\tRole: \"exit\",\n\t\t\tProtocol: defaultString(outNode.Protocol, \"tls\"),\n\t\t\tStrategy: defaultString(outNode.Strategy, \"round\"),\n\t\t}\n\t\tapplyRes, err := fc.ApplyRole(remoteURL, remoteToken, localDomain, applyReq)\n\t\tif err != nil {\n\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 运行时下发失败: %w\", nodeDisplayName(node), err)\n\t\t}\n\t\tif applyRes.AllocatedPort > 0 {\n\t\t\tstate.OutNodes[outIdx].Port = applyRes.AllocatedPort\n\t\t\toutNode = state.OutNodes[outIdx]\n\t\t}\n\n\t\tbindings = append(bindings, repo.FederationTunnelBinding{\n\t\t\tTunnelID: state.TunnelID,\n\t\t\tNodeID: outNode.NodeID,\n\t\t\tChainType: 3,\n\t\t\tHopInx: 0,\n\t\t\tRemoteURL: remoteURL,\n\t\t\tResourceKey: resourceKey,\n\t\t\tRemoteBindingID: defaultString(applyRes.BindingID, reserveRes.BindingID),\n\t\t\tAllocatedPort: outNode.Port,\n\t\t\tStatus: 1,\n\t\t\tCreatedTime: now,\n\t\t\tUpdatedTime: now,\n\t\t})\n\t\treleaseRefs = append(releaseRefs, federationRuntimeReleaseRef{\n\t\t\tRemoteURL: remoteURL,\n\t\t\tRemoteToken: remoteToken,\n\t\t\tBindingID: applyRes.BindingID,\n\t\t\tReservationID: reserveRes.ReservationID,\n\t\t\tResourceKey: resourceKey,\n\t\t})\n\t}\n\n\tfor hopIdx := len(state.ChainHops) - 1; hopIdx >= 0; hopIdx-- {\n\t\tfor nodeIdx := range state.ChainHops[hopIdx] {\n\t\t\tchainNode := state.ChainHops[hopIdx][nodeIdx]\n\t\t\tnode := state.Nodes[chainNode.NodeID]\n\t\t\tif node == nil || node.IsRemote != 1 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tremoteURL := strings.TrimSpace(node.RemoteURL)\n\t\t\tremoteToken := strings.TrimSpace(node.RemoteToken)\n\t\t\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 缺少共享配置\", nodeDisplayName(node))\n\t\t\t}\n\n\t\t\tresourceKey := federationRuntimeResourceKey(state.TunnelID, chainNode.NodeID, 2, hopIdx+1)\n\t\t\treserveReq := client.RuntimeReservePortRequest{\n\t\t\t\tResourceKey: resourceKey,\n\t\t\t\tProtocol: defaultString(chainNode.Protocol, \"tls\"),\n\t\t\t\tRequestedPort: chainNode.Port,\n\t\t\t}\n\t\t\treserveRes, err := fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n\t\t\tif err != nil && reserveReq.RequestedPort > 0 {\n\t\t\t\treserveReq.RequestedPort = 0\n\t\t\t\treserveRes, err = fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 端口分配失败: %w\", nodeDisplayName(node), err)\n\t\t\t}\n\n\t\t\tstate.ChainHops[hopIdx][nodeIdx].Port = reserveRes.AllocatedPort\n\t\t\tchainNode = state.ChainHops[hopIdx][nodeIdx]\n\n\t\t\tnextTargets := state.OutNodes\n\t\t\tif hopIdx+1 < len(state.ChainHops) {\n\t\t\t\tnextTargets = state.ChainHops[hopIdx+1]\n\t\t\t}\n\t\t\tapplyTargets := make([]client.RuntimeTarget, 0, len(nextTargets))\n\t\t\tfor _, target := range nextTargets {\n\t\t\t\ttargetNode := state.Nodes[target.NodeID]\n\t\t\t\tif targetNode == nil {\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, 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\t\t\t\t}\n\t\t\t\tif target.Port <= 0 {\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\tapplyTargets = append(applyTargets, client.RuntimeTarget{\n\t\t\t\t\tHost: host,\n\t\t\t\t\tPort: target.Port,\n\t\t\t\t\tProtocol: defaultString(target.Protocol, \"tls\"),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tapplyReq := client.RuntimeApplyRoleRequest{\n\t\t\t\tReservationID: reserveRes.ReservationID,\n\t\t\t\tResourceKey: resourceKey,\n\t\t\t\tRole: \"middle\",\n\t\t\t\tProtocol: defaultString(chainNode.Protocol, \"tls\"),\n\t\t\t\tStrategy: defaultString(chainNode.Strategy, \"round\"),\n\t\t\t\tTargets: applyTargets,\n\t\t\t}\n\t\t\tapplyRes, err := fc.ApplyRole(remoteURL, remoteToken, localDomain, applyReq)\n\t\t\tif err != nil {\n\t\t\t\th.releaseFederationRuntimeRefs(releaseRefs)\n\t\t\t\treturn nil, nil, fmt.Errorf(\"远程节点 %s 运行时下发失败: %w\", nodeDisplayName(node), err)\n\t\t\t}\n\t\t\tif applyRes.AllocatedPort > 0 {\n\t\t\t\tstate.ChainHops[hopIdx][nodeIdx].Port = applyRes.AllocatedPort\n\t\t\t\tchainNode = state.ChainHops[hopIdx][nodeIdx]\n\t\t\t}\n\n\t\t\tbindings = append(bindings, repo.FederationTunnelBinding{\n\t\t\t\tTunnelID: state.TunnelID,\n\t\t\t\tNodeID: chainNode.NodeID,\n\t\t\t\tChainType: 2,\n\t\t\t\tHopInx: hopIdx + 1,\n\t\t\t\tRemoteURL: remoteURL,\n\t\t\t\tResourceKey: resourceKey,\n\t\t\t\tRemoteBindingID: defaultString(applyRes.BindingID, reserveRes.BindingID),\n\t\t\t\tAllocatedPort: chainNode.Port,\n\t\t\t\tStatus: 1,\n\t\t\t\tCreatedTime: now,\n\t\t\t\tUpdatedTime: now,\n\t\t\t})\n\t\t\treleaseRefs = append(releaseRefs, federationRuntimeReleaseRef{\n\t\t\t\tRemoteURL: remoteURL,\n\t\t\t\tRemoteToken: remoteToken,\n\t\t\t\tBindingID: applyRes.BindingID,\n\t\t\t\tReservationID: reserveRes.ReservationID,\n\t\t\t\tResourceKey: resourceKey,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn bindings, releaseRefs, nil\n}\n\nfunc (h *Handler) releaseFederationRuntimeRefs(refs []federationRuntimeReleaseRef) {\n\tif h == nil || len(refs) == 0 {\n\t\treturn\n\t}\n\tfc := client.NewFederationClient()\n\tlocalDomain := h.federationLocalDomain()\n\tfor i := len(refs) - 1; i >= 0; i-- {\n\t\tref := refs[i]\n\t\tif strings.TrimSpace(ref.RemoteURL) == \"\" || strings.TrimSpace(ref.RemoteToken) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\treq := client.RuntimeReleaseRoleRequest{\n\t\t\tBindingID: ref.BindingID,\n\t\t\tReservationID: ref.ReservationID,\n\t\t\tResourceKey: ref.ResourceKey,\n\t\t}\n\t\t_ = fc.ReleaseRole(ref.RemoteURL, ref.RemoteToken, localDomain, req)\n\t}\n}\n\nfunc (h *Handler) cleanupFederationRuntime(tunnelID int64) {\n\tif h == nil || tunnelID <= 0 {\n\t\treturn\n\t}\n\tbindings, err := h.repo.ListActiveFederationTunnelBindingsByTunnel(tunnelID)\n\tif err != nil || len(bindings) == 0 {\n\t\treturn\n\t}\n\n\tfc := client.NewFederationClient()\n\tlocalDomain := h.federationLocalDomain()\n\tfor _, b := range bindings {\n\t\tnode, nodeErr := h.repo.GetNodeByID(b.NodeID)\n\t\tif nodeErr != nil || node == nil {\n\t\t\tcontinue\n\t\t}\n\t\tremoteURL := strings.TrimSpace(node.RemoteURL.String)\n\t\tif remoteURL == \"\" {\n\t\t\tremoteURL = strings.TrimSpace(b.RemoteURL)\n\t\t}\n\t\tremoteToken := strings.TrimSpace(node.RemoteToken.String)\n\t\tif remoteURL == \"\" || remoteToken == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\treq := client.RuntimeReleaseRoleRequest{\n\t\t\tBindingID: strings.TrimSpace(b.RemoteBindingID),\n\t\t\tResourceKey: strings.TrimSpace(b.ResourceKey),\n\t\t}\n\t\t_ = fc.ReleaseRole(remoteURL, remoteToken, localDomain, req)\n\t}\n\t_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)\n}\n\nfunc (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64, error) {\n\tif h == nil || state == nil {\n\t\treturn nil, nil, errors.New(\"invalid tunnel runtime state\")\n\t}\n\tcreatedChains := make([]int64, 0)\n\tcreatedServices := make([]int64, 0)\n\tif state.Type != 2 {\n\t\treturn createdChains, createdServices, nil\n\t}\n\n\tfor _, inNode := range state.InNodes {\n\t\tnode := state.Nodes[inNode.NodeID]\n\t\ttargets := state.OutNodes\n\t\tif len(state.ChainHops) > 0 {\n\t\t\ttargets = state.ChainHops[0]\n\t\t}\n\t\tchainData, err := buildTunnelChainConfig(state.TunnelID, inNode.NodeID, targets, state.Nodes, state.IPPreference)\n\t\tif err != nil {\n\t\t\treturn createdChains, createdServices, err\n\t\t}\n\t\tif _, err := h.sendNodeCommand(inNode.NodeID, \"AddChains\", chainData, true, false); err != nil {\n\t\t\tif node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn createdChains, createdServices, fmt.Errorf(\"入口节点 %s 下发转发链失败: %w\", nodeDisplayName(state.Nodes[inNode.NodeID]), err)\n\t\t}\n\t\tcreatedChains = append(createdChains, inNode.NodeID)\n\t}\n\n\tfor i, hop := range state.ChainHops {\n\t\tnextTargets := state.OutNodes\n\t\tif i+1 < len(state.ChainHops) {\n\t\t\tnextTargets = state.ChainHops[i+1]\n\t\t}\n\t\tfor _, chainNode := range hop {\n\t\t\tif node := state.Nodes[chainNode.NodeID]; node != nil && node.IsRemote == 1 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tchainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)\n\t\t\tif err != nil {\n\t\t\t\treturn createdChains, createdServices, err\n\t\t\t}\n\t\t\tif _, err := h.sendNodeCommand(chainNode.NodeID, \"AddChains\", chainData, true, false); err != nil {\n\t\t\t\treturn createdChains, createdServices, fmt.Errorf(\"转发链节点 %s 下发转发链失败: %w\", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)\n\t\t\t}\n\t\t\tcreatedChains = append(createdChains, chainNode.NodeID)\n\n\t\t\tserviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID])\n\t\t\tif _, err := h.sendNodeCommand(chainNode.NodeID, \"AddService\", serviceData, true, false); err != nil {\n\t\t\t\treturn createdChains, createdServices, fmt.Errorf(\"转发链节点 %s 下发服务失败: %w\", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)\n\t\t\t}\n\t\t\tcreatedServices = append(createdServices, chainNode.NodeID)\n\t\t}\n\t}\n\n\tfor _, outNode := range state.OutNodes {\n\t\tif node := state.Nodes[outNode.NodeID]; node != nil && node.IsRemote == 1 {\n\t\t\tcontinue\n\t\t}\n\t\tserviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID])\n\t\tif _, err := h.sendNodeCommand(outNode.NodeID, \"AddService\", serviceData, true, false); err != nil {\n\t\t\treturn createdChains, createdServices, fmt.Errorf(\"出口节点 %s 下发服务失败: %w\", nodeDisplayName(state.Nodes[outNode.NodeID]), err)\n\t\t}\n\t\tcreatedServices = append(createdServices, outNode.NodeID)\n\t}\n\n\treturn createdChains, createdServices, nil\n}\n\nfunc (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tunnelID int64) {\n\tif h == nil || tunnelID <= 0 {\n\t\treturn\n\t}\n\tseenServices := make(map[int64]struct{})\n\tserviceName := fmt.Sprintf(\"%d_tls\", tunnelID)\n\tfor i := len(serviceNodeIDs) - 1; i >= 0; i-- {\n\t\tnodeID := serviceNodeIDs[i]\n\t\tif _, ok := seenServices[nodeID]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseenServices[nodeID] = struct{}{}\n\t\t_, _ = h.sendNodeCommand(nodeID, \"DeleteService\", map[string]interface{}{\"services\": []string{serviceName}}, false, true)\n\t}\n\n\tseenChains := make(map[int64]struct{})\n\tchainName := fmt.Sprintf(\"chains_%d\", tunnelID)\n\tfor i := len(chainNodeIDs) - 1; i >= 0; i-- {\n\t\tnodeID := chainNodeIDs[i]\n\t\tif _, ok := seenChains[nodeID]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseenChains[nodeID] = struct{}{}\n\t\t_, _ = h.sendNodeCommand(nodeID, \"DeleteChains\", map[string]interface{}{\"chain\": chainName}, false, true)\n\t}\n}\n\nfunc shouldDeferTunnelRuntimeApplyError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tmsg := strings.ToLower(strings.TrimSpace(err.Error()))\n\tif msg == \"\" {\n\t\treturn false\n\t}\n\tif strings.Contains(msg, \"节点不在线\") {\n\t\treturn true\n\t}\n\tif strings.Contains(msg, \"等待节点响应超时\") || strings.Contains(msg, \"timeout\") || strings.Contains(msg, \"超时\") {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) (map[string]interface{}, error) {\n\tfromNode := nodes[fromNodeID]\n\tif fromNode == nil {\n\t\treturn nil, errors.New(\"节点不存在\")\n\t}\n\tif len(targets) == 0 {\n\t\treturn nil, errors.New(\"转发链目标不能为空\")\n\t}\n\tnodeItems := make([]map[string]interface{}, 0, len(targets))\n\tfor idx, target := range targets {\n\t\ttargetNode := nodes[target.NodeID]\n\t\tif targetNode == nil {\n\t\t\treturn nil, errors.New(\"节点不存在\")\n\t\t}\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\t\tport := target.Port\n\t\tif port <= 0 {\n\t\t\treturn nil, errors.New(\"节点端口不能为空\")\n\t\t}\n\t\tprotocol := defaultString(target.Protocol, \"tls\")\n\t\tconnector := map[string]interface{}{\n\t\t\t\"type\": \"relay\",\n\t\t}\n\t\tif isTLSTunnelProtocol(protocol) {\n\t\t\tconnector[\"metadata\"] = map[string]interface{}{\"nodelay\": true}\n\t\t}\n\t\tnodeItems = append(nodeItems, map[string]interface{}{\n\t\t\t\"name\": fmt.Sprintf(\"node_%d\", idx+1),\n\t\t\t\"addr\": processServerAddress(fmt.Sprintf(\"%s:%d\", host, port)),\n\t\t\t\"connector\": connector,\n\t\t\t\"dialer\": map[string]interface{}{\n\t\t\t\t\"type\": protocol,\n\t\t\t},\n\t\t})\n\t}\n\n\tstrategy := defaultString(strings.TrimSpace(targets[0].Strategy), \"round\")\n\thop := map[string]interface{}{\n\t\t\"name\": fmt.Sprintf(\"hop_%d\", tunnelID),\n\t\t\"selector\": map[string]interface{}{\n\t\t\t\"strategy\": strategy,\n\t\t\t\"maxFails\": 1,\n\t\t\t\"failTimeout\": int64(600000000000),\n\t\t},\n\t\t\"nodes\": nodeItems,\n\t}\n\tif strings.TrimSpace(fromNode.InterfaceName) != \"\" {\n\t\thop[\"interface\"] = fromNode.InterfaceName\n\t}\n\n\treturn map[string]interface{}{\n\t\t\"name\": fmt.Sprintf(\"chains_%d\", tunnelID),\n\t\t\"hops\": []map[string]interface{}{hop},\n\t}, nil\n}\n\nfunc buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode, node *nodeRecord) []map[string]interface{} {\n\tif node == nil {\n\t\treturn nil\n\t}\n\tprotocol := defaultString(chainNode.Protocol, \"tls\")\n\thandlerCfg := map[string]interface{}{\n\t\t\"type\": \"relay\",\n\t}\n\tif isTLSTunnelProtocol(protocol) {\n\t\thandlerCfg[\"metadata\"] = map[string]interface{}{\"nodelay\": true}\n\t}\n\tservice := map[string]interface{}{\n\t\t\"name\": fmt.Sprintf(\"%d_tls\", tunnelID),\n\t\t\"addr\": processServerAddress(fmt.Sprintf(\"%s:%d\", defaultString(strings.TrimSpace(chainNode.ConnectIP), node.TCPListenAddr), chainNode.Port)),\n\t\t\"handler\": handlerCfg,\n\t\t\"listener\": map[string]interface{}{\n\t\t\t\"type\": protocol,\n\t\t},\n\t}\n\tif chainNode.ChainType == 2 {\n\t\tservice[\"handler\"].(map[string]interface{})[\"chain\"] = fmt.Sprintf(\"chains_%d\", tunnelID)\n\t}\n\tif chainNode.ChainType == 3 && strings.TrimSpace(node.InterfaceName) != \"\" {\n\t\tservice[\"metadata\"] = map[string]interface{}{\"interface\": node.InterfaceName}\n\t}\n\treturn []map[string]interface{}{service}\n}\n\nfunc 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\ttoV6 := nodeSupportsV6(toNode)\n\n\tswitch strings.TrimSpace(ipPreference) {\n\tcase \"v6\":\n\t\tif fromV6 && toV6 {\n\t\t\tif host := pickNodeAddressV6(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\t\tif fromV4 && toV4 {\n\t\t\tif host := pickNodeAddressV4(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\tcase \"v4\":\n\t\tif fromV4 && toV4 {\n\t\t\tif host := pickNodeAddressV4(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\t\tif fromV6 && toV6 {\n\t\t\tif host := pickNodeAddressV6(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\tdefault:\n\t\tif fromV4 && toV4 {\n\t\t\tif host := pickNodeAddressV4(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\t\tif fromV6 && toV6 {\n\t\t\tif host := pickNodeAddressV6(toNode); host != \"\" {\n\t\t\t\treturn host, nil\n\t\t\t}\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"节点链路不兼容:%s(v4=%t,v6=%t) -> %s(v4=%t,v6=%t)\", nodeDisplayName(fromNode), fromV4, fromV6, nodeDisplayName(toNode), toV4, toV6)\n}\n\nfunc nodeDisplayName(node *nodeRecord) string {\n\tif node == nil {\n\t\treturn \"node\"\n\t}\n\tif strings.TrimSpace(node.Name) != \"\" {\n\t\treturn strings.TrimSpace(node.Name)\n\t}\n\treturn fmt.Sprintf(\"node_%d\", node.ID)\n}\n\nfunc isTLSTunnelProtocol(protocol string) bool {\n\treturn strings.EqualFold(strings.TrimSpace(defaultString(protocol, \"tls\")), \"tls\")\n}\n\nfunc nodeSupportsV4(node *nodeRecord) bool {\n\tif node == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(node.ServerIPv4) != \"\" {\n\t\treturn true\n\t}\n\tif strings.TrimSpace(node.ServerIPv6) != \"\" {\n\t\treturn false\n\t}\n\tlegacy := strings.Trim(strings.TrimSpace(node.ServerIP), \"[]\")\n\tif legacy == \"\" {\n\t\treturn false\n\t}\n\tif ip := net.ParseIP(legacy); ip != nil {\n\t\treturn ip.To4() != nil\n\t}\n\treturn true\n}\n\nfunc nodeSupportsV6(node *nodeRecord) bool {\n\tif node == nil {\n\t\treturn false\n\t}\n\tif strings.TrimSpace(node.ServerIPv6) != \"\" {\n\t\treturn true\n\t}\n\tif strings.TrimSpace(node.ServerIPv4) != \"\" {\n\t\treturn false\n\t}\n\tlegacy := strings.Trim(strings.TrimSpace(node.ServerIP), \"[]\")\n\tif legacy == \"\" {\n\t\treturn false\n\t}\n\tif ip := net.ParseIP(legacy); ip != nil {\n\t\treturn ip.To4() == nil\n\t}\n\treturn true\n}\n\nfunc pickNodeAddressV4(node *nodeRecord) string {\n\tif node == nil {\n\t\treturn \"\"\n\t}\n\tif v := strings.TrimSpace(node.ServerIPv4); v != \"\" {\n\t\treturn v\n\t}\n\treturn strings.TrimSpace(node.ServerIP)\n}\n\nfunc pickNodeAddressV6(node *nodeRecord) string {\n\tif node == nil {\n\t\treturn \"\"\n\t}\n\tif v := strings.TrimSpace(node.ServerIPv6); v != \"\" {\n\t\treturn v\n\t}\n\treturn strings.TrimSpace(node.ServerIP)\n}\n\nfunc (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[string]interface{}) error {\n\tallocated := map[int64]int{}\n\tinNodes := asMapSlice(req[\"inNodeId\"])\n\tfor i, n := range inNodes {\n\t\tnodeID := asInt64(n[\"nodeId\"], 0)\n\t\tif nodeID <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif err := h.repo.CreateChainTunnelTx(\n\t\t\ttx,\n\t\t\ttunnelID,\n\t\t\t\"1\",\n\t\t\tnodeID,\n\t\t\tsql.NullInt64{},\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\t}\n\tfor i, n := range asMapSlice(req[\"outNodeId\"]) {\n\t\tnodeID := asInt64(n[\"nodeId\"], 0)\n\t\tif nodeID <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tport := asInt(n[\"port\"], 0)\n\t\tif port <= 0 {\n\t\t\tvar pickErr error\n\t\t\tport, pickErr = h.repo.PickNodePortTx(tx, nodeID, allocated, 0)\n\t\t\tif pickErr != nil {\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\t\t\t\"3\",\n\t\t\tnodeID,\n\t\t\tsql.NullInt64{Int64: int64(port), Valid: true},\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\t}\n\tchainNodes := asAnySlice(req[\"chainNodes\"])\n\tfor i, grp := range chainNodes {\n\t\tfor _, n := range asMapSlice(grp) {\n\t\t\tnodeID := asInt64(n[\"nodeId\"], 0)\n\t\t\tif nodeID <= 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tport := asInt(n[\"port\"], 0)\n\t\t\tif port <= 0 {\n\t\t\t\tvar pickErr error\n\t\t\t\tport, pickErr = h.repo.PickNodePortTx(tx, nodeID, allocated, 0)\n\t\t\t\tif pickErr != nil {\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\t\t\t\t\"2\",\n\t\t\t\tnodeID,\n\t\t\t\tsql.NullInt64{Int64: int64(port), Valid: true},\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\t\t}\n\t}\n\treturn nil\n}\n\nfunc (h *Handler) deleteNodeByID(id int64) error {\n\treturn h.repo.DeleteNodeCascade(id)\n}\n\nfunc (h *Handler) deleteTunnelByID(id int64) error {\n\tif h == nil || h.repo == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\n\ttunnelName, _ := h.repo.GetTunnelName(id)\n\tif err := h.repo.DeleteTunnelCascade(id); err != nil {\n\t\treturn err\n\t}\n\n\tshareID, port, ok := parsePeerShareInfoFromFederationTunnelName(tunnelName)\n\tif !ok {\n\t\treturn nil\n\t}\n\n\treturn h.repo.MarkPeerShareRuntimeReleasedByPort(shareID, port, time.Now().UnixMilli())\n}\n\nfunc (h *Handler) deleteForwardByID(id int64) error {\n\treturn h.repo.DeleteForwardCascade(id)\n}\n\nfunc (h *Handler) batchForwardDelete(ids []int64) (int, int) {\n\ts := 0\n\tf := 0\n\tfor _, id := range ids {\n\t\tif err := h.deleteForwardByID(id); err != nil {\n\t\t\tf++\n\t\t} else {\n\t\t\ts++\n\t\t}\n\t}\n\treturn s, f\n}\n\nfunc (h *Handler) batchForwardStatus(ids []int64, status int) (int, int) {\n\treturn h.repo.BatchUpdateForwardStatus(ids, status)\n}\n\nfunc (h *Handler) tunnelEntryNodeIDs(tunnelID int64) ([]int64, error) {\n\treturn h.repo.TunnelEntryNodeIDs(tunnelID)\n}\n\nfunc (h *Handler) pickTunnelPort(tunnelID int64) int {\n\tentryNodes, err := h.tunnelEntryNodeIDs(tunnelID)\n\tif err != nil || len(entryNodes) == 0 {\n\t\treturn 10000\n\t}\n\n\tvar commonAvailable []int\n\tfirstNode := true\n\n\tfor _, nodeID := range entryNodes {\n\t\tportRange, err := h.repo.GetNodePortRange(nodeID)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif portRange == \"\" {\n\t\t\tportRange = \"1000-65535\"\n\t\t}\n\n\t\tnodePorts, err := parsePorts(portRange)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tused, err := h.getUsedPorts(nodeID)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar available []int\n\t\tfor _, p := range nodePorts {\n\t\t\tif !used[p] {\n\t\t\t\tavailable = append(available, p)\n\t\t\t}\n\t\t}\n\n\t\tif firstNode {\n\t\t\tcommonAvailable = available\n\t\t\tfirstNode = false\n\t\t} else {\n\t\t\tset := make(map[int]bool)\n\t\t\tfor _, p := range available {\n\t\t\t\tset[p] = true\n\t\t\t}\n\t\t\tvar newCommon []int\n\t\t\tfor _, p := range commonAvailable {\n\t\t\t\tif set[p] {\n\t\t\t\t\tnewCommon = append(newCommon, p)\n\t\t\t\t}\n\t\t\t}\n\t\t\tcommonAvailable = newCommon\n\t\t}\n\n\t\tif len(commonAvailable) == 0 {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif len(commonAvailable) > 0 {\n\t\tidx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(commonAvailable))))\n\t\treturn commonAvailable[idx.Int64()]\n\t}\n\n\treturn 10000\n}\n\nfunc (h *Handler) getUsedPorts(nodeID int64) (map[int]bool, error) {\n\treturn h.repo.GetUsedPortsOnNodeAsMap(nodeID)\n}\n\nfunc parsePorts(portRange string) ([]int, error) {\n\tvar ports []int\n\tparts := strings.Split(portRange, \",\")\n\tfor _, part := range parts {\n\t\tpart = strings.TrimSpace(part)\n\t\tif part == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.Contains(part, \"-\") {\n\t\t\trangeParts := strings.Split(part, \"-\")\n\t\t\tif len(rangeParts) != 2 {\n\t\t\t\treturn nil, errors.New(\"invalid port range format\")\n\t\t\t}\n\t\t\tstart, err1 := strconv.Atoi(strings.TrimSpace(rangeParts[0]))\n\t\t\tend, err2 := strconv.Atoi(strings.TrimSpace(rangeParts[1]))\n\t\t\tif err1 != nil || err2 != nil || start > end {\n\t\t\t\treturn nil, errors.New(\"invalid port range values\")\n\t\t\t}\n\t\t\tfor i := start; i <= end; i++ {\n\t\t\t\tports = append(ports, i)\n\t\t\t}\n\t\t} else {\n\t\t\tp, err := strconv.Atoi(part)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, errors.New(\"invalid port value\")\n\t\t\t}\n\t\t\tports = append(ports, p)\n\t\t}\n\t}\n\treturn ports, nil\n}\n\nfunc (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\t}\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\tInIP string\n\t\t}{NodeID: nid, Port: port, InIP: inIp}\n\t}\n\treturn h.repo.ReplaceForwardPorts(forwardID, entries)\n}\n\nfunc (h *Handler) replaceForwardPortsWithRecords(forwardID int64, ports []forwardPortRecord) error {\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\tInIP string\n\t\t}{NodeID: fp.NodeID, Port: fp.Port, InIP: fp.InIP}\n\t}\n\treturn h.repo.ReplaceForwardPorts(forwardID, entries)\n}\n\nfunc (h *Handler) rollbackForwardMutation(oldForward *forwardRecord, oldPorts []forwardPortRecord) {\n\tif h == nil || oldForward == nil || h.repo == nil {\n\t\treturn\n\t}\n\n\th.repo.RollbackForwardFields(\n\t\toldForward.ID, oldForward.UserID, oldForward.UserName, oldForward.Name,\n\t\toldForward.TunnelID, oldForward.RemoteAddr, oldForward.Strategy, oldForward.Status,\n\t\toldForward.SpeedID,\n\t\ttime.Now().UnixMilli(),\n\t)\n\n\tif err := h.replaceForwardPortsWithRecords(oldForward.ID, oldPorts); err != nil {\n\t\treturn\n\t}\n\n\t_ = h.syncForwardServices(oldForward, \"UpdateService\", true)\n}\n\nfunc (h *Handler) upsertUserTunnel(req map[string]interface{}) error {\n\tuserID := asInt64(req[\"userId\"], 0)\n\ttunnelID := asInt64(req[\"tunnelId\"], 0)\n\tif userID <= 0 || tunnelID <= 0 {\n\t\treturn fmt.Errorf(\"userId or tunnelId missing\")\n\t}\n\n\texistingID, currentFlow, currentNum, currentExpTime, currentFlowReset, currentSpeedID, currentStatus, err :=\n\t\th.repo.GetExistingUserTunnel(userID, tunnelID)\n\n\tspeedID := asAnyToInt64Ptr(req[\"speedId\"])\n\tif err := h.validateSpeedLimitReference(speedID); err != nil {\n\t\treturn err\n\t}\n\n\treqFlow := asInt64(req[\"flow\"], -1)\n\treqNum := asInt(req[\"num\"], -1)\n\treqExpTime := asInt64(req[\"expTime\"], -1)\n\treqFlowReset := asInt64(req[\"flowResetTime\"], -1)\n\treqStatus := asInt(req[\"status\"], -1)\n\n\tif err == sql.ErrNoRows {\n\t\tif reqFlow < 0 || reqNum < 0 || reqExpTime < 0 || reqFlowReset < 0 {\n\t\t\tuFlow, uNum, uExp, uReset, uErr := h.repo.GetUserDefaultsForTunnel(userID)\n\t\t\tif uErr == nil {\n\t\t\t\tif reqFlow < 0 {\n\t\t\t\t\treqFlow = uFlow\n\t\t\t\t}\n\t\t\t\tif reqNum < 0 {\n\t\t\t\t\treqNum = uNum\n\t\t\t\t}\n\t\t\t\tif reqExpTime < 0 {\n\t\t\t\t\treqExpTime = uExp\n\t\t\t\t}\n\t\t\t\tif reqFlowReset < 0 {\n\t\t\t\t\treqFlowReset = uReset\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif reqFlow < 0 {\n\t\t\treqFlow = 0\n\t\t}\n\t\tif reqNum < 0 {\n\t\t\treqNum = 0\n\t\t}\n\t\tif reqExpTime < 0 {\n\t\t\treqExpTime = time.Now().Add(365 * 24 * time.Hour).UnixMilli()\n\t\t}\n\t\tif reqFlowReset < 0 {\n\t\t\treqFlowReset = 1\n\t\t}\n\t\tif reqStatus < 0 {\n\t\t\treqStatus = 1\n\t\t}\n\n\t\tif err := h.repo.InsertUserTunnel(userID, tunnelID, nullableInt(speedID), reqNum, reqFlow, reqFlowReset, reqExpTime, reqStatus); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {\n\t\t\tinsertedID, _, _, _, _, _, _, lookupErr := h.repo.GetExistingUserTunnel(userID, tunnelID)\n\t\t\tif lookupErr != nil {\n\t\t\t\treturn fmt.Errorf(\"下发失败且回滚失败: %v; 回滚查询错误: %w\", syncErr, lookupErr)\n\t\t\t}\n\n\t\t\tif rollbackErr := h.repo.DeleteUserTunnel(insertedID); rollbackErr != nil {\n\t\t\t\treturn fmt.Errorf(\"下发失败且回滚失败: %v; 回滚删除错误: %w\", syncErr, rollbackErr)\n\t\t\t}\n\n\t\t\treturn fmt.Errorf(\"下发失败,已回滚: %w\", syncErr)\n\t\t}\n\n\t\treturn nil\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tnewFlow := currentFlow\n\tif reqFlow >= 0 {\n\t\tnewFlow = reqFlow\n\t}\n\n\tnewNum := int(currentNum)\n\tif reqNum >= 0 {\n\t\tnewNum = reqNum\n\t}\n\n\tnewExpTime := currentExpTime\n\tif reqExpTime >= 0 {\n\t\tnewExpTime = reqExpTime\n\t}\n\n\tnewFlowReset := currentFlowReset\n\tif reqFlowReset >= 0 {\n\t\tnewFlowReset = reqFlowReset\n\t}\n\n\tnewStatus := currentStatus\n\tif reqStatus >= 0 {\n\t\tnewStatus = reqStatus\n\t}\n\n\tnewSpeedID := currentSpeedID\n\tif speedID != nil {\n\t\tnewSpeedID = sql.NullInt64{Int64: *speedID, Valid: true}\n\t} else if _, ok := req[\"speedId\"]; ok {\n\t\tnewSpeedID = sql.NullInt64{Valid: false}\n\t}\n\n\tif err := h.repo.UpdateUserTunnelFields(existingID, newSpeedID, newFlow, newNum, newExpTime, newFlowReset, newStatus); err != nil {\n\t\treturn err\n\t}\n\n\tif syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {\n\t\trollbackErr := h.repo.UpdateUserTunnelFields(\n\t\t\texistingID,\n\t\t\tcurrentSpeedID,\n\t\t\tcurrentFlow,\n\t\t\tint(currentNum),\n\t\t\tcurrentExpTime,\n\t\t\tcurrentFlowReset,\n\t\t\tcurrentStatus,\n\t\t)\n\t\tif rollbackErr != nil {\n\t\t\treturn fmt.Errorf(\"下发失败且回滚失败: %v; 回滚错误: %w\", syncErr, rollbackErr)\n\t\t}\n\n\t\treturn fmt.Errorf(\"下发失败,已回滚: %w\", syncErr)\n\t}\n\n\treturn nil\n}\n\nfunc (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) error {\n\tforwards, err := h.listForwardsByTunnel(tunnelID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor i := range forwards {\n\t\tf := &forwards[i]\n\t\tif f.UserID == userID {\n\t\t\tif err := h.syncForwardServices(f, \"UpdateService\", true); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (h *Handler) validateSpeedLimitReference(speedID *int64) error {\n\tif speedID == nil {\n\t\treturn nil\n\t}\n\n\texists, err := h.repo.SpeedLimitExists(*speedID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !exists {\n\t\treturn errors.New(\"限速规则不存在\")\n\t}\n\n\treturn nil\n}\n\nfunc asAnySlice(v interface{}) []interface{} {\n\tif v == nil {\n\t\treturn nil\n\t}\n\tif arr, ok := v.([]interface{}); ok {\n\t\treturn arr\n\t}\n\treturn nil\n}\n\nfunc asMapSlice(v interface{}) []map[string]interface{} {\n\tarr := asAnySlice(v)\n\tif arr == nil {\n\t\treturn nil\n\t}\n\tout := make([]map[string]interface{}, 0, len(arr))\n\tfor _, it := range arr {\n\t\tif m, ok := it.(map[string]interface{}); ok {\n\t\t\tout = append(out, m)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc asString(v interface{}) string {\n\tswitch t := v.(type) {\n\tcase nil:\n\t\treturn \"\"\n\tcase string:\n\t\treturn strings.TrimSpace(t)\n\tcase float64:\n\t\tif t == float64(int64(t)) {\n\t\t\treturn strconv.FormatInt(int64(t), 10)\n\t\t}\n\t\treturn strconv.FormatFloat(t, 'f', -1, 64)\n\tcase int, int32, int64:\n\t\treturn fmt.Sprintf(\"%v\", t)\n\tdefault:\n\t\tb, _ := json.Marshal(t)\n\t\treturn strings.Trim(string(b), \"\\\"\")\n\t}\n}\n\nfunc asInt(v interface{}, def int) int {\n\ts := asString(v)\n\tif s == \"\" {\n\t\treturn def\n\t}\n\ti, err := strconv.Atoi(s)\n\tif err != nil {\n\t\treturn def\n\t}\n\treturn i\n}\n\nfunc asInt64(v interface{}, def int64) int64 {\n\ts := asString(v)\n\tif s == \"\" {\n\t\treturn def\n\t}\n\ti, err := strconv.ParseInt(s, 10, 64)\n\tif err != nil {\n\t\treturn def\n\t}\n\treturn i\n}\n\nfunc asFloat(v interface{}, def float64) float64 {\n\ts := asString(v)\n\tif s == \"\" {\n\t\treturn def\n\t}\n\tf, err := strconv.ParseFloat(s, 64)\n\tif err != nil {\n\t\treturn def\n\t}\n\treturn f\n}\n\nfunc asAnyToInt64Ptr(v interface{}) *int64 {\n\ts := asString(v)\n\tif s == \"\" || strings.EqualFold(s, \"null\") {\n\t\treturn nil\n\t}\n\ti, err := strconv.ParseInt(s, 10, 64)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &i\n}\n\nfunc idFromBody(r *http.Request, w http.ResponseWriter) int64 {\n\treturn asInt64FromBodyKey(r, w, \"id\")\n}\n\nfunc asInt64FromBodyKey(r *http.Request, w http.ResponseWriter, key string) int64 {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn 0\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn 0\n\t}\n\tid := asInt64(req[key], 0)\n\tif id <= 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"参数错误\"))\n\t\treturn 0\n\t}\n\treturn id\n}\n\nfunc idsFromBody(r *http.Request, w http.ResponseWriter) []int64 {\n\tif r.Method != http.MethodPost {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n\t\treturn nil\n\t}\n\tvar req map[string]interface{}\n\tif err := decodeJSON(r.Body, &req); err != nil {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n\t\treturn nil\n\t}\n\tarr := asAnySlice(req[\"ids\"])\n\tif len(arr) == 0 {\n\t\tresponse.WriteJSON(w, response.ErrDefault(\"ids不能为空\"))\n\t\treturn nil\n\t}\n\tids := make([]int64, 0, len(arr))\n\tfor _, x := range arr {\n\t\tid := asInt64(x, 0)\n\t\tif id > 0 {\n\t\t\tids = append(ids, id)\n\t\t}\n\t}\n\tsort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })\n\treturn ids\n}\n\nfunc nullableText(s string) interface{} {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn nil\n\t}\n\treturn s\n}\n\nfunc nullableInt(v *int64) interface{} {\n\tif v == nil {\n\t\treturn nil\n\t}\n\treturn *v\n}\n\nfunc defaultString(v, def string) string {\n\tif strings.TrimSpace(v) == \"\" {\n\t\treturn def\n\t}\n\treturn v\n}\n\nfunc randomToken(n int) string {\n\tbuf := make([]byte, n)\n\tif _, err := rand.Read(buf); err != nil {\n\t\treturn strconv.FormatInt(time.Now().UnixNano(), 16)\n\t}\n\treturn hex.EncodeToString(buf)\n}\n\nfunc asInt64Slice(v interface{}) []int64 {\n\tarr := asAnySlice(v)\n\tif len(arr) == 0 {\n\t\treturn nil\n\t}\n\tids := make([]int64, 0, len(arr))\n\tfor _, x := range arr {\n\t\tif id := asInt64(x, 0); id > 0 {\n\t\t\tids = append(ids, id)\n\t\t}\n\t}\n\treturn ids\n}\n",
|
||
"additions": 1,
|
||
"deletions": 1
|
||
}
|
||
],
|
||
"diagnostics": {
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 161,
|
||
"character": 13
|
||
},
|
||
"end": {
|
||
"line": 161,
|
||
"character": 24
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 356,
|
||
"character": 77
|
||
},
|
||
"end": {
|
||
"line": 356,
|
||
"character": 88
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 436,
|
||
"character": 74
|
||
},
|
||
"end": {
|
||
"line": 436,
|
||
"character": 85
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 573,
|
||
"character": 77
|
||
},
|
||
"end": {
|
||
"line": 573,
|
||
"character": 88
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 583,
|
||
"character": 77
|
||
},
|
||
"end": {
|
||
"line": 583,
|
||
"character": 88
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 596,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 596,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 603,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 603,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 611,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 611,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 624,
|
||
"character": 47
|
||
},
|
||
"end": {
|
||
"line": 624,
|
||
"character": 58
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 632,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 632,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 634,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 634,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 654,
|
||
"character": 47
|
||
},
|
||
"end": {
|
||
"line": 654,
|
||
"character": 58
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 662,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 662,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 664,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 664,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 676,
|
||
"character": 53
|
||
},
|
||
"end": {
|
||
"line": 676,
|
||
"character": 64
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 684,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 684,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 686,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 686,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 696,
|
||
"character": 50
|
||
},
|
||
"end": {
|
||
"line": 696,
|
||
"character": 61
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 728,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 728,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 734,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 734,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 750,
|
||
"character": 75
|
||
},
|
||
"end": {
|
||
"line": 750,
|
||
"character": 86
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 769,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 769,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 771,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 771,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 776,
|
||
"character": 64
|
||
},
|
||
"end": {
|
||
"line": 776,
|
||
"character": 75
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 790,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 790,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 792,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 792,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 797,
|
||
"character": 49
|
||
},
|
||
"end": {
|
||
"line": 797,
|
||
"character": 60
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 807,
|
||
"character": 40
|
||
},
|
||
"end": {
|
||
"line": 807,
|
||
"character": 51
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 811,
|
||
"character": 31
|
||
},
|
||
"end": {
|
||
"line": 811,
|
||
"character": 42
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 817,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 817,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 818,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 818,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 819,
|
||
"character": 39
|
||
},
|
||
"end": {
|
||
"line": 819,
|
||
"character": 50
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 839,
|
||
"character": 47
|
||
},
|
||
"end": {
|
||
"line": 839,
|
||
"character": 58
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 856,
|
||
"character": 24
|
||
},
|
||
"end": {
|
||
"line": 856,
|
||
"character": 35
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 873,
|
||
"character": 54
|
||
},
|
||
"end": {
|
||
"line": 873,
|
||
"character": 65
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 879,
|
||
"character": 51
|
||
},
|
||
"end": {
|
||
"line": 879,
|
||
"character": 62
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 883,
|
||
"character": 56
|
||
},
|
||
"end": {
|
||
"line": 883,
|
||
"character": 67
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 897,
|
||
"character": 33
|
||
},
|
||
"end": {
|
||
"line": 897,
|
||
"character": 44
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 910,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 910,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 921,
|
||
"character": 54
|
||
},
|
||
"end": {
|
||
"line": 921,
|
||
"character": 65
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 929,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 929,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 935,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 935,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 944,
|
||
"character": 52
|
||
},
|
||
"end": {
|
||
"line": 944,
|
||
"character": 63
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 952,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 952,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 958,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 958,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 967,
|
||
"character": 58
|
||
},
|
||
"end": {
|
||
"line": 967,
|
||
"character": 69
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 990,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 990,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 992,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 992,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1064,
|
||
"character": 84
|
||
},
|
||
"end": {
|
||
"line": 1064,
|
||
"character": 95
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1130,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 1130,
|
||
"character": 38
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1146,
|
||
"character": 83
|
||
},
|
||
"end": {
|
||
"line": 1146,
|
||
"character": 94
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1170,
|
||
"character": 90
|
||
},
|
||
"end": {
|
||
"line": 1170,
|
||
"character": 101
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1184,
|
||
"character": 85
|
||
},
|
||
"end": {
|
||
"line": 1184,
|
||
"character": 96
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1404,
|
||
"character": 85
|
||
},
|
||
"end": {
|
||
"line": 1404,
|
||
"character": 96
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1421,
|
||
"character": 130
|
||
},
|
||
"end": {
|
||
"line": 1421,
|
||
"character": 141
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1442,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 1442,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2382,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 2382,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2397,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 2397,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2503,
|
||
"character": 36
|
||
},
|
||
"end": {
|
||
"line": 2503,
|
||
"character": 47
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2591,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 2591,
|
||
"character": 38
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2713,
|
||
"character": 16
|
||
},
|
||
"end": {
|
||
"line": 2713,
|
||
"character": 29
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "stringscut",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/gopls/internal/analysis/modernize#stringscut"
|
||
},
|
||
"source": "stringscut",
|
||
"message": "strings.Index can be simplified using strings.Cut"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2757,
|
||
"character": 15
|
||
},
|
||
"end": {
|
||
"line": 2757,
|
||
"character": 28
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringsseq"
|
||
},
|
||
"source": "stringsseq",
|
||
"message": "Ranging over SplitSeq is more efficient"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2803,
|
||
"character": 38
|
||
},
|
||
"end": {
|
||
"line": 2803,
|
||
"character": 49
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2810,
|
||
"character": 38
|
||
},
|
||
"end": {
|
||
"line": 2810,
|
||
"character": 49
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2818,
|
||
"character": 36
|
||
},
|
||
"end": {
|
||
"line": 2818,
|
||
"character": 47
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_migrate_test.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 230,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 230,
|
||
"character": 85
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#omitzero"
|
||
},
|
||
"source": "omitzero",
|
||
"message": "Omitempty has no effect on nested struct fields"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 383,
|
||
"character": 25
|
||
},
|
||
"end": {
|
||
"line": 383,
|
||
"character": 36
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 392,
|
||
"character": 44
|
||
},
|
||
"end": {
|
||
"line": 392,
|
||
"character": 55
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 400,
|
||
"character": 38
|
||
},
|
||
"end": {
|
||
"line": 400,
|
||
"character": 49
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 410,
|
||
"character": 49
|
||
},
|
||
"end": {
|
||
"line": 410,
|
||
"character": 60
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 415,
|
||
"character": 33
|
||
},
|
||
"end": {
|
||
"line": 415,
|
||
"character": 44
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_mutations.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 78,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 78,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 96,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 96,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 113,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 113,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 156,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 156,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 163,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 163,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 172,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 172,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 198,
|
||
"character": 116
|
||
},
|
||
"end": {
|
||
"line": 198,
|
||
"character": 127
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 198,
|
||
"character": 277
|
||
},
|
||
"end": {
|
||
"line": 198,
|
||
"character": 288
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 241,
|
||
"character": 119
|
||
},
|
||
"end": {
|
||
"line": 241,
|
||
"character": 130
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 247,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 247,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 294,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 294,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 371,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 371,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 380,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 380,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 559,
|
||
"character": 107
|
||
},
|
||
"end": {
|
||
"line": 559,
|
||
"character": 118
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 565,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 565,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 601,
|
||
"character": 70
|
||
},
|
||
"end": {
|
||
"line": 601,
|
||
"character": 81
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 620,
|
||
"character": 62
|
||
},
|
||
"end": {
|
||
"line": 620,
|
||
"character": 73
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 626,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 626,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 648,
|
||
"character": 122
|
||
},
|
||
"end": {
|
||
"line": 648,
|
||
"character": 133
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 654,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 654,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 670,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 670,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 679,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 679,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 722,
|
||
"character": 149
|
||
},
|
||
"end": {
|
||
"line": 722,
|
||
"character": 160
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 728,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 728,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 786,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 786,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 837,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 837,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1179,
|
||
"character": 192
|
||
},
|
||
"end": {
|
||
"line": 1179,
|
||
"character": 203
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1228,
|
||
"character": 80
|
||
},
|
||
"end": {
|
||
"line": 1228,
|
||
"character": 91
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1237,
|
||
"character": 137
|
||
},
|
||
"end": {
|
||
"line": 1237,
|
||
"character": 148
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1265,
|
||
"character": 31
|
||
},
|
||
"end": {
|
||
"line": 1265,
|
||
"character": 42
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1291,
|
||
"character": 30
|
||
},
|
||
"end": {
|
||
"line": 1291,
|
||
"character": 41
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1334,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 1334,
|
||
"character": 38
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1363,
|
||
"character": 10
|
||
},
|
||
"end": {
|
||
"line": 1363,
|
||
"character": 23
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringsseq"
|
||
},
|
||
"source": "stringsseq",
|
||
"message": "Ranging over SplitSeq is more efficient"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_control.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane_test.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go": []
|
||
},
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772506325518,
|
||
"end": 1772506325744
|
||
}
|
||
},
|
||
"id": "prt_cb19c180d001ixLnBfshhwMvLk",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c06bc002UREkIC6iFkpWuo"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "d236541a96e6040855f0a7821c573d4497d00de3",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 61729,
|
||
"input": 61491,
|
||
"output": 238,
|
||
"reasoning": 20,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb19c22f8001U7LNQM5oIOa039",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c06bc002UREkIC6iFkpWuo"
|
||
},
|
||
{
|
||
"type": "patch",
|
||
"hash": "8cc406b7f6401d9dd934f782ff5c852a6118988e",
|
||
"files": [
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go"
|
||
],
|
||
"id": "prt_cb19c2349001j6VFp3pscc90Ut",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c06bc002UREkIC6iFkpWuo"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506325838,
|
||
"completed": 1772506333131
|
||
},
|
||
"parentID": "msg_cb19b9ae6001Tg0NlwB47EgTPl",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"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": 61905,
|
||
"input": 61736,
|
||
"output": 169,
|
||
"reasoning": 91,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb19c234e002MNiEnJ73NYDNkl",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "d236541a96e6040855f0a7821c573d4497d00de3",
|
||
"id": "prt_cb19c3dc2001TKEFYXKyjtqRPz",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c234e002MNiEnJ73NYDNkl"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Preparing tests for updated signature**",
|
||
"time": {
|
||
"start": 1772506332610,
|
||
"end": 1772506333037
|
||
},
|
||
"id": "prt_cb19c3dc20021eUuurN67yMVZU",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c234e002MNiEnJ73NYDNkl"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_InWsvWnoRPtYEC7QMj2w3xsO",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "buildForwardServiceConfigs\\(",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler",
|
||
"include": "*.go"
|
||
},
|
||
"output": "Found 2 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go:\n Line 284: \t\tservices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol)\n Line 1306: func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {",
|
||
"title": "buildForwardServiceConfigs\\(",
|
||
"metadata": {
|
||
"matches": 2,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772506332916,
|
||
"end": 1772506332934
|
||
}
|
||
},
|
||
"id": "prt_cb19c3e400013N7rYSJbs1Tywx",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c234e002MNiEnJ73NYDNkl"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "d236541a96e6040855f0a7821c573d4497d00de3",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 61905,
|
||
"input": 61736,
|
||
"output": 169,
|
||
"reasoning": 91,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb19c3f6f0017wc071SBP0EswX",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c234e002MNiEnJ73NYDNkl"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506333133,
|
||
"completed": 1772506349217
|
||
},
|
||
"parentID": "msg_cb19b9ae6001Tg0NlwB47EgTPl",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"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": 62835,
|
||
"input": 61972,
|
||
"output": 863,
|
||
"reasoning": 111,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb19c3fcd002t6Xmt4e0RkiObE",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "d236541a96e6040855f0a7821c573d4497d00de3",
|
||
"id": "prt_cb19c52d6001wujRX7adYhhPki",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c3fcd002t6Xmt4e0RkiObE"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Planning tests for service config**",
|
||
"time": {
|
||
"start": 1772506338007,
|
||
"end": 1772506349039
|
||
},
|
||
"id": "prt_cb19c52d7001vi7Wq51XfF5l51",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c3fcd002t6Xmt4e0RkiObE"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_QkEnUdxMZuDbhlByI8rg32aq",
|
||
"tool": "apply_patch",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"patchText": "*** Begin Patch\n*** Update File: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane_test.go\n@@\n import (\n \t\"reflect\"\n \t\"testing\"\n )\n@@\n func TestIsAlreadyExistsMessage(t *testing.T) {\n \tif !isAlreadyExistsMessage(\"service demo already exists\") {\n \t\tt.Fatalf(\"expected already exists message to be tolerated\")\n \t}\n@@\n \tif isAlreadyExistsMessage(\"listen tcp [::]:10001: bind: address already in use\") {\n \t\tt.Fatalf(\"address already in use must not be treated as already exists\")\n \t}\n }\n+\n+func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {\n+\tforward := &forwardRecord{RemoteAddr: \"1.2.3.4:80\", Strategy: \"fifo\", TunnelID: 7}\n+\tnode := &nodeRecord{TCPListenAddr: \"[::]\", UDPListenAddr: \"[::]\"}\n+\tservices := buildForwardServiceConfigs(\"1_2_0\", forward, nil, node, 22000, \"10.9.8.7\", nil, false)\n+\tif len(services) != 2 {\n+\t\tt.Fatalf(\"expected 2 services, got %d\", len(services))\n+\t}\n+\tfor _, svc := range services {\n+\t\taddr, _ := svc[\"addr\"].(string)\n+\t\tif addr != \"10.9.8.7:22000\" {\n+\t\t\tt.Fatalf(\"expected bind IP address 10.9.8.7:22000, got %q\", addr)\n+\t\t}\n+\t}\n+}\n+\n+func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.T) {\n+\tforward := &forwardRecord{RemoteAddr: \"1.2.3.4:80\", Strategy: \"fifo\", TunnelID: 7}\n+\tnode := &nodeRecord{TCPListenAddr: \"0.0.0.0\", UDPListenAddr: \"[::]\"}\n+\tservices := buildForwardServiceConfigs(\"1_2_0\", forward, nil, node, 22001, \"\", nil, false)\n+\tif len(services) != 2 {\n+\t\tt.Fatalf(\"expected 2 services, got %d\", len(services))\n+\t}\n+\ttcpAddr, _ := services[0][\"addr\"].(string)\n+\tudpAddr, _ := services[1][\"addr\"].(string)\n+\tif tcpAddr != \"0.0.0.0:22001\" {\n+\t\tt.Fatalf(\"expected tcp addr 0.0.0.0:22001, got %q\", tcpAddr)\n+\t}\n+\tif udpAddr != \"[::]:22001\" {\n+\t\tt.Fatalf(\"expected udp addr [::]:22001, got %q\", udpAddr)\n+\t}\n+}\n*** End Patch"
|
||
},
|
||
"output": "Success. Updated the following files:\nM go-backend/internal/http/handler/control_plane_test.go",
|
||
"title": "Success. Updated the following files:\nM go-backend/internal/http/handler/control_plane_test.go",
|
||
"metadata": {
|
||
"diff": "Index: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane_test.go\n===================================================================\n--- /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane_test.go\n+++ /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane_test.go\n@@ -64,4 +64,36 @@\n \tif isAlreadyExistsMessage(\"listen tcp [::]:10001: bind: address already in use\") {\n \t\tt.Fatalf(\"address already in use must not be treated as already exists\")\n \t}\n }\n+\n+func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {\n+\tforward := &forwardRecord{RemoteAddr: \"1.2.3.4:80\", Strategy: \"fifo\", TunnelID: 7}\n+\tnode := &nodeRecord{TCPListenAddr: \"[::]\", UDPListenAddr: \"[::]\"}\n+\tservices := buildForwardServiceConfigs(\"1_2_0\", forward, nil, node, 22000, \"10.9.8.7\", nil, false)\n+\tif len(services) != 2 {\n+\t\tt.Fatalf(\"expected 2 services, got %d\", len(services))\n+\t}\n+\tfor _, svc := range services {\n+\t\taddr, _ := svc[\"addr\"].(string)\n+\t\tif addr != \"10.9.8.7:22000\" {\n+\t\t\tt.Fatalf(\"expected bind IP address 10.9.8.7:22000, got %q\", addr)\n+\t\t}\n+\t}\n+}\n+\n+func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.T) {\n+\tforward := &forwardRecord{RemoteAddr: \"1.2.3.4:80\", Strategy: \"fifo\", TunnelID: 7}\n+\tnode := &nodeRecord{TCPListenAddr: \"0.0.0.0\", UDPListenAddr: \"[::]\"}\n+\tservices := buildForwardServiceConfigs(\"1_2_0\", forward, nil, node, 22001, \"\", nil, false)\n+\tif len(services) != 2 {\n+\t\tt.Fatalf(\"expected 2 services, got %d\", len(services))\n+\t}\n+\ttcpAddr, _ := services[0][\"addr\"].(string)\n+\tudpAddr, _ := services[1][\"addr\"].(string)\n+\tif tcpAddr != \"0.0.0.0:22001\" {\n+\t\tt.Fatalf(\"expected tcp addr 0.0.0.0:22001, got %q\", tcpAddr)\n+\t}\n+\tif udpAddr != \"[::]:22001\" {\n+\t\tt.Fatalf(\"expected udp addr [::]:22001, got %q\", udpAddr)\n+\t}\n+}\n\n",
|
||
"files": [
|
||
{
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane_test.go",
|
||
"relativePath": "go-backend/internal/http/handler/control_plane_test.go",
|
||
"type": "update",
|
||
"diff": "Index: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane_test.go\n===================================================================\n--- /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane_test.go\n+++ /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane_test.go\n@@ -64,4 +64,36 @@\n \tif isAlreadyExistsMessage(\"listen tcp [::]:10001: bind: address already in use\") {\n \t\tt.Fatalf(\"address already in use must not be treated as already exists\")\n \t}\n }\n+\n+func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {\n+\tforward := &forwardRecord{RemoteAddr: \"1.2.3.4:80\", Strategy: \"fifo\", TunnelID: 7}\n+\tnode := &nodeRecord{TCPListenAddr: \"[::]\", UDPListenAddr: \"[::]\"}\n+\tservices := buildForwardServiceConfigs(\"1_2_0\", forward, nil, node, 22000, \"10.9.8.7\", nil, false)\n+\tif len(services) != 2 {\n+\t\tt.Fatalf(\"expected 2 services, got %d\", len(services))\n+\t}\n+\tfor _, svc := range services {\n+\t\taddr, _ := svc[\"addr\"].(string)\n+\t\tif addr != \"10.9.8.7:22000\" {\n+\t\t\tt.Fatalf(\"expected bind IP address 10.9.8.7:22000, got %q\", addr)\n+\t\t}\n+\t}\n+}\n+\n+func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.T) {\n+\tforward := &forwardRecord{RemoteAddr: \"1.2.3.4:80\", Strategy: \"fifo\", TunnelID: 7}\n+\tnode := &nodeRecord{TCPListenAddr: \"0.0.0.0\", UDPListenAddr: \"[::]\"}\n+\tservices := buildForwardServiceConfigs(\"1_2_0\", forward, nil, node, 22001, \"\", nil, false)\n+\tif len(services) != 2 {\n+\t\tt.Fatalf(\"expected 2 services, got %d\", len(services))\n+\t}\n+\ttcpAddr, _ := services[0][\"addr\"].(string)\n+\tudpAddr, _ := services[1][\"addr\"].(string)\n+\tif tcpAddr != \"0.0.0.0:22001\" {\n+\t\tt.Fatalf(\"expected tcp addr 0.0.0.0:22001, got %q\", tcpAddr)\n+\t}\n+\tif udpAddr != \"[::]:22001\" {\n+\t\tt.Fatalf(\"expected udp addr [::]:22001, got %q\", udpAddr)\n+\t}\n+}\n",
|
||
"before": "package handler\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestBuildForwardControlServiceNamesPauseResume(t *testing.T) {\n\tbase := \"12_34_56\"\n\twant := []string{base + \"_tcp\", base + \"_udp\"}\n\n\tfor _, command := range []string{\"PauseService\", \"ResumeService\"} {\n\t\tgot := buildForwardControlServiceNames(base, command)\n\t\tif !reflect.DeepEqual(got, want) {\n\t\t\tt.Fatalf(\"command %s expected %v, got %v\", command, want, got)\n\t\t}\n\t}\n}\n\nfunc TestBuildForwardControlServiceNamesDelete(t *testing.T) {\n\tbase := \"12_34_56\"\n\twant := []string{base, base + \"_tcp\", base + \"_udp\"}\n\tgot := buildForwardControlServiceNames(base, \" DeleteService \")\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"expected %v, got %v\", want, got)\n\t}\n}\n\nfunc TestBuildForwardServiceBaseCandidates(t *testing.T) {\n\tgot := buildForwardServiceBaseCandidates(12, 34, 56, []int64{56, 78, 90})\n\twant := []string{\"12_34_56\", \"12_34_78\", \"12_34_90\", \"12_34_0\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"expected %v, got %v\", want, got)\n\t}\n}\n\nfunc TestBuildForwardServiceBaseCandidatesWithZeroPreferred(t *testing.T) {\n\tgot := buildForwardServiceBaseCandidates(12, 34, 0, []int64{78, 0, 90})\n\twant := []string{\"12_34_0\", \"12_34_78\", \"12_34_90\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"expected %v, got %v\", want, got)\n\t}\n}\n\nfunc TestShouldTryLegacySingleService(t *testing.T) {\n\tif !shouldTryLegacySingleService(\"PauseService\") {\n\t\tt.Fatalf(\"PauseService should require legacy fallback\")\n\t}\n\tif !shouldTryLegacySingleService(\"resumeService\") {\n\t\tt.Fatalf(\"ResumeService should require legacy fallback\")\n\t}\n\tif shouldTryLegacySingleService(\"DeleteService\") {\n\t\tt.Fatalf(\"DeleteService should not require legacy fallback\")\n\t}\n}\n\nfunc TestIsAlreadyExistsMessage(t *testing.T) {\n\tif !isAlreadyExistsMessage(\"service demo already exists\") {\n\t\tt.Fatalf(\"expected already exists message to be tolerated\")\n\t}\n\tif !isAlreadyExistsMessage(\"服务已存在\") {\n\t\tt.Fatalf(\"expected Chinese already exists message to be tolerated\")\n\t}\n\tif isAlreadyExistsMessage(\"listen tcp [::]:10001: bind: address already in use\") {\n\t\tt.Fatalf(\"address already in use must not be treated as already exists\")\n\t}\n}\n",
|
||
"after": "package handler\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestBuildForwardControlServiceNamesPauseResume(t *testing.T) {\n\tbase := \"12_34_56\"\n\twant := []string{base + \"_tcp\", base + \"_udp\"}\n\n\tfor _, command := range []string{\"PauseService\", \"ResumeService\"} {\n\t\tgot := buildForwardControlServiceNames(base, command)\n\t\tif !reflect.DeepEqual(got, want) {\n\t\t\tt.Fatalf(\"command %s expected %v, got %v\", command, want, got)\n\t\t}\n\t}\n}\n\nfunc TestBuildForwardControlServiceNamesDelete(t *testing.T) {\n\tbase := \"12_34_56\"\n\twant := []string{base, base + \"_tcp\", base + \"_udp\"}\n\tgot := buildForwardControlServiceNames(base, \" DeleteService \")\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"expected %v, got %v\", want, got)\n\t}\n}\n\nfunc TestBuildForwardServiceBaseCandidates(t *testing.T) {\n\tgot := buildForwardServiceBaseCandidates(12, 34, 56, []int64{56, 78, 90})\n\twant := []string{\"12_34_56\", \"12_34_78\", \"12_34_90\", \"12_34_0\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"expected %v, got %v\", want, got)\n\t}\n}\n\nfunc TestBuildForwardServiceBaseCandidatesWithZeroPreferred(t *testing.T) {\n\tgot := buildForwardServiceBaseCandidates(12, 34, 0, []int64{78, 0, 90})\n\twant := []string{\"12_34_0\", \"12_34_78\", \"12_34_90\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"expected %v, got %v\", want, got)\n\t}\n}\n\nfunc TestShouldTryLegacySingleService(t *testing.T) {\n\tif !shouldTryLegacySingleService(\"PauseService\") {\n\t\tt.Fatalf(\"PauseService should require legacy fallback\")\n\t}\n\tif !shouldTryLegacySingleService(\"resumeService\") {\n\t\tt.Fatalf(\"ResumeService should require legacy fallback\")\n\t}\n\tif shouldTryLegacySingleService(\"DeleteService\") {\n\t\tt.Fatalf(\"DeleteService should not require legacy fallback\")\n\t}\n}\n\nfunc TestIsAlreadyExistsMessage(t *testing.T) {\n\tif !isAlreadyExistsMessage(\"service demo already exists\") {\n\t\tt.Fatalf(\"expected already exists message to be tolerated\")\n\t}\n\tif !isAlreadyExistsMessage(\"服务已存在\") {\n\t\tt.Fatalf(\"expected Chinese already exists message to be tolerated\")\n\t}\n\tif isAlreadyExistsMessage(\"listen tcp [::]:10001: bind: address already in use\") {\n\t\tt.Fatalf(\"address already in use must not be treated as already exists\")\n\t}\n}\n\nfunc TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {\n\tforward := &forwardRecord{RemoteAddr: \"1.2.3.4:80\", Strategy: \"fifo\", TunnelID: 7}\n\tnode := &nodeRecord{TCPListenAddr: \"[::]\", UDPListenAddr: \"[::]\"}\n\tservices := buildForwardServiceConfigs(\"1_2_0\", forward, nil, node, 22000, \"10.9.8.7\", nil, false)\n\tif len(services) != 2 {\n\t\tt.Fatalf(\"expected 2 services, got %d\", len(services))\n\t}\n\tfor _, svc := range services {\n\t\taddr, _ := svc[\"addr\"].(string)\n\t\tif addr != \"10.9.8.7:22000\" {\n\t\t\tt.Fatalf(\"expected bind IP address 10.9.8.7:22000, got %q\", addr)\n\t\t}\n\t}\n}\n\nfunc TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.T) {\n\tforward := &forwardRecord{RemoteAddr: \"1.2.3.4:80\", Strategy: \"fifo\", TunnelID: 7}\n\tnode := &nodeRecord{TCPListenAddr: \"0.0.0.0\", UDPListenAddr: \"[::]\"}\n\tservices := buildForwardServiceConfigs(\"1_2_0\", forward, nil, node, 22001, \"\", nil, false)\n\tif len(services) != 2 {\n\t\tt.Fatalf(\"expected 2 services, got %d\", len(services))\n\t}\n\ttcpAddr, _ := services[0][\"addr\"].(string)\n\tudpAddr, _ := services[1][\"addr\"].(string)\n\tif tcpAddr != \"0.0.0.0:22001\" {\n\t\tt.Fatalf(\"expected tcp addr 0.0.0.0:22001, got %q\", tcpAddr)\n\t}\n\tif udpAddr != \"[::]:22001\" {\n\t\tt.Fatalf(\"expected udp addr [::]:22001, got %q\", udpAddr)\n\t}\n}\n",
|
||
"additions": 32,
|
||
"deletions": 0
|
||
}
|
||
],
|
||
"diagnostics": {
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 161,
|
||
"character": 13
|
||
},
|
||
"end": {
|
||
"line": 161,
|
||
"character": 24
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 356,
|
||
"character": 77
|
||
},
|
||
"end": {
|
||
"line": 356,
|
||
"character": 88
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 436,
|
||
"character": 74
|
||
},
|
||
"end": {
|
||
"line": 436,
|
||
"character": 85
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 573,
|
||
"character": 77
|
||
},
|
||
"end": {
|
||
"line": 573,
|
||
"character": 88
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 583,
|
||
"character": 77
|
||
},
|
||
"end": {
|
||
"line": 583,
|
||
"character": 88
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 596,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 596,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 603,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 603,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 611,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 611,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 624,
|
||
"character": 47
|
||
},
|
||
"end": {
|
||
"line": 624,
|
||
"character": 58
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 632,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 632,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 634,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 634,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 654,
|
||
"character": 47
|
||
},
|
||
"end": {
|
||
"line": 654,
|
||
"character": 58
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 662,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 662,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 664,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 664,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 676,
|
||
"character": 53
|
||
},
|
||
"end": {
|
||
"line": 676,
|
||
"character": 64
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 684,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 684,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 686,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 686,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 696,
|
||
"character": 50
|
||
},
|
||
"end": {
|
||
"line": 696,
|
||
"character": 61
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 728,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 728,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 734,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 734,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 750,
|
||
"character": 75
|
||
},
|
||
"end": {
|
||
"line": 750,
|
||
"character": 86
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 769,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 769,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 771,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 771,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 776,
|
||
"character": 64
|
||
},
|
||
"end": {
|
||
"line": 776,
|
||
"character": 75
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 790,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 790,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 792,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 792,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 797,
|
||
"character": 49
|
||
},
|
||
"end": {
|
||
"line": 797,
|
||
"character": 60
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 807,
|
||
"character": 40
|
||
},
|
||
"end": {
|
||
"line": 807,
|
||
"character": 51
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 811,
|
||
"character": 31
|
||
},
|
||
"end": {
|
||
"line": 811,
|
||
"character": 42
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 817,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 817,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 818,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 818,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 819,
|
||
"character": 39
|
||
},
|
||
"end": {
|
||
"line": 819,
|
||
"character": 50
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 839,
|
||
"character": 47
|
||
},
|
||
"end": {
|
||
"line": 839,
|
||
"character": 58
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 856,
|
||
"character": 24
|
||
},
|
||
"end": {
|
||
"line": 856,
|
||
"character": 35
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 873,
|
||
"character": 54
|
||
},
|
||
"end": {
|
||
"line": 873,
|
||
"character": 65
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 879,
|
||
"character": 51
|
||
},
|
||
"end": {
|
||
"line": 879,
|
||
"character": 62
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 883,
|
||
"character": 56
|
||
},
|
||
"end": {
|
||
"line": 883,
|
||
"character": 67
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 897,
|
||
"character": 33
|
||
},
|
||
"end": {
|
||
"line": 897,
|
||
"character": 44
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 910,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 910,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 921,
|
||
"character": 54
|
||
},
|
||
"end": {
|
||
"line": 921,
|
||
"character": 65
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 929,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 929,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 935,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 935,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 944,
|
||
"character": 52
|
||
},
|
||
"end": {
|
||
"line": 944,
|
||
"character": 63
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 952,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 952,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 958,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 958,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 967,
|
||
"character": 58
|
||
},
|
||
"end": {
|
||
"line": 967,
|
||
"character": 69
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 990,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 990,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 992,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 992,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1064,
|
||
"character": 84
|
||
},
|
||
"end": {
|
||
"line": 1064,
|
||
"character": 95
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1130,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 1130,
|
||
"character": 38
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1146,
|
||
"character": 83
|
||
},
|
||
"end": {
|
||
"line": 1146,
|
||
"character": 94
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1170,
|
||
"character": 90
|
||
},
|
||
"end": {
|
||
"line": 1170,
|
||
"character": 101
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1184,
|
||
"character": 85
|
||
},
|
||
"end": {
|
||
"line": 1184,
|
||
"character": 96
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1404,
|
||
"character": 85
|
||
},
|
||
"end": {
|
||
"line": 1404,
|
||
"character": 96
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1421,
|
||
"character": 130
|
||
},
|
||
"end": {
|
||
"line": 1421,
|
||
"character": 141
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1442,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 1442,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2382,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 2382,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2397,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 2397,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2503,
|
||
"character": 36
|
||
},
|
||
"end": {
|
||
"line": 2503,
|
||
"character": 47
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2591,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 2591,
|
||
"character": 38
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2713,
|
||
"character": 16
|
||
},
|
||
"end": {
|
||
"line": 2713,
|
||
"character": 29
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "stringscut",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/gopls/internal/analysis/modernize#stringscut"
|
||
},
|
||
"source": "stringscut",
|
||
"message": "strings.Index can be simplified using strings.Cut"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2757,
|
||
"character": 15
|
||
},
|
||
"end": {
|
||
"line": 2757,
|
||
"character": 28
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringsseq"
|
||
},
|
||
"source": "stringsseq",
|
||
"message": "Ranging over SplitSeq is more efficient"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2803,
|
||
"character": 38
|
||
},
|
||
"end": {
|
||
"line": 2803,
|
||
"character": 49
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2810,
|
||
"character": 38
|
||
},
|
||
"end": {
|
||
"line": 2810,
|
||
"character": 49
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2818,
|
||
"character": 36
|
||
},
|
||
"end": {
|
||
"line": 2818,
|
||
"character": 47
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_migrate_test.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 230,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 230,
|
||
"character": 85
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#omitzero"
|
||
},
|
||
"source": "omitzero",
|
||
"message": "Omitempty has no effect on nested struct fields"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 383,
|
||
"character": 25
|
||
},
|
||
"end": {
|
||
"line": 383,
|
||
"character": 36
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 392,
|
||
"character": 44
|
||
},
|
||
"end": {
|
||
"line": 392,
|
||
"character": 55
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 400,
|
||
"character": 38
|
||
},
|
||
"end": {
|
||
"line": 400,
|
||
"character": 49
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 410,
|
||
"character": 49
|
||
},
|
||
"end": {
|
||
"line": 410,
|
||
"character": 60
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 415,
|
||
"character": 33
|
||
},
|
||
"end": {
|
||
"line": 415,
|
||
"character": 44
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_mutations.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 78,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 78,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 96,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 96,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 113,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 113,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 156,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 156,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 163,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 163,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 172,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 172,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 198,
|
||
"character": 116
|
||
},
|
||
"end": {
|
||
"line": 198,
|
||
"character": 127
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 198,
|
||
"character": 277
|
||
},
|
||
"end": {
|
||
"line": 198,
|
||
"character": 288
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 241,
|
||
"character": 119
|
||
},
|
||
"end": {
|
||
"line": 241,
|
||
"character": 130
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 247,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 247,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 294,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 294,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 371,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 371,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 380,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 380,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 559,
|
||
"character": 107
|
||
},
|
||
"end": {
|
||
"line": 559,
|
||
"character": 118
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 565,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 565,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 601,
|
||
"character": 70
|
||
},
|
||
"end": {
|
||
"line": 601,
|
||
"character": 81
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 620,
|
||
"character": 62
|
||
},
|
||
"end": {
|
||
"line": 620,
|
||
"character": 73
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 626,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 626,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 648,
|
||
"character": 122
|
||
},
|
||
"end": {
|
||
"line": 648,
|
||
"character": 133
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 654,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 654,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 670,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 670,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 679,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 679,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 722,
|
||
"character": 149
|
||
},
|
||
"end": {
|
||
"line": 722,
|
||
"character": 160
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 728,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 728,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 786,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 786,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 837,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 837,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1179,
|
||
"character": 192
|
||
},
|
||
"end": {
|
||
"line": 1179,
|
||
"character": 203
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1228,
|
||
"character": 80
|
||
},
|
||
"end": {
|
||
"line": 1228,
|
||
"character": 91
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1237,
|
||
"character": 137
|
||
},
|
||
"end": {
|
||
"line": 1237,
|
||
"character": 148
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1265,
|
||
"character": 31
|
||
},
|
||
"end": {
|
||
"line": 1265,
|
||
"character": 42
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1291,
|
||
"character": 30
|
||
},
|
||
"end": {
|
||
"line": 1291,
|
||
"character": 41
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1334,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 1334,
|
||
"character": 38
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1363,
|
||
"character": 10
|
||
},
|
||
"end": {
|
||
"line": 1363,
|
||
"character": 23
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringsseq"
|
||
},
|
||
"source": "stringsseq",
|
||
"message": "Ranging over SplitSeq is more efficient"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_control.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane_test.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go": []
|
||
},
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772506348939,
|
||
"end": 1772506349129
|
||
}
|
||
},
|
||
"id": "prt_cb19c533c001uPwcJFfkUkewmc",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c3fcd002t6Xmt4e0RkiObE"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "9ff9476eff2292f3cef00ae9dbcb5db0ebc376c6",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 62835,
|
||
"input": 61972,
|
||
"output": 863,
|
||
"reasoning": 111,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb19c7e4d001ONNE9r30ZV9RyU",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c3fcd002t6Xmt4e0RkiObE"
|
||
},
|
||
{
|
||
"type": "patch",
|
||
"hash": "d236541a96e6040855f0a7821c573d4497d00de3",
|
||
"files": [
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane_test.go"
|
||
],
|
||
"id": "prt_cb19c7e9e001LzA7r3133Ez9XY",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c3fcd002t6Xmt4e0RkiObE"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506349219,
|
||
"completed": 1772506361080
|
||
},
|
||
"parentID": "msg_cb19b9ae6001Tg0NlwB47EgTPl",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"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": 63392,
|
||
"input": 62752,
|
||
"output": 640,
|
||
"reasoning": 50,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb19c7ea3002BsyYN0Y2c7PzxJ",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "9ff9476eff2292f3cef00ae9dbcb5db0ebc376c6",
|
||
"id": "prt_cb19c8eda001MKh2080N9oWYcK",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c7ea3002BsyYN0Y2c7PzxJ"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Planning tests for tunnel config**",
|
||
"time": {
|
||
"start": 1772506353371,
|
||
"end": 1772506360903
|
||
},
|
||
"id": "prt_cb19c8edb001kjbu1eD50QWpW3",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c7ea3002BsyYN0Y2c7PzxJ"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_acmnvX1KRuJbynX6YI5raTtQ",
|
||
"tool": "apply_patch",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"patchText": "*** Begin Patch\n*** Update File: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go\n@@\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 \tif host != \"192.168.0.3\" {\n \t\tt.Fatalf(\"connectIp should override v6 preference, got %q\", host)\n \t}\n }\n+\n+func TestBuildTunnelChainServiceConfig_UsesConnectIPForListen(t *testing.T) {\n+\tnode := &nodeRecord{TCPListenAddr: \"[::]\"}\n+\tchain := tunnelRuntimeNode{Protocol: \"tls\", Port: 21000, ConnectIP: \"2001:db8::88\"}\n+\tservices := buildTunnelChainServiceConfig(99, chain, node)\n+\tif len(services) != 1 {\n+\t\tt.Fatalf(\"expected 1 service, got %d\", len(services))\n+\t}\n+\taddr, _ := services[0][\"addr\"].(string)\n+\tif addr != \"[2001:db8::88]:21000\" {\n+\t\tt.Fatalf(\"expected connectIp listen [2001:db8::88]:21000, got %q\", addr)\n+\t}\n+}\n+\n+func TestBuildTunnelChainServiceConfig_DefaultListenAddrWhenConnectIPEmpty(t *testing.T) {\n+\tnode := &nodeRecord{TCPListenAddr: \"[::]\"}\n+\tchain := tunnelRuntimeNode{Protocol: \"tls\", Port: 21001}\n+\tservices := buildTunnelChainServiceConfig(99, chain, node)\n+\tif len(services) != 1 {\n+\t\tt.Fatalf(\"expected 1 service, got %d\", len(services))\n+\t}\n+\taddr, _ := services[0][\"addr\"].(string)\n+\tif addr != \"[::]:21001\" {\n+\t\tt.Fatalf(\"expected default listen [::]:21001, got %q\", addr)\n+\t}\n+}\n*** End Patch"
|
||
},
|
||
"output": "Success. Updated the following files:\nM go-backend/internal/http/handler/dual_stack_test.go",
|
||
"title": "Success. Updated the following files:\nM go-backend/internal/http/handler/dual_stack_test.go",
|
||
"metadata": {
|
||
"diff": "Index: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go\n===================================================================\n--- /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go\n+++ /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go\n@@ -29,8 +29,34 @@\n \t\tt.Fatalf(\"connectIp should override v6 preference, got %q\", host)\n \t}\n }\n \n+func TestBuildTunnelChainServiceConfig_UsesConnectIPForListen(t *testing.T) {\n+\tnode := &nodeRecord{TCPListenAddr: \"[::]\"}\n+\tchain := tunnelRuntimeNode{Protocol: \"tls\", Port: 21000, ConnectIP: \"2001:db8::88\"}\n+\tservices := buildTunnelChainServiceConfig(99, chain, node)\n+\tif len(services) != 1 {\n+\t\tt.Fatalf(\"expected 1 service, got %d\", len(services))\n+\t}\n+\taddr, _ := services[0][\"addr\"].(string)\n+\tif addr != \"[2001:db8::88]:21000\" {\n+\t\tt.Fatalf(\"expected connectIp listen [2001:db8::88]:21000, got %q\", addr)\n+\t}\n+}\n+\n+func TestBuildTunnelChainServiceConfig_DefaultListenAddrWhenConnectIPEmpty(t *testing.T) {\n+\tnode := &nodeRecord{TCPListenAddr: \"[::]\"}\n+\tchain := tunnelRuntimeNode{Protocol: \"tls\", Port: 21001}\n+\tservices := buildTunnelChainServiceConfig(99, chain, node)\n+\tif len(services) != 1 {\n+\t\tt.Fatalf(\"expected 1 service, got %d\", len(services))\n+\t}\n+\taddr, _ := services[0][\"addr\"].(string)\n+\tif addr != \"[::]:21001\" {\n+\t\tt.Fatalf(\"expected default listen [::]:21001, got %q\", addr)\n+\t}\n+}\n+\n func TestNodeSupportsV6_Nil(t *testing.T) {\n \tif nodeSupportsV6(nil) {\n \t\tt.Fatal(\"nil node must not support v6\")\n \t}\n\n",
|
||
"files": [
|
||
{
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go",
|
||
"relativePath": "go-backend/internal/http/handler/dual_stack_test.go",
|
||
"type": "update",
|
||
"diff": "Index: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go\n===================================================================\n--- /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go\n+++ /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go\n@@ -29,8 +29,34 @@\n \t\tt.Fatalf(\"connectIp should override v6 preference, got %q\", host)\n \t}\n }\n \n+func TestBuildTunnelChainServiceConfig_UsesConnectIPForListen(t *testing.T) {\n+\tnode := &nodeRecord{TCPListenAddr: \"[::]\"}\n+\tchain := tunnelRuntimeNode{Protocol: \"tls\", Port: 21000, ConnectIP: \"2001:db8::88\"}\n+\tservices := buildTunnelChainServiceConfig(99, chain, node)\n+\tif len(services) != 1 {\n+\t\tt.Fatalf(\"expected 1 service, got %d\", len(services))\n+\t}\n+\taddr, _ := services[0][\"addr\"].(string)\n+\tif addr != \"[2001:db8::88]:21000\" {\n+\t\tt.Fatalf(\"expected connectIp listen [2001:db8::88]:21000, got %q\", addr)\n+\t}\n+}\n+\n+func TestBuildTunnelChainServiceConfig_DefaultListenAddrWhenConnectIPEmpty(t *testing.T) {\n+\tnode := &nodeRecord{TCPListenAddr: \"[::]\"}\n+\tchain := tunnelRuntimeNode{Protocol: \"tls\", Port: 21001}\n+\tservices := buildTunnelChainServiceConfig(99, chain, node)\n+\tif len(services) != 1 {\n+\t\tt.Fatalf(\"expected 1 service, got %d\", len(services))\n+\t}\n+\taddr, _ := services[0][\"addr\"].(string)\n+\tif addr != \"[::]:21001\" {\n+\t\tt.Fatalf(\"expected default listen [::]:21001, got %q\", addr)\n+\t}\n+}\n+\n func TestNodeSupportsV6_Nil(t *testing.T) {\n \tif nodeSupportsV6(nil) {\n \t\tt.Fatal(\"nil node must not support v6\")\n \t}\n",
|
||
"before": "package handler\n\nimport (\n\t\"testing\"\n)\n\n// ---------------------------------------------------------------------------\n// nodeSupportsV4 / nodeSupportsV6\n// ---------------------------------------------------------------------------\n\nfunc 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\nfunc TestNodeSupportsV6_Nil(t *testing.T) {\n\tif nodeSupportsV6(nil) {\n\t\tt.Fatal(\"nil node must not support v6\")\n\t}\n}\n\nfunc 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 needs support v4\")\n\t}\n}\n\nfunc TestNodeSupportsV6_ExplicitV6(t *testing.T) {\n\tn := &nodeRecord{ServerIPv6: \"2001:db8::1\"}\n\tif !nodeSupportsV6(n) {\n\t\tt.Fatal(\"explicit server_ip_v6 needs support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV4_OnlyV6Set(t *testing.T) {\n\tn := &nodeRecord{ServerIPv6: \"2001:db8::1\"}\n\tif nodeSupportsV4(n) {\n\t\tt.Fatal(\"node with only v6 should not support v4\")\n\t}\n}\n\nfunc TestNodeSupportsV6_OnlyV4Set(t *testing.T) {\n\tn := &nodeRecord{ServerIPv4: \"10.0.0.1\"}\n\tif nodeSupportsV6(n) {\n\t\tt.Fatal(\"node with only v4 should not support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV4_DualStack(t *testing.T) {\n\tn := &nodeRecord{ServerIPv4: \"10.0.0.1\", ServerIPv6: \"2001:db8::1\"}\n\tif !nodeSupportsV4(n) {\n\t\tt.Fatal(\"dual-stack node must support v4\")\n\t}\n}\n\nfunc TestNodeSupportsV6_DualStack(t *testing.T) {\n\tn := &nodeRecord{ServerIPv4: \"10.0.0.1\", ServerIPv6: \"2001:db8::1\"}\n\tif !nodeSupportsV6(n) {\n\t\tt.Fatal(\"dual-stack node must support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV4_LegacyV4Only(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"192.168.1.1\"}\n\tif !nodeSupportsV4(n) {\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 should not support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV6_LegacyV6Only(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"2001:db8::1\"}\n\tif !nodeSupportsV6(n) {\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 should not support v4\")\n\t}\n}\n\nfunc TestNodeSupportsV4_EmptyNode(t *testing.T) {\n\tn := &nodeRecord{}\n\tif nodeSupportsV4(n) {\n\t\tt.Fatal(\"empty node must not support v4\")\n\t}\n\tif nodeSupportsV6(n) {\n\t\tt.Fatal(\"empty node must not support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV4_LegacyBracketed(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"[::1]\"}\n\tif nodeSupportsV4(n) {\n\t\tt.Fatal(\"bracketed ipv6 must not support v4\")\n\t}\n\tif !nodeSupportsV6(n) {\n\t\tt.Fatal(\"bracketed ipv6 must support v6\")\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// pickNodeAddressV4 / pickNodeAddressV6\n// ---------------------------------------------------------------------------\n\nfunc TestPickNodeAddressV4_Nil(t *testing.T) {\n\tif pickNodeAddressV4(nil) != \"\" {\n\t\tt.Fatal(\"nil node must return empty\")\n\t}\n}\n\nfunc TestPickNodeAddressV6_Nil(t *testing.T) {\n\tif pickNodeAddressV6(nil) != \"\" {\n\t\tt.Fatal(\"nil node must return empty\")\n\t}\n}\n\nfunc TestPickNodeAddressV4_PreferExplicit(t *testing.T) {\n\tn := &nodeRecord{ServerIPv4: \"10.0.0.1\", ServerIP: \"192.168.0.1\"}\n\tgot := pickNodeAddressV4(n)\n\tif got != \"10.0.0.1\" {\n\t\tt.Fatalf(\"expected explicit v4 10.0.0.1, got %q\", got)\n\t}\n}\n\nfunc TestPickNodeAddressV4_FallbackLegacy(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"192.168.0.1\"}\n\tgot := pickNodeAddressV4(n)\n\tif got != \"192.168.0.1\" {\n\t\tt.Fatalf(\"expected legacy 192.168.0.1, got %q\", got)\n\t}\n}\n\nfunc TestPickNodeAddressV6_PreferExplicit(t *testing.T) {\n\tn := &nodeRecord{ServerIPv6: \"2001:db8::1\", ServerIP: \"::1\"}\n\tgot := pickNodeAddressV6(n)\n\tif got != \"2001:db8::1\" {\n\t\tt.Fatalf(\"expected explicit v6 2001:db8::1, got %q\", got)\n\t}\n}\n\nfunc TestPickNodeAddressV6_FallbackLegacy(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"::1\"}\n\tgot := pickNodeAddressV6(n)\n\tif got != \"::1\" {\n\t\tt.Fatalf(\"expected legacy ::1, got %q\", got)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// selectTunnelDialHost — core IP preference selection logic\n// ---------------------------------------------------------------------------\n\nfunc dualStackNode(name, v4, v6 string) *nodeRecord {\n\treturn &nodeRecord{\n\t\tName: name,\n\t\tServerIPv4: v4,\n\t\tServerIPv6: v6,\n\t}\n}\n\nfunc v4OnlyNode(name, v4 string) *nodeRecord {\n\treturn &nodeRecord{\n\t\tName: name,\n\t\tServerIPv4: v4,\n\t}\n}\n\nfunc v6OnlyNode(name, v6 string) *nodeRecord {\n\treturn &nodeRecord{\n\t\tName: name,\n\t\tServerIPv6: v6,\n\t}\n}\n\nfunc TestSelectTunnelDialHost_NilNodes(t *testing.T) {\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\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\tif err == nil {\n\t\tt.Fatal(\"expected error for nil fromNode\")\n\t}\n}\n\nfunc 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\thost, err := selectTunnelDialHost(from, to, \"\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\t// Default prefers v4 when both available\n\tif host != \"10.0.0.2\" {\n\t\tt.Fatalf(\"default preference should pick v4, got %q\", host)\n\t}\n}\n\nfunc 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\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\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(\"v4 preference should pick v4 address, got %q\", host)\n\t}\n}\n\nfunc 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\thost, err := selectTunnelDialHost(from, to, \"v6\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"v6 preference should pick v6 address, got %q\", host)\n\t}\n}\n\nfunc TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {\n\tfrom := v4OnlyNode(\"from\", \"10.0.0.1\")\n\tto := v4OnlyNode(\"to\", \"10.0.0.2\")\n\t// User prefers v6, but both nodes are v4-only — should fallback to v4\n\thost, err := selectTunnelDialHost(from, to, \"v6\", \"\")\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(\"v6 preference on v4-only nodes should fallback to v4, got %q\", host)\n\t}\n}\n\nfunc TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {\n\tfrom := v6OnlyNode(\"from\", \"2001:db8::1\")\n\tto := v6OnlyNode(\"to\", \"2001:db8::2\")\n\t// User prefers v4, but both nodes are v6-only — should fallback to v6\n\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"v4 preference on v6-only nodes should fallback to v6, got %q\", host)\n\t}\n}\n\nfunc TestSelectTunnelDialHost_Incompatible(t *testing.T) {\n\tfrom := v4OnlyNode(\"from\", \"10.0.0.1\")\n\tto := v6OnlyNode(\"to\", \"2001:db8::2\")\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}\n\nfunc TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {\n\tfrom := v6OnlyNode(\"from\", \"2001:db8::1\")\n\tto := v4OnlyNode(\"to\", \"10.0.0.2\")\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}\n\nfunc 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\t// Whitespace should be trimmed, treated as \"v6\"\n\thost, err := selectTunnelDialHost(from, to, \" v6 \", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"trimmed v6 preference should pick v6 address, got %q\", host)\n\t}\n}\n\nfunc 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\t// v6 preferred, but target only has v4 — should succeed with v4\n\thost, err := selectTunnelDialHost(from, to, \"v6\", \"\")\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(\"should fallback to v4 when target is v4-only, got %q\", host)\n\t}\n}\n\nfunc 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\t// v4 preferred, but target only has v6 — should succeed with v6\n\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"should fallback to v6 when target is v6-only, got %q\", host)\n\t}\n}\n\nfunc 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\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\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"10.0.0.2\" {\n\t\tt.Fatalf(\"should use v4 when from is v4-only, got %q\", host)\n\t}\n}\n\nfunc 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\t// v4 preferred, but from only has v6 — should use v6\n\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"should use v6 when from is v6-only, got %q\", host)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// nodeDisplayName\n// ---------------------------------------------------------------------------\n\nfunc TestNodeDisplayName_Nil(t *testing.T) {\n\tgot := nodeDisplayName(nil)\n\tif got != \"node\" {\n\t\tt.Fatalf(\"nil node display name should be 'node', got %q\", got)\n\t}\n}\n\nfunc TestNodeDisplayName_Named(t *testing.T) {\n\tn := &nodeRecord{ID: 42, Name: \"hk-node\"}\n\tgot := nodeDisplayName(n)\n\tif got != \"hk-node\" {\n\t\tt.Fatalf(\"expected 'hk-node', got %q\", got)\n\t}\n}\nfunc TestNodeDisplayName_Unnamed(t *testing.T) {\n\tn := &nodeRecord{ID: 42}\n\tgot := nodeDisplayName(n)\n\tif got != \"node_42\" {\n\t\tt.Fatalf(\"expected 'node_42', got %q\", got)\n\t}\n}\n",
|
||
"after": "package handler\n\nimport (\n\t\"testing\"\n)\n\n// ---------------------------------------------------------------------------\n// nodeSupportsV4 / nodeSupportsV6\n// ---------------------------------------------------------------------------\n\nfunc 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\nfunc TestBuildTunnelChainServiceConfig_UsesConnectIPForListen(t *testing.T) {\n\tnode := &nodeRecord{TCPListenAddr: \"[::]\"}\n\tchain := tunnelRuntimeNode{Protocol: \"tls\", Port: 21000, ConnectIP: \"2001:db8::88\"}\n\tservices := buildTunnelChainServiceConfig(99, chain, node)\n\tif len(services) != 1 {\n\t\tt.Fatalf(\"expected 1 service, got %d\", len(services))\n\t}\n\taddr, _ := services[0][\"addr\"].(string)\n\tif addr != \"[2001:db8::88]:21000\" {\n\t\tt.Fatalf(\"expected connectIp listen [2001:db8::88]:21000, got %q\", addr)\n\t}\n}\n\nfunc TestBuildTunnelChainServiceConfig_DefaultListenAddrWhenConnectIPEmpty(t *testing.T) {\n\tnode := &nodeRecord{TCPListenAddr: \"[::]\"}\n\tchain := tunnelRuntimeNode{Protocol: \"tls\", Port: 21001}\n\tservices := buildTunnelChainServiceConfig(99, chain, node)\n\tif len(services) != 1 {\n\t\tt.Fatalf(\"expected 1 service, got %d\", len(services))\n\t}\n\taddr, _ := services[0][\"addr\"].(string)\n\tif addr != \"[::]:21001\" {\n\t\tt.Fatalf(\"expected default listen [::]:21001, got %q\", addr)\n\t}\n}\n\nfunc TestNodeSupportsV6_Nil(t *testing.T) {\n\tif nodeSupportsV6(nil) {\n\t\tt.Fatal(\"nil node must not support v6\")\n\t}\n}\n\nfunc 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 needs support v4\")\n\t}\n}\n\nfunc TestNodeSupportsV6_ExplicitV6(t *testing.T) {\n\tn := &nodeRecord{ServerIPv6: \"2001:db8::1\"}\n\tif !nodeSupportsV6(n) {\n\t\tt.Fatal(\"explicit server_ip_v6 needs support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV4_OnlyV6Set(t *testing.T) {\n\tn := &nodeRecord{ServerIPv6: \"2001:db8::1\"}\n\tif nodeSupportsV4(n) {\n\t\tt.Fatal(\"node with only v6 should not support v4\")\n\t}\n}\n\nfunc TestNodeSupportsV6_OnlyV4Set(t *testing.T) {\n\tn := &nodeRecord{ServerIPv4: \"10.0.0.1\"}\n\tif nodeSupportsV6(n) {\n\t\tt.Fatal(\"node with only v4 should not support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV4_DualStack(t *testing.T) {\n\tn := &nodeRecord{ServerIPv4: \"10.0.0.1\", ServerIPv6: \"2001:db8::1\"}\n\tif !nodeSupportsV4(n) {\n\t\tt.Fatal(\"dual-stack node must support v4\")\n\t}\n}\n\nfunc TestNodeSupportsV6_DualStack(t *testing.T) {\n\tn := &nodeRecord{ServerIPv4: \"10.0.0.1\", ServerIPv6: \"2001:db8::1\"}\n\tif !nodeSupportsV6(n) {\n\t\tt.Fatal(\"dual-stack node must support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV4_LegacyV4Only(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"192.168.1.1\"}\n\tif !nodeSupportsV4(n) {\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 should not support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV6_LegacyV6Only(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"2001:db8::1\"}\n\tif !nodeSupportsV6(n) {\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 should not support v4\")\n\t}\n}\n\nfunc TestNodeSupportsV4_EmptyNode(t *testing.T) {\n\tn := &nodeRecord{}\n\tif nodeSupportsV4(n) {\n\t\tt.Fatal(\"empty node must not support v4\")\n\t}\n\tif nodeSupportsV6(n) {\n\t\tt.Fatal(\"empty node must not support v6\")\n\t}\n}\n\nfunc TestNodeSupportsV4_LegacyBracketed(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"[::1]\"}\n\tif nodeSupportsV4(n) {\n\t\tt.Fatal(\"bracketed ipv6 must not support v4\")\n\t}\n\tif !nodeSupportsV6(n) {\n\t\tt.Fatal(\"bracketed ipv6 must support v6\")\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// pickNodeAddressV4 / pickNodeAddressV6\n// ---------------------------------------------------------------------------\n\nfunc TestPickNodeAddressV4_Nil(t *testing.T) {\n\tif pickNodeAddressV4(nil) != \"\" {\n\t\tt.Fatal(\"nil node must return empty\")\n\t}\n}\n\nfunc TestPickNodeAddressV6_Nil(t *testing.T) {\n\tif pickNodeAddressV6(nil) != \"\" {\n\t\tt.Fatal(\"nil node must return empty\")\n\t}\n}\n\nfunc TestPickNodeAddressV4_PreferExplicit(t *testing.T) {\n\tn := &nodeRecord{ServerIPv4: \"10.0.0.1\", ServerIP: \"192.168.0.1\"}\n\tgot := pickNodeAddressV4(n)\n\tif got != \"10.0.0.1\" {\n\t\tt.Fatalf(\"expected explicit v4 10.0.0.1, got %q\", got)\n\t}\n}\n\nfunc TestPickNodeAddressV4_FallbackLegacy(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"192.168.0.1\"}\n\tgot := pickNodeAddressV4(n)\n\tif got != \"192.168.0.1\" {\n\t\tt.Fatalf(\"expected legacy 192.168.0.1, got %q\", got)\n\t}\n}\n\nfunc TestPickNodeAddressV6_PreferExplicit(t *testing.T) {\n\tn := &nodeRecord{ServerIPv6: \"2001:db8::1\", ServerIP: \"::1\"}\n\tgot := pickNodeAddressV6(n)\n\tif got != \"2001:db8::1\" {\n\t\tt.Fatalf(\"expected explicit v6 2001:db8::1, got %q\", got)\n\t}\n}\n\nfunc TestPickNodeAddressV6_FallbackLegacy(t *testing.T) {\n\tn := &nodeRecord{ServerIP: \"::1\"}\n\tgot := pickNodeAddressV6(n)\n\tif got != \"::1\" {\n\t\tt.Fatalf(\"expected legacy ::1, got %q\", got)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// selectTunnelDialHost — core IP preference selection logic\n// ---------------------------------------------------------------------------\n\nfunc dualStackNode(name, v4, v6 string) *nodeRecord {\n\treturn &nodeRecord{\n\t\tName: name,\n\t\tServerIPv4: v4,\n\t\tServerIPv6: v6,\n\t}\n}\n\nfunc v4OnlyNode(name, v4 string) *nodeRecord {\n\treturn &nodeRecord{\n\t\tName: name,\n\t\tServerIPv4: v4,\n\t}\n}\n\nfunc v6OnlyNode(name, v6 string) *nodeRecord {\n\treturn &nodeRecord{\n\t\tName: name,\n\t\tServerIPv6: v6,\n\t}\n}\n\nfunc TestSelectTunnelDialHost_NilNodes(t *testing.T) {\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\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\tif err == nil {\n\t\tt.Fatal(\"expected error for nil fromNode\")\n\t}\n}\n\nfunc 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\thost, err := selectTunnelDialHost(from, to, \"\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\t// Default prefers v4 when both available\n\tif host != \"10.0.0.2\" {\n\t\tt.Fatalf(\"default preference should pick v4, got %q\", host)\n\t}\n}\n\nfunc 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\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\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(\"v4 preference should pick v4 address, got %q\", host)\n\t}\n}\n\nfunc 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\thost, err := selectTunnelDialHost(from, to, \"v6\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"v6 preference should pick v6 address, got %q\", host)\n\t}\n}\n\nfunc TestSelectTunnelDialHost_V4Only_PreferV6Fallback(t *testing.T) {\n\tfrom := v4OnlyNode(\"from\", \"10.0.0.1\")\n\tto := v4OnlyNode(\"to\", \"10.0.0.2\")\n\t// User prefers v6, but both nodes are v4-only — should fallback to v4\n\thost, err := selectTunnelDialHost(from, to, \"v6\", \"\")\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(\"v6 preference on v4-only nodes should fallback to v4, got %q\", host)\n\t}\n}\n\nfunc TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {\n\tfrom := v6OnlyNode(\"from\", \"2001:db8::1\")\n\tto := v6OnlyNode(\"to\", \"2001:db8::2\")\n\t// User prefers v4, but both nodes are v6-only — should fallback to v6\n\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"v4 preference on v6-only nodes should fallback to v6, got %q\", host)\n\t}\n}\n\nfunc TestSelectTunnelDialHost_Incompatible(t *testing.T) {\n\tfrom := v4OnlyNode(\"from\", \"10.0.0.1\")\n\tto := v6OnlyNode(\"to\", \"2001:db8::2\")\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}\n\nfunc TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {\n\tfrom := v6OnlyNode(\"from\", \"2001:db8::1\")\n\tto := v4OnlyNode(\"to\", \"10.0.0.2\")\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}\n\nfunc 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\t// Whitespace should be trimmed, treated as \"v6\"\n\thost, err := selectTunnelDialHost(from, to, \" v6 \", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"trimmed v6 preference should pick v6 address, got %q\", host)\n\t}\n}\n\nfunc 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\t// v6 preferred, but target only has v4 — should succeed with v4\n\thost, err := selectTunnelDialHost(from, to, \"v6\", \"\")\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(\"should fallback to v4 when target is v4-only, got %q\", host)\n\t}\n}\n\nfunc 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\t// v4 preferred, but target only has v6 — should succeed with v6\n\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"should fallback to v6 when target is v6-only, got %q\", host)\n\t}\n}\n\nfunc 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\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\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"10.0.0.2\" {\n\t\tt.Fatalf(\"should use v4 when from is v4-only, got %q\", host)\n\t}\n}\n\nfunc 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\t// v4 preferred, but from only has v6 — should use v6\n\thost, err := selectTunnelDialHost(from, to, \"v4\", \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif host != \"2001:db8::2\" {\n\t\tt.Fatalf(\"should use v6 when from is v6-only, got %q\", host)\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// nodeDisplayName\n// ---------------------------------------------------------------------------\n\nfunc TestNodeDisplayName_Nil(t *testing.T) {\n\tgot := nodeDisplayName(nil)\n\tif got != \"node\" {\n\t\tt.Fatalf(\"nil node display name should be 'node', got %q\", got)\n\t}\n}\n\nfunc TestNodeDisplayName_Named(t *testing.T) {\n\tn := &nodeRecord{ID: 42, Name: \"hk-node\"}\n\tgot := nodeDisplayName(n)\n\tif got != \"hk-node\" {\n\t\tt.Fatalf(\"expected 'hk-node', got %q\", got)\n\t}\n}\nfunc TestNodeDisplayName_Unnamed(t *testing.T) {\n\tn := &nodeRecord{ID: 42}\n\tgot := nodeDisplayName(n)\n\tif got != \"node_42\" {\n\t\tt.Fatalf(\"expected 'node_42', got %q\", got)\n\t}\n}\n",
|
||
"additions": 26,
|
||
"deletions": 0
|
||
}
|
||
],
|
||
"diagnostics": {
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 161,
|
||
"character": 13
|
||
},
|
||
"end": {
|
||
"line": 161,
|
||
"character": 24
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 356,
|
||
"character": 77
|
||
},
|
||
"end": {
|
||
"line": 356,
|
||
"character": 88
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 436,
|
||
"character": 74
|
||
},
|
||
"end": {
|
||
"line": 436,
|
||
"character": 85
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 573,
|
||
"character": 77
|
||
},
|
||
"end": {
|
||
"line": 573,
|
||
"character": 88
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 583,
|
||
"character": 77
|
||
},
|
||
"end": {
|
||
"line": 583,
|
||
"character": 88
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 596,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 596,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 603,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 603,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 611,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 611,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 624,
|
||
"character": 47
|
||
},
|
||
"end": {
|
||
"line": 624,
|
||
"character": 58
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 632,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 632,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 634,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 634,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 654,
|
||
"character": 47
|
||
},
|
||
"end": {
|
||
"line": 654,
|
||
"character": 58
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 662,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 662,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 664,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 664,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 676,
|
||
"character": 53
|
||
},
|
||
"end": {
|
||
"line": 676,
|
||
"character": 64
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 684,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 684,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 686,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 686,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 696,
|
||
"character": 50
|
||
},
|
||
"end": {
|
||
"line": 696,
|
||
"character": 61
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 728,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 728,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 734,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 734,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 750,
|
||
"character": 75
|
||
},
|
||
"end": {
|
||
"line": 750,
|
||
"character": 86
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 769,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 769,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 771,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 771,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 776,
|
||
"character": 64
|
||
},
|
||
"end": {
|
||
"line": 776,
|
||
"character": 75
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 790,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 790,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 792,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 792,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 797,
|
||
"character": 49
|
||
},
|
||
"end": {
|
||
"line": 797,
|
||
"character": 60
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 807,
|
||
"character": 40
|
||
},
|
||
"end": {
|
||
"line": 807,
|
||
"character": 51
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 811,
|
||
"character": 31
|
||
},
|
||
"end": {
|
||
"line": 811,
|
||
"character": 42
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 817,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 817,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 818,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 818,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 819,
|
||
"character": 39
|
||
},
|
||
"end": {
|
||
"line": 819,
|
||
"character": 50
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 839,
|
||
"character": 47
|
||
},
|
||
"end": {
|
||
"line": 839,
|
||
"character": 58
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 856,
|
||
"character": 24
|
||
},
|
||
"end": {
|
||
"line": 856,
|
||
"character": 35
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 873,
|
||
"character": 54
|
||
},
|
||
"end": {
|
||
"line": 873,
|
||
"character": 65
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 879,
|
||
"character": 51
|
||
},
|
||
"end": {
|
||
"line": 879,
|
||
"character": 62
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 883,
|
||
"character": 56
|
||
},
|
||
"end": {
|
||
"line": 883,
|
||
"character": 67
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 897,
|
||
"character": 33
|
||
},
|
||
"end": {
|
||
"line": 897,
|
||
"character": 44
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 910,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 910,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 921,
|
||
"character": 54
|
||
},
|
||
"end": {
|
||
"line": 921,
|
||
"character": 65
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 929,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 929,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 935,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 935,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 944,
|
||
"character": 52
|
||
},
|
||
"end": {
|
||
"line": 944,
|
||
"character": 63
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 952,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 952,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 958,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 958,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 967,
|
||
"character": 58
|
||
},
|
||
"end": {
|
||
"line": 967,
|
||
"character": 69
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 990,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 990,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 992,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 992,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1064,
|
||
"character": 84
|
||
},
|
||
"end": {
|
||
"line": 1064,
|
||
"character": 95
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1130,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 1130,
|
||
"character": 38
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1146,
|
||
"character": 83
|
||
},
|
||
"end": {
|
||
"line": 1146,
|
||
"character": 94
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1170,
|
||
"character": 90
|
||
},
|
||
"end": {
|
||
"line": 1170,
|
||
"character": 101
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1184,
|
||
"character": 85
|
||
},
|
||
"end": {
|
||
"line": 1184,
|
||
"character": 96
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1404,
|
||
"character": 85
|
||
},
|
||
"end": {
|
||
"line": 1404,
|
||
"character": 96
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1421,
|
||
"character": 130
|
||
},
|
||
"end": {
|
||
"line": 1421,
|
||
"character": 141
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1442,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 1442,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2382,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 2382,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2397,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 2397,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2503,
|
||
"character": 36
|
||
},
|
||
"end": {
|
||
"line": 2503,
|
||
"character": 47
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2591,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 2591,
|
||
"character": 38
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2713,
|
||
"character": 16
|
||
},
|
||
"end": {
|
||
"line": 2713,
|
||
"character": 29
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "stringscut",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/gopls/internal/analysis/modernize#stringscut"
|
||
},
|
||
"source": "stringscut",
|
||
"message": "strings.Index can be simplified using strings.Cut"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2757,
|
||
"character": 15
|
||
},
|
||
"end": {
|
||
"line": 2757,
|
||
"character": 28
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringsseq"
|
||
},
|
||
"source": "stringsseq",
|
||
"message": "Ranging over SplitSeq is more efficient"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2803,
|
||
"character": 38
|
||
},
|
||
"end": {
|
||
"line": 2803,
|
||
"character": 49
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2810,
|
||
"character": 38
|
||
},
|
||
"end": {
|
||
"line": 2810,
|
||
"character": 49
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2818,
|
||
"character": 36
|
||
},
|
||
"end": {
|
||
"line": 2818,
|
||
"character": 47
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_migrate_test.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 230,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 230,
|
||
"character": 85
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#omitzero"
|
||
},
|
||
"source": "omitzero",
|
||
"message": "Omitempty has no effect on nested struct fields"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 383,
|
||
"character": 25
|
||
},
|
||
"end": {
|
||
"line": 383,
|
||
"character": 36
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 392,
|
||
"character": 44
|
||
},
|
||
"end": {
|
||
"line": 392,
|
||
"character": 55
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 400,
|
||
"character": 38
|
||
},
|
||
"end": {
|
||
"line": 400,
|
||
"character": 49
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 410,
|
||
"character": 49
|
||
},
|
||
"end": {
|
||
"line": 410,
|
||
"character": 60
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 415,
|
||
"character": 33
|
||
},
|
||
"end": {
|
||
"line": 415,
|
||
"character": 44
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_mutations.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 78,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 78,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 96,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 96,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 113,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 113,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 156,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 156,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 163,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 163,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 172,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 172,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 198,
|
||
"character": 116
|
||
},
|
||
"end": {
|
||
"line": 198,
|
||
"character": 127
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 198,
|
||
"character": 277
|
||
},
|
||
"end": {
|
||
"line": 198,
|
||
"character": 288
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 241,
|
||
"character": 119
|
||
},
|
||
"end": {
|
||
"line": 241,
|
||
"character": 130
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 247,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 247,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 294,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 294,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 371,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 371,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 380,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 380,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 559,
|
||
"character": 107
|
||
},
|
||
"end": {
|
||
"line": 559,
|
||
"character": 118
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 565,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 565,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 601,
|
||
"character": 70
|
||
},
|
||
"end": {
|
||
"line": 601,
|
||
"character": 81
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 620,
|
||
"character": 62
|
||
},
|
||
"end": {
|
||
"line": 620,
|
||
"character": 73
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 626,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 626,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 648,
|
||
"character": 122
|
||
},
|
||
"end": {
|
||
"line": 648,
|
||
"character": 133
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 654,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 654,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 670,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 670,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 679,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 679,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 722,
|
||
"character": 149
|
||
},
|
||
"end": {
|
||
"line": 722,
|
||
"character": 160
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 728,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 728,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 786,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 786,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 837,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 837,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1179,
|
||
"character": 192
|
||
},
|
||
"end": {
|
||
"line": 1179,
|
||
"character": 203
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1228,
|
||
"character": 80
|
||
},
|
||
"end": {
|
||
"line": 1228,
|
||
"character": 91
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1237,
|
||
"character": 137
|
||
},
|
||
"end": {
|
||
"line": 1237,
|
||
"character": 148
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1265,
|
||
"character": 31
|
||
},
|
||
"end": {
|
||
"line": 1265,
|
||
"character": 42
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1291,
|
||
"character": 30
|
||
},
|
||
"end": {
|
||
"line": 1291,
|
||
"character": 41
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1334,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 1334,
|
||
"character": 38
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1363,
|
||
"character": 10
|
||
},
|
||
"end": {
|
||
"line": 1363,
|
||
"character": 23
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringsseq"
|
||
},
|
||
"source": "stringsseq",
|
||
"message": "Ranging over SplitSeq is more efficient"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_control.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane_test.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go": []
|
||
},
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772506360772,
|
||
"end": 1772506360987
|
||
}
|
||
},
|
||
"id": "prt_cb19c8f820018N548IgnzbI4kr",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c7ea3002BsyYN0Y2c7PzxJ"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "3226f07afb4be2c356b6f96c2ce0fbaca73c36e1",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 63392,
|
||
"input": 62752,
|
||
"output": 640,
|
||
"reasoning": 50,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb19caca0001d6ezkasM0X8lS8",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c7ea3002BsyYN0Y2c7PzxJ"
|
||
},
|
||
{
|
||
"type": "patch",
|
||
"hash": "9ff9476eff2292f3cef00ae9dbcb5db0ebc376c6",
|
||
"files": [
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go"
|
||
],
|
||
"id": "prt_cb19cacf5001f64IxEPtmUfFgN",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19c7ea3002BsyYN0Y2c7PzxJ"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506361083,
|
||
"completed": 1772506367588
|
||
},
|
||
"parentID": "msg_cb19b9ae6001Tg0NlwB47EgTPl",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"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": 63454,
|
||
"input": 63371,
|
||
"output": 83,
|
||
"reasoning": 0,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb19cacfb002aQ6gEqsWfDljOS",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "3226f07afb4be2c356b6f96c2ce0fbaca73c36e1",
|
||
"id": "prt_cb19cb7e900133evtTPxxv6MNC",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19cacfb002aQ6gEqsWfDljOS"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_j5hg0QjO6lCC5l5OgcKg8Jiu",
|
||
"tool": "bash",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"command": "go test ./internal/http/handler/...",
|
||
"timeout": 120000,
|
||
"workdir": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend",
|
||
"description": "Run handler package tests"
|
||
},
|
||
"output": "ok \tgo-backend/internal/http/handler\t1.043s\n",
|
||
"title": "Run handler package tests",
|
||
"metadata": {
|
||
"output": "ok \tgo-backend/internal/http/handler\t1.043s\n",
|
||
"exit": 0,
|
||
"description": "Run handler package tests",
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772506364957,
|
||
"end": 1772506367540
|
||
}
|
||
},
|
||
"id": "prt_cb19cb7ea0016RBPiTA4WvAD7f",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19cacfb002aQ6gEqsWfDljOS"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "3226f07afb4be2c356b6f96c2ce0fbaca73c36e1",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 63454,
|
||
"input": 63371,
|
||
"output": 83,
|
||
"reasoning": 0,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb19cc634001LB077a2GHDfc35",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19cacfb002aQ6gEqsWfDljOS"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772506367591,
|
||
"completed": 1772506375519
|
||
},
|
||
"parentID": "msg_cb19b9ae6001Tg0NlwB47EgTPl",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"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": 63746,
|
||
"input": 63480,
|
||
"output": 266,
|
||
"reasoning": 0,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "stop",
|
||
"id": "msg_cb19cc667002SuwoWcS0lQUwvR",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "3226f07afb4be2c356b6f96c2ce0fbaca73c36e1",
|
||
"id": "prt_cb19cde9a001azPO7ZDArNfTJk",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19cc667002SuwoWcS0lQUwvR"
|
||
},
|
||
{
|
||
"type": "text",
|
||
"text": "已实施完成,并且按你确认的语义实现为:`connectIp` 同时用于“上游拨号目标 + 本节点监听绑定”。\n\n- 转发服务下发现在会优先使用 `forward_port.in_ip` 作为监听地址,不再固定用节点默认 `[::]`。\n- 隧道服务下发现在会优先使用 `connectIp` 作为监听地址;为空时才回退到节点默认监听地址。\n- IPv6 监听地址统一通过 `processServerAddress(...)` 处理,确保带方括号格式正确。\n\n改动位置:\n- `go-backend/internal/http/handler/control_plane.go:284`\n- `go-backend/internal/http/handler/control_plane.go:1306`\n- `go-backend/internal/http/handler/mutations.go:2646`\n\n新增测试:\n- `go-backend/internal/http/handler/control_plane_test.go:69`\n- `go-backend/internal/http/handler/control_plane_test.go:86`\n- `go-backend/internal/http/handler/dual_stack_test.go:33`\n- `go-backend/internal/http/handler/dual_stack_test.go:49`\n\n验证结果:\n- 已运行 `go test ./internal/http/handler/...`,全部通过。",
|
||
"time": {
|
||
"start": 1772506375423,
|
||
"end": 1772506375423
|
||
},
|
||
"id": "prt_cb19cde9b001EJDY1tkhelX7s8",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19cc667002SuwoWcS0lQUwvR"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "stop",
|
||
"snapshot": "3226f07afb4be2c356b6f96c2ce0fbaca73c36e1",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 63746,
|
||
"input": 63480,
|
||
"output": 266,
|
||
"reasoning": 0,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb19ce502001pkBKEh9zIJgFeL",
|
||
"sessionID": "ses_34e68d081ffeyr3uWQwm5venHp",
|
||
"messageID": "msg_cb19cc667002SuwoWcS0lQUwvR"
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|