mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-13 12:46:36 +08:00
1081 lines
167 KiB
JSON
1081 lines
167 KiB
JSON
{
|
|
"info": {
|
|
"id": "ses_35212171dffd9vra00U17BWVxP",
|
|
"slug": "quiet-eagle",
|
|
"projectID": "0d4685396cc353e0f47ffbe712b4e8d0545f5bad",
|
|
"directory": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
|
"parentID": "ses_3522e875effekgOPksTOkVAXDR",
|
|
"title": "Explore tunnel chain hop connection (@explore subagent)",
|
|
"version": "1.2.15",
|
|
"summary": {
|
|
"additions": 0,
|
|
"deletions": 0,
|
|
"files": 0
|
|
},
|
|
"permission": [
|
|
{
|
|
"permission": "todowrite",
|
|
"pattern": "*",
|
|
"action": "deny"
|
|
},
|
|
{
|
|
"permission": "todoread",
|
|
"pattern": "*",
|
|
"action": "deny"
|
|
},
|
|
{
|
|
"permission": "task",
|
|
"pattern": "*",
|
|
"action": "deny"
|
|
}
|
|
],
|
|
"time": {
|
|
"created": 1772444575970,
|
|
"updated": 1772444705157
|
|
}
|
|
},
|
|
"messages": [
|
|
{
|
|
"info": {
|
|
"role": "user",
|
|
"time": {
|
|
"created": 1772444575973
|
|
},
|
|
"summary": {
|
|
"diffs": []
|
|
},
|
|
"agent": "explore",
|
|
"model": {
|
|
"providerID": "zai-coding-plan",
|
|
"modelID": "glm-5"
|
|
},
|
|
"tools": {
|
|
"todowrite": false,
|
|
"todoread": false,
|
|
"task": false
|
|
},
|
|
"id": "msg_cadede8e5001rqRhy8g3MBaUkL",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP"
|
|
},
|
|
"parts": [
|
|
{
|
|
"type": "text",
|
|
"text": "探索隧道链路构建时下一跳连接地址的选择流程:\n\n1. 在 `control_plane.go` 中查找隧道链路构建逻辑\n2. 查找 `applyTunnelRuntime` 或类似函数如何向节点发送命令\n3. 理解 hop 节点如何连接到下一个节点(连接地址从哪里来)\n4. 查找 `ChainTunnel` 表的 `Port` 字段如何被使用\n\n返回:\n- 隧道链路配置发送到节点的代码位置\n- 下一跳连接地址是如何确定的\n- 修改连接地址需要改动的关键位置",
|
|
"id": "prt_cadede8e5003PL4eDpxOSWmL5G",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadede8e5001rqRhy8g3MBaUkL"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"info": {
|
|
"role": "assistant",
|
|
"time": {
|
|
"created": 1772444575977,
|
|
"completed": 1772444598876
|
|
},
|
|
"parentID": "msg_cadede8e5001rqRhy8g3MBaUkL",
|
|
"modelID": "glm-5",
|
|
"providerID": "zai-coding-plan",
|
|
"mode": "explore",
|
|
"agent": "explore",
|
|
"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": 6604,
|
|
"input": 152,
|
|
"output": 180,
|
|
"reasoning": 0,
|
|
"cache": {
|
|
"read": 6272,
|
|
"write": 0
|
|
}
|
|
},
|
|
"finish": "tool-calls",
|
|
"id": "msg_cadede8e9001Z5KxGEwlqWNYKc",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP"
|
|
},
|
|
"parts": [
|
|
{
|
|
"type": "step-start",
|
|
"snapshot": "73ca391522f6954b1825f9d1b6c4a29d9e2b8eb3",
|
|
"id": "prt_cadee369f001TYnZGAXinwxTN7",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadede8e9001Z5KxGEwlqWNYKc"
|
|
},
|
|
{
|
|
"type": "reasoning",
|
|
"text": "用户想要探索隧道链路构建时下一跳连接地址的选择流程。我需要:\n\n1. 在 `control_plane.go` 中查找隧道链路构建逻辑\n2. 查找 `applyTunnelRuntime` 或类似函数如何向节点发送命令\n3. 理解 hop 节点如何连接到下一个节点(连接地址从哪里来)\n4. 查找 `ChainTunnel` 表的 `Port` 字段如何被使用\n\n让我先找到 control_plane.go 文件,然后搜索相关的函数。",
|
|
"time": {
|
|
"start": 1772444595871,
|
|
"end": 1772444598787
|
|
},
|
|
"id": "prt_cadee369f002BA7BFu5MyIRY1v",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadede8e9001Z5KxGEwlqWNYKc"
|
|
},
|
|
{
|
|
"type": "text",
|
|
"text": "我来帮你探索隧道链路构建时下一跳连接地址的选择流程。让我先找到相关文件和代码。",
|
|
"time": {
|
|
"start": 1772444598787,
|
|
"end": 1772444598787
|
|
},
|
|
"id": "prt_cadee41f0001cP74wDgcjzc4Sx",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadede8e9001Z5KxGEwlqWNYKc"
|
|
},
|
|
{
|
|
"type": "tool",
|
|
"callID": "call_b7c5656de6674302b347bf58",
|
|
"tool": "glob",
|
|
"state": {
|
|
"status": "completed",
|
|
"input": {
|
|
"pattern": "**/control_plane.go"
|
|
},
|
|
"output": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go",
|
|
"title": "",
|
|
"metadata": {
|
|
"count": 1,
|
|
"truncated": false
|
|
},
|
|
"time": {
|
|
"start": 1772444598775,
|
|
"end": 1772444598813
|
|
}
|
|
},
|
|
"id": "prt_cadee41f4001Jah0v8mKhn6KbX",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadede8e9001Z5KxGEwlqWNYKc"
|
|
},
|
|
{
|
|
"type": "tool",
|
|
"callID": "call_8f9c2dba7962493d83925a35",
|
|
"tool": "grep",
|
|
"state": {
|
|
"status": "completed",
|
|
"input": {
|
|
"pattern": "applyTunnelRuntime"
|
|
},
|
|
"output": "Found 6 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/.entire/metadata/ses_3522e875effekgOPksTOkVAXDR/full.jsonl:\n Line 745: \"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>480: \\tlocalDomain := h.federationLocalDomain()\\n481: \\n482: \\ttx := h.repo.BeginTx()\\n483: \\tif tx.Error != nil {\\n484: \\t\\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\\n485: \\t\\treturn\\n486: \\t}\\n487: \\tdefer func() { tx.Rollback() }()\\n488: \\n489: \\truntimeState, err := h.prepareTunnelCreateState(tx, req, typeVal, 0)\\n490: \\tif err != nil {\\n491: \\t\\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\\n492: \\t\\treturn\\n493: \\t}\\n494: \\truntimeState.IPPreference = ipPreference\\n495: \\tif strings.TrimSpace(inIP) == \\\"\\\" {\\n496: \\t\\tinIP = buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes, ipPreference)\\n497: \\t}\\n498: \\n499: \\tif len(runtimeState.InNodes) > 0 {\\n500: \\t\\tfirstNodeID := runtimeState.InNodes[0].NodeID\\n501: \\t\\tisRemote, rUrl, rToken, _ := h.repo.GetNodeRemoteFieldsTx(tx, firstNodeID)\\n502: \\t\\tif isRemote == 1 {\\n503: \\t\\t\\tfc := client.NewFederationClient()\\n504: \\n505: \\t\\t\\ttargetProto := \\\"tcp\\\"\\n506: \\t\\t\\ttargetPort := 0\\n507: \\t\\t\\ttargetAddr := \\\"\\\"\\n508: \\n509: \\t\\t\\tif typeVal == 1 {\\n510: \\t\\t\\t\\tif len(runtimeState.OutNodes) > 0 {\\n511: \\t\\t\\t\\t\\toutNode := runtimeState.OutNodes[0]\\n512: \\t\\t\\t\\t\\toutNodeRec := runtimeState.Nodes[outNode.NodeID]\\n513: \\t\\t\\t\\t\\ttargetAddr = processServerAddress(outNodeRec.ServerIP)\\n514: \\t\\t\\t\\t\\tif outNode.Port > 0 {\\n515: \\t\\t\\t\\t\\t\\ttargetAddr = fmt.Sprintf(\\\"%s:%d\\\", targetAddr, outNode.Port)\\n516: \\t\\t\\t\\t\\t}\\n517: \\t\\t\\t\\t}\\n518: \\t\\t\\t\\tif len(runtimeState.InNodes) > 0 {\\n519: \\t\\t\\t\\t\\tinNodesRaw := asMapSlice(req[\\\"inNodeId\\\"])\\n520: \\t\\t\\t\\t\\tif len(inNodesRaw) > 0 {\\n521: \\t\\t\\t\\t\\t\\ttargetPort = asInt(inNodesRaw[0][\\\"port\\\"], 0)\\n522: \\t\\t\\t\\t\\t\\ttargetProto = defaultString(asString(inNodesRaw[0][\\\"protocol\\\"]), \\\"tcp\\\")\\n523: \\t\\t\\t\\t\\t}\\n524: \\t\\t\\t\\t}\\n525: \\...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go:\n Line 594: \t\tcreatedChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)\n Line 741: \t\tcreatedChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)\n Line 955: \t\t_, _, applyErr := h.applyTunnelRuntime(state)\n Line 2454: func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64, error) {\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/federation_runtime_test.go:\n Line 102: \tchains, services, err := h.applyTunnelRuntime(state)",
|
|
"title": "applyTunnelRuntime",
|
|
"metadata": {
|
|
"matches": 6,
|
|
"truncated": false
|
|
},
|
|
"time": {
|
|
"start": 1772444598781,
|
|
"end": 1772444598826
|
|
}
|
|
},
|
|
"id": "prt_cadee41f8001FH7yKJTKiUewVY",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadede8e9001Z5KxGEwlqWNYKc"
|
|
},
|
|
{
|
|
"type": "tool",
|
|
"callID": "call_5c6cca076095442d924e14bd",
|
|
"tool": "grep",
|
|
"state": {
|
|
"status": "completed",
|
|
"input": {
|
|
"pattern": "ChainTunnel",
|
|
"include": "*.go"
|
|
},
|
|
"output": "Found 43 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go:\n Line 511: \t\t\t\tChainTunnels []struct {\n Line 530: \t\t\tif len(tunnel.ChainTunnels) != 1 {\n Line 531: \t\t\t\tt.Fatalf(\"expected one chain tunnel for seeded tunnel %d, got %d\", tunnelID, len(tunnel.ChainTunnels))\n Line 533: \t\t\tif tunnel.ChainTunnels[0].Inx != 0 {\n Line 534: \t\t\t\tt.Fatalf(\"expected nullable chain inx to export as 0, got %d\", tunnel.ChainTunnels[0].Inx)\n Line 536: \t\t\tif tunnel.ChainTunnels[0].Strategy != \"\" {\n Line 537: \t\t\t\tt.Fatalf(\"expected nullable chain strategy to export as empty string, got %q\", tunnel.ChainTunnels[0].Strategy)\n Line 539: \t\t\tif tunnel.ChainTunnels[0].Protocol != \"\" {\n Line 540: \t\t\t\tt.Fatalf(\"expected nullable chain protocol to export as empty string, got %q\", tunnel.ChainTunnels[0].Protocol)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_mutations.go:\n Line 307: \t\tif err := tx.Where(\"node_id = ?\", nodeID).Delete(&model.ChainTunnel{}).Error; err != nil {\n Line 391: func (r *Repository) DeleteChainTunnelsByTunnelTx(tx *gorm.DB, tunnelID int64) error {\n Line 395: \treturn tx.Where(\"tunnel_id = ?\", tunnelID).Delete(&model.ChainTunnel{}).Error\n Line 398: func (r *Repository) CreateChainTunnelTx(tx *gorm.DB, tunnelID int64, chainType string, nodeID int64, port sql.NullInt64, strategy string, inx int, protocol string) error {\n Line 402: \tct := model.ChainTunnel{\n Line 459: \tchainQuery := tx.Model(&model.ChainTunnel{}).Where(\"node_id = ? AND port > 0\", nodeID)\n Line 525: \t\tif err := tx.Where(\"tunnel_id = ?\", tunnelID).Delete(&model.ChainTunnel{}).Error; err != nil {\n Line 540: \terr := r.db.Model(&model.ChainTunnel{}).\n Line 746: \tif err := r.db.Model(&model.ChainTunnel{}).Where(\"node_id = ? AND port > 0\", nodeID).Pluck(\"port\", &chainPorts).Error; err != nil {\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_federation.go:\n Line 152: \t\tct := model.ChainTunnel{\n Line 180: \terr := r.db.Model(&model.ChainTunnel{}).\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_control.go:\n Line 65: \terr := r.db.Model(&model.ChainTunnel{}).\n Line 114: \tvar ct model.ChainTunnel\n Line 294: \terr := r.db.Model(&model.ChainTunnel{}).\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go:\n Line 40: type ChainTunnelBackup = model.ChainTunnelBackup\n Line 170: \t\t&model.ChainTunnel{},\n Line 834: \tvar chains []model.ChainTunnel\n Line 1718: \t\tchains, err := r.exportChainTunnels(t.ID)\n Line 1722: \t\tb.ChainTunnels = chains\n Line 1728: func (r *Repository) exportChainTunnels(tunnelID int64) ([]model.ChainTunnelBackup, error) {\n Line 1729: \tvar chains []model.ChainTunnel\n Line 1733: \tout := make([]model.ChainTunnelBackup, 0, len(chains))\n Line 1735: \t\tb := model.ChainTunnelBackup{\n Line 2085: \t\tfor _, ct := range t.ChainTunnels {\n Line 2086: \t\t\tchainItem := model.ChainTunnel{\n Line 2515: \tif err := normalizeStrategy(&model.ChainTunnel{}, \"chain_tunnel\", \"round\"); err != nil {\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go:\n Line 127: type ChainTunnel struct {\n Line 138: func (ChainTunnel) TableName() string { return \"chain_tunnel\" }\n Line 371: \tChainTunnels []ChainTunnelBackup `json:\"chainTunnels,omitempty\"`\n Line 374: type ChainTunnelBackup struct {\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go:\n Line 720: \tif err := h.repo.DeleteChainTunnelsByTunnelTx(tx, id); err != nil {\n Line 2783: \t\tif err := h.repo.CreateChainTunnelTx(\n Line 2809: \t\tif err := h.repo.CreateChainTunnelTx(\n Line 2837: \t\t\tif err := h.repo.CreateChainTunnelTx(",
|
|
"title": "ChainTunnel",
|
|
"metadata": {
|
|
"matches": 43,
|
|
"truncated": false
|
|
},
|
|
"time": {
|
|
"start": 1772444598785,
|
|
"end": 1772444598825
|
|
}
|
|
},
|
|
"id": "prt_cadee41fe001p8gvKt2PrLFgFB",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadede8e9001Z5KxGEwlqWNYKc"
|
|
},
|
|
{
|
|
"type": "step-finish",
|
|
"reason": "tool-calls",
|
|
"snapshot": "73ca391522f6954b1825f9d1b6c4a29d9e2b8eb3",
|
|
"cost": 0,
|
|
"tokens": {
|
|
"total": 6604,
|
|
"input": 152,
|
|
"output": 180,
|
|
"reasoning": 0,
|
|
"cache": {
|
|
"read": 6272,
|
|
"write": 0
|
|
}
|
|
},
|
|
"id": "prt_cadee422a0014Oxo25VlMgBY2d",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadede8e9001Z5KxGEwlqWNYKc"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"info": {
|
|
"role": "assistant",
|
|
"time": {
|
|
"created": 1772444598876,
|
|
"completed": 1772444615289
|
|
},
|
|
"parentID": "msg_cadede8e5001rqRhy8g3MBaUkL",
|
|
"modelID": "glm-5",
|
|
"providerID": "zai-coding-plan",
|
|
"mode": "explore",
|
|
"agent": "explore",
|
|
"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": 9375,
|
|
"input": 2714,
|
|
"output": 261,
|
|
"reasoning": 0,
|
|
"cache": {
|
|
"read": 6400,
|
|
"write": 0
|
|
}
|
|
},
|
|
"finish": "tool-calls",
|
|
"id": "msg_cadee425c001OJkgzM0lAvkQsk",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP"
|
|
},
|
|
"parts": [
|
|
{
|
|
"type": "step-start",
|
|
"snapshot": "73ca391522f6954b1825f9d1b6c4a29d9e2b8eb3",
|
|
"id": "prt_cadee8243001d6KlkNktgF77TO",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadee425c001OJkgzM0lAvkQsk"
|
|
},
|
|
{
|
|
"type": "reasoning",
|
|
"text": "现在我需要查看关键文件:\n1. control_plane.go - 隧道链路构建逻辑\n2. mutations.go 中的 applyTunnelRuntime 函数\n3. ChainTunnel 模型定义\n\n让我先读取这些文件。",
|
|
"time": {
|
|
"start": 1772444615235,
|
|
"end": 1772444615238
|
|
},
|
|
"id": "prt_cadee8243002Tu2UCKEKUne3O6",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadee425c001OJkgzM0lAvkQsk"
|
|
},
|
|
{
|
|
"type": "text",
|
|
"text": "让我读取关键文件来理解隧道链路构建流程:",
|
|
"time": {
|
|
"start": 1772444615238,
|
|
"end": 1772444615238
|
|
},
|
|
"id": "prt_cadee82440014WUfRqEqsJ9Od5",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadee425c001OJkgzM0lAvkQsk"
|
|
},
|
|
{
|
|
"type": "tool",
|
|
"callID": "call_8287c3f5663042d0aa881e7d",
|
|
"tool": "read",
|
|
"state": {
|
|
"status": "completed",
|
|
"input": {
|
|
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go"
|
|
},
|
|
"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: \n61: func (h *Handler) buildDiagnosisStreamStartItems(workItems []diagnosisWorkItem) []map[string]interface{} {\n62: \tif len(workItems) == 0 {\n63: \t\treturn []map[string]interface{}{}\n64: \t}\n65: \n66: \tnodeCache := map[int64]*nodeRecord{}\n67: \titems := make([]map[string]interface{}, 0, len(workItems))\n68: \tfor _, workItem := range workItems {\n69: \t\ttargetIP := strings.TrimSpace(workItem.targetIP)\n70: \t\ttargetPort := workItem.targetPort\n71: \t\tif workItem.hasChainHop {\n72: \t\t\tfromNode, _ := h.cachedNode(nodeCache, workItem.fromNodeID)\n73: \t\t\ttargetNode, err := h.cachedNode(nodeCache, workItem.toNode.NodeID)\n74: \t\t\tif err == nil {\n75: \t\t\t\tresolvedIP, resolvedPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, workItem.toNode.Port, workItem.ipPreference)\n76: \t\t\t\tif resolveErr == nil {\n77: \t\t\t\t\ttargetIP = resolvedIP\n78: \t\t\t\t\ttargetPort = resolvedPort\n79: \t\t\t\t}\n80: \t\t\t}\n81: \t\t}\n82: \t\tif targetPort <= 0 {\n83: \t\t\ttargetPort = 443\n84: \t\t}\n85: \n86: \t\tnodeName := fmt.Sprintf(\"node_%d\", workItem.fromNodeID)\n87: \t\tif node, err := h.cachedNode(nodeCache, workItem.fromNodeID); err == nil && strings.TrimSpace(node.Name) != \"\" {\n88: \t\t\tnodeName = node.Name\n89: \t\t}\n90: \n91: \t\titem := map[string]interface{}{\n92: \t\t\t\"success\": false,\n93: \t\t\t\"diagnosing\": true,\n94: \t\t\t\"description\": workItem.description,\n95: \t\t\t\"nodeName\": nodeName,\n96: \t\t\t\"nodeId\": strconv.FormatInt(workItem.fromNodeID, 10),\n97: \t\t\t\"targetIp\": targetIP,\n98: \t\t\t\"targetPort\": targetPort,\n99: \t\t\t\"message\": \"诊断中...\",\n100: \t\t}\n101: \t\tfor key, value := range workItem.metadata {\n102: \t\t\titem[key] = value\n103: \t\t}\n104: \t\titems = append(items, item)\n105: \t}\n106: \n107: \treturn items\n108: }\n109: \n110: const diagnosisMaxConcurrency = 8\n111: \n112: const (\n113: \tdefaultNodeCommandTimeout = 6 * time.Second\n114: \tdiagnosisCommandTimeout = 30 * time.Second\n115: \tdiagnosisRequestTimeout = 2 * time.Minute\n116: \tdiagnosisCommandTimeoutMsg = \"诊断超时(30秒)\"\n117: \tdiagnosisRequestTimeoutMsg = \"诊断超时(2分钟)\"\n118: )\n119: \n120: func (h *Handler) resolveForwardAccess(r *http.Request, forwardID int64) (*forwardRecord, int64, int, error) {\n121: \tuserID, roleID, err := userRoleFromRequest(r)\n122: \tif err != nil {\n123: \t\treturn nil, 0, 0, err\n124: \t}\n125: \tforward, err := h.ensureForwardAccessByActor(userID, roleID, forwardID)\n126: \tif err != nil {\n127: \t\treturn nil, userID, roleID, err\n128: \t}\n129: \treturn forward, userID, roleID, nil\n130: }\n131: \n132: func (h *Handler) ensureForwardAccessByActor(actorUserID int64, actorRole int, forwardID int64) (*forwardRecord, error) {\n133: \tforward, err := h.getForwardRecord(forwardID)\n134: \tif err != nil {\n135: \t\treturn nil, err\n136: \t}\n137: \tif actorRole != 0 && forward.UserID != actorUserID {\n138: \t\treturn nil, errForwardNotFound\n139: \t}\n140: \treturn forward, nil\n141: }\n142: \n143: func (h *Handler) ensureTunnelPermission(userID int64, roleID int, tunnelID int64) error {\n144: \tif roleID == 0 {\n145: \t\treturn nil\n146: \t}\n147: \tok, err := h.repo.UserTunnelExistsByUserAndTunnel(userID, tunnelID)\n148: \tif err != nil {\n149: \t\treturn err\n150: \t}\n151: \tif !ok {\n152: \t\treturn errors.New(\"你没有该隧道的权限\")\n153: \t}\n154: \treturn nil\n155: }\n156: \n157: func (h *Handler) getForwardRecord(forwardID int64) (*forwardRecord, error) {\n158: \tfr, err := h.repo.GetForwardRecord(forwardID)\n159: \tif err != nil {\n160: \t\treturn nil, err\n161: \t}\n162: \tif fr == nil {\n163: \t\treturn nil, errForwardNotFound\n164: \t}\n165: \treturn fr, nil\n166: }\n167: \n168: func (h *Handler) getTunnelRecord(tunnelID int64) (*tunnelRecord, error) {\n169: \ttr, err := h.repo.GetTunnelRecord(tunnelID)\n170: \tif err != nil {\n171: \t\treturn nil, err\n172: \t}\n173: \tif tr == nil {\n174: \t\treturn nil, errors.New(\"隧道不存在\")\n175: \t}\n176: \treturn tr, nil\n177: }\n178: \n179: func (h *Handler) listForwardsByTunnel(tunnelID int64) ([]forwardRecord, error) {\n180: \treturn h.repo.ListForwardsByTunnel(tunnelID)\n181: }\n182: \n183: func (h *Handler) listForwardPorts(forwardID int64) ([]forwardPortRecord, error) {\n184: \treturn h.repo.ListForwardPorts(forwardID)\n185: }\n186: \n187: func (h *Handler) isTunnelSelectedTLSProtocol(tunnelID int64) (bool, error) {\n188: \tprotocol, err := h.repo.GetTunnelOutProtocol(tunnelID)\n189: \tif err != nil {\n190: \t\treturn false, err\n191: \t}\n192: \treturn isTLSTunnelProtocol(protocol), nil\n193: }\n194: \n195: func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {\n196: \tn, err := h.repo.GetNodeRecord(nodeID)\n197: \tif err != nil {\n198: \t\treturn nil, err\n199: \t}\n200: \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\n340: \t\t\tfor _, name := range variants {\n341: \t\t\t\tpayload := map[string]interface{}{\n342: \t\t\t\t\t\"services\": []string{name},\n343: \t\t\t\t}\n344: \t\t\t\t_, err := h.sendNodeCommand(fp.NodeID, commandType, payload, false, false)\n345: \t\t\t\tif err == nil {\n346: \t\t\t\t\tcandidateHandled = true\n347: \t\t\t\t\tcontinue\n348: \t\t\t\t}\n349: \t\t\t\tif !isNotFoundError(err) {\n350: \t\t\t\t\treturn err\n351: \t\t\t\t}\n352: \t\t\t\tlastNotFoundErr = err\n353: \t\t\t}\n354: \n355: \t\t\tif candidateHandled {\n356: \t\t\t\tnodeHandled = true\n357: \t\t\t\tbreak\n358: \t\t\t}\n359: \t\t}\n360: \n361: \t\tif nodeHandled {\n362: \t\t\tcontinue\n363: \t\t}\n364: \t\tif tolerateNotFound {\n365: \t\t\tcontinue\n366: \t\t}\n367: \t\tif lastNotFoundErr != nil {\n368: \t\t\treturn lastNotFoundErr\n369: \t\t}\n370: \t\treturn errors.New(\"service control failed\")\n371: \t}\n372: \treturn nil\n373: }\n374: \n375: func (h *Handler) applyNodeProtocolChange(nodeID int64, httpVal, tlsVal, socksVal int) error {\n376: \t_, err := h.sendNodeCommand(nodeID, \"SetProtocol\", map[string]interface{}{\n377: \t\t\"http\": httpVal,\n378: \t\t\"tls\": tlsVal,\n379: \t\t\"socks\": socksVal,\n380: \t}, false, false)\n381: \treturn err\n382: }\n383: \n384: func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interface{}, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {\n385: \treturn h.sendNodeCommandWithTimeout(nodeID, commandType, data, defaultNodeCommandTimeout, tolerateExists, tolerateNotFound)\n386: }\n387: \n388: func (h *Handler) sendNodeCommandWithTimeout(nodeID int64, commandType string, data interface{}, timeout time.Duration, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {\n389: \tvar (\n390: \t\tresult ws.CommandResult\n391: \t\terr error\n392: \t)\n393: \tif timeout <= 0 {\n394: \t\ttimeout = defaultNodeCommandTimeout\n395: \t}\n396: \n397: \tnode, nodeErr := h.getNodeRecord(nodeID)\n398: \tif nodeErr == nil && node != nil && node.IsRemote == 1 {\n399: \t\tresult, err = h.sendRemoteNodeCommandWithTimeout(node, commandType, data, timeout)\n400: \t} else {\n401: \t\tresult, err = h.wsServer.SendCommand(nodeID, commandType, data, timeout)\n402: \t}\n403: \tif err == nil {\n404: \t\treturn result, nil\n405: \t}\n406: \tmsg := strings.ToLower(strings.TrimSpace(err.Error()))\n407: \tif tolerateExists {\n408: \t\tif isAlreadyExistsMessage(msg) {\n409: \t\t\treturn result, nil\n410: \t\t}\n411: \t}\n412: \tif tolerateNotFound {\n413: \t\tif strings.Contains(msg, \"not found\") || strings.Contains(msg, \"不存在\") {\n414: \t\t\treturn result, nil\n415: \t\t}\n416: \t}\n417: \treturn result, err\n418: }\n419: \n420: func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, data interface{}) (ws.CommandResult, error) {\n421: \treturn h.sendRemoteNodeCommandWithTimeout(node, commandType, data, 0)\n422: }\n423: \n424: func (h *Handler) sendRemoteNodeCommandWithTimeout(node *nodeRecord, commandType string, data interface{}, timeout time.Duration) (ws.CommandResult, error) {\n425: \tif node == nil {\n426: \t\treturn ws.CommandResult{}, errors.New(\"节点不存在\")\n427: \t}\n428: \tremoteURL := strings.TrimSpace(node.RemoteURL)\n429: \tremoteToken := strings.TrimSpace(node.RemoteToken)\n430: \tif remoteURL == \"\" || remoteToken == \"\" {\n431: \t\treturn ws.CommandResult{}, errors.New(\"远程节点缺少共享配置\")\n432: \t}\n433: \n434: \tfc := client.NewFederationClient()\n435: \tif timeout > 0 {\n436: \t\tfc = client.NewFederationClientWithTimeout(timeout)\n437: \t}\n438: \tres, err := fc.Command(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeNodeCommandRequest{\n439: \t\tCommandType: commandType,\n440: \t\tData: data,\n441: \t})\n442: \tif err != nil {\n443: \t\treturn ws.CommandResult{}, err\n444: \t}\n445: \tif res == nil {\n446: \t\treturn ws.CommandResult{}, errors.New(\"远程节点未返回命令结果\")\n447: \t}\n448: \n449: \tresult := ws.CommandResult{\n450: \t\tType: res.Type,\n451: \t\tSuccess: res.Success,\n452: \t\tMessage: res.Message,\n453: \t\tData: res.Data,\n454: \t}\n455: \tif !result.Success {\n456: \t\tmsg := strings.TrimSpace(result.Message)\n457: \t\tif msg == \"\" {\n458: \t\t\tmsg = \"命令执行失败\"\n459: \t\t}\n460: \t\treturn result, errors.New(msg)\n461: \t}\n462: \treturn result, nil\n463: }\n464: \n465: func (h *Handler) diagnoseForwardRuntime(ctx context.Context, forward *forwardRecord) (map[string]interface{}, error) {\n466: \tif ctx == nil {\n467: \t\tctx = context.Background()\n468: \t}\n469: \tforwardName, workItems, err := h.prepareForwardDiagnosis(forward)\n470: \tif err != nil {\n471: \t\treturn nil, err\n472: \t}\n473: \n474: \tresults := h.runDiagnosisWorkItems(ctx, workItems, nil)\n475: \n476: \tpayload := map[string]interface{}{\n477: \t\t\"forwardName\": forwardName,\n478: \t\t\"timestamp\": time.Now().UnixMilli(),\n479: \t\t\"results\": results,\n480: \t}\n481: \treturn payload, nil\n482: }\n483: \n484: func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []diagnosisWorkItem, error) {\n485: \tif forward == nil {\n486: \t\treturn \"\", nil, errForwardNotFound\n487: \t}\n488: \ttargets, err := resolveDiagnosisTargets(forward.RemoteAddr)\n489: \tif err != nil {\n490: \t\treturn \"\", nil, err\n491: \t}\n492: \n493: \ttunnel, err := h.getTunnelRecord(forward.TunnelID)\n494: \tif err != nil {\n495: \t\treturn \"\", nil, err\n496: \t}\n497: \n498: \tchainRows, err := h.listChainNodesForTunnel(forward.TunnelID)\n499: \tif err != nil {\n500: \t\treturn \"\", nil, err\n501: \t}\n502: \tif len(chainRows) == 0 {\n503: \t\treturn \"\", nil, errors.New(\"隧道配置不完整\")\n504: \t}\n505: \n506: \tipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)\n507: \n508: \tinNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)\n509: \tworkItems := make([]diagnosisWorkItem, 0, len(chainRows)*2+len(targets))\n510: \n511: \tswitch tunnel.Type {\n512: \tcase 1:\n513: \t\tfor _, inNode := range inNodes {\n514: \t\t\tfor _, target := range targets {\n515: \t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->目标(%s)\", inNode.NodeName, target.Address)\n516: \t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n517: \t\t\t\t\tfromNodeID: inNode.NodeID,\n518: \t\t\t\t\ttargetIP: target.IP,\n519: \t\t\t\t\ttargetPort: target.Port,\n520: \t\t\t\t\tdescription: description,\n521: \t\t\t\t\tmetadata: map[string]interface{}{\n522: \t\t\t\t\t\t\"fromChainType\": 1,\n523: \t\t\t\t\t},\n524: \t\t\t\t})\n525: \t\t\t}\n526: \t\t}\n527: \tcase 2:\n528: \t\tfor _, inNode := range inNodes {\n529: \t\t\tif len(chainHops) > 0 {\n530: \t\t\t\tfor _, firstNode := range chainHops[0] {\n531: \t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->第1跳(%s)\", inNode.NodeName, firstNode.NodeName)\n532: \t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n533: \t\t\t\t\t\tfromNodeID: inNode.NodeID,\n534: \t\t\t\t\t\ttoNode: firstNode,\n535: \t\t\t\t\t\thasChainHop: true,\n536: \t\t\t\t\t\tipPreference: ipPreference,\n537: \t\t\t\t\t\tdescription: description,\n538: \t\t\t\t\t\tmetadata: map[string]interface{}{\n539: \t\t\t\t\t\t\t\"fromChainType\": 1,\n540: \t\t\t\t\t\t\t\"toChainType\": 2,\n541: \t\t\t\t\t\t\t\"toInx\": firstNode.Inx,\n542: \t\t\t\t\t\t},\n543: \t\t\t\t\t})\n544: \t\t\t\t}\n545: \t\t\t} else {\n546: \t\t\t\tfor _, outNode := range outNodes {\n547: \t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->出口(%s)\", inNode.NodeName, outNode.NodeName)\n548: \t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n549: \t\t\t\t\t\tfromNodeID: inNode.NodeID,\n550: \t\t\t\t\t\ttoNode: outNode,\n551: \t\t\t\t\t\thasChainHop: true,\n552: \t\t\t\t\t\tipPreference: ipPreference,\n553: \t\t\t\t\t\tdescription: description,\n554: \t\t\t\t\t\tmetadata: map[string]interface{}{\n555: \t\t\t\t\t\t\t\"fromChainType\": 1,\n556: \t\t\t\t\t\t\t\"toChainType\": 3,\n557: \t\t\t\t\t\t},\n558: \t\t\t\t\t})\n559: \t\t\t\t}\n560: \t\t\t}\n561: \t\t}\n562: \n563: \t\tfor i, hop := range chainHops {\n564: \t\t\tfor _, currentNode := range hop {\n565: \t\t\t\tif i+1 < len(chainHops) {\n566: \t\t\t\t\tfor _, nextNode := range chainHops[i+1] {\n567: \t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->第%d跳(%s)\", i+1, currentNode.NodeName, i+2, nextNode.NodeName)\n568: \t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n569: \t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n570: \t\t\t\t\t\t\ttoNode: nextNode,\n571: \t\t\t\t\t\t\thasChainHop: true,\n572: \t\t\t\t\t\t\tipPreference: ipPreference,\n573: \t\t\t\t\t\t\tdescription: description,\n574: \t\t\t\t\t\t\tmetadata: map[string]interface{}{\n575: \t\t\t\t\t\t\t\t\"fromChainType\": 2,\n576: \t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n577: \t\t\t\t\t\t\t\t\"toChainType\": 2,\n578: \t\t\t\t\t\t\t\t\"toInx\": nextNode.Inx,\n579: \t\t\t\t\t\t\t},\n580: \t\t\t\t\t\t})\n581: \t\t\t\t\t}\n582: \t\t\t\t} else {\n583: \t\t\t\t\tfor _, outNode := range outNodes {\n584: \t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->出口(%s)\", i+1, currentNode.NodeName, outNode.NodeName)\n585: \t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n586: \t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n587: \t\t\t\t\t\t\ttoNode: outNode,\n588: \t\t\t\t\t\t\thasChainHop: true,\n589: \t\t\t\t\t\t\tipPreference: ipPreference,\n590: \t\t\t\t\t\t\tdescription: description,\n591: \t\t\t\t\t\t\tmetadata: map[string]interface{}{\n592: \t\t\t\t\t\t\t\t\"fromChainType\": 2,\n593: \t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n594: \t\t\t\t\t\t\t\t\"toChainType\": 3,\n595: \t\t\t\t\t\t\t},\n596: \t\t\t\t\t\t})\n597: \t\t\t\t\t}\n598: \t\t\t\t}\n599: \t\t\t}\n600: \t\t}\n601: \n602: \t\tfor _, outNode := range outNodes {\n603: \t\t\tfor _, target := range targets {\n604: \t\t\t\tdescription := fmt.Sprintf(\"出口(%s)->目标(%s)\", outNode.NodeName, target.Address)\n605: \t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n606: \t\t\t\t\tfromNodeID: outNode.NodeID,\n607: \t\t\t\t\ttargetIP: target.IP,\n608: \t\t\t\t\ttargetPort: target.Port,\n609: \t\t\t\t\tdescription: description,\n610: \t\t\t\t\tmetadata: map[string]interface{}{\n611: \t\t\t\t\t\t\"fromChainType\": 3,\n612: \t\t\t\t\t},\n613: \t\t\t\t})\n614: \t\t\t}\n615: \t\t}\n616: \tdefault:\n617: \t\tfor _, inNode := range inNodes {\n618: \t\t\tfor _, target := range targets {\n619: \t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->目标(%s)\", inNode.NodeName, target.Address)\n620: \t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n621: \t\t\t\t\tfromNodeID: inNode.NodeID,\n622: \t\t\t\t\ttargetIP: target.IP,\n623: \t\t\t\t\ttargetPort: target.Port,\n624: \t\t\t\t\tdescription: description,\n625: \t\t\t\t\tmetadata: map[string]interface{}{\n626: \t\t\t\t\t\t\"fromChainType\": 1,\n627: \t\t\t\t\t},\n628: \t\t\t\t})\n629: \t\t\t}\n630: \t\t}\n631: \t}\n632: \n633: \treturn forward.Name, workItems, nil\n634: }\n635: \n636: func (h *Handler) diagnoseTunnelRuntime(ctx context.Context, tunnelID int64) (map[string]interface{}, error) {\n637: \tif ctx == nil {\n638: \t\tctx = context.Background()\n639: \t}\n640: \ttunnelName, tunnelType, workItems, err := h.prepareTunnelDiagnosis(tunnelID)\n641: \tif err != nil {\n642: \t\treturn nil, err\n643: \t}\n644: \n645: \tresults := h.runDiagnosisWorkItems(ctx, workItems, nil)\n646: \n647: \tpayload := map[string]interface{}{\n648: \t\t\"tunnelName\": tunnelName,\n649: \t\t\"tunnelType\": tunnelType,\n650: \t\t\"timestamp\": time.Now().UnixMilli(),\n651: \t\t\"results\": results,\n652: \t}\n653: \treturn payload, nil\n654: }\n655: \n656: func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diagnosisWorkItem, error) {\n657: \ttunnel, err := h.getTunnelRecord(tunnelID)\n658: \tif err != nil {\n659: \t\treturn \"\", \"\", nil, err\n660: \t}\n661: \n662: \ttunnelName, err := h.repo.GetTunnelName(tunnelID)\n663: \tif err != nil {\n664: \t\treturn \"\", \"\", nil, err\n665: \t}\n666: \tif tunnelName == \"\" {\n667: \t\treturn \"\", \"\", nil, errors.New(\"隧道不存在\")\n668: \t}\n669: \n670: \tchainRows, err := h.listChainNodesForTunnel(tunnelID)\n671: \tif err != nil {\n672: \t\treturn \"\", \"\", nil, err\n673: \t}\n674: \tif len(chainRows) == 0 {\n675: \t\treturn \"\", \"\", nil, errors.New(\"隧道配置不完整\")\n676: \t}\n677: \n678: \tipPreference := h.repo.GetTunnelIPPreference(tunnelID)\n679: \tinNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)\n680: \tworkItems := make([]diagnosisWorkItem, 0, len(chainRows)*2)\n681: \n682: \tswitch tunnel.Type {\n683: \tcase 1:\n684: \t\tfor _, inNode := range inNodes {\n685: \t\t\tdescription := fmt.Sprintf(\"入口(%s)->外网\", inNode.NodeName)\n686: \t\t\tworkItems = append(workItems, diagnosisWorkItem{\n687: \t\t\t\tfromNodeID: inNode.NodeID,\n688: \t\t\t\ttargetIP: \"www.bing.com\",\n689: \t\t\t\ttargetPort: 443,\n690: \t\t\t\tdescription: description,\n691: \t\t\t\tmetadata: map[string]interface{}{\n692: \t\t\t\t\t\"fromChainType\": 1,\n693: \t\t\t\t},\n694: \t\t\t})\n695: \t\t}\n696: \tcase 2:\n697: \t\tfor _, inNode := range inNodes {\n698: \t\t\tif len(chainHops) > 0 {\n699: \t\t\t\tfor _, firstNode := range chainHops[0] {\n700: \t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->第1跳(%s)\", inNode.NodeName, firstNode.NodeName)\n701: \t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n702: \t\t\t\t\t\tfromNodeID: inNode.NodeID,\n703: \t\t\t\t\t\ttoNode: firstNode,\n704: \t\t\t\t\t\thasChainHop: true,\n705: \t\t\t\t\t\tipPreference: ipPreference,\n706: \t\t\t\t\t\tdescription: description,\n707: \t\t\t\t\t\tmetadata: map[string]interface{}{\n708: \t\t\t\t\t\t\t\"fromChainType\": 1,\n709: \t\t\t\t\t\t\t\"toChainType\": 2,\n710: \t\t\t\t\t\t\t\"toInx\": firstNode.Inx,\n711: \t\t\t\t\t\t},\n712: \t\t\t\t\t})\n713: \t\t\t\t}\n714: \t\t\t} else {\n715: \t\t\t\tfor _, outNode := range outNodes {\n716: \t\t\t\t\tdescription := fmt.Sprintf(\"入口(%s)->出口(%s)\", inNode.NodeName, outNode.NodeName)\n717: \t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n718: \t\t\t\t\t\tfromNodeID: inNode.NodeID,\n719: \t\t\t\t\t\ttoNode: outNode,\n720: \t\t\t\t\t\thasChainHop: true,\n721: \t\t\t\t\t\tipPreference: ipPreference,\n722: \t\t\t\t\t\tdescription: description,\n723: \t\t\t\t\t\tmetadata: map[string]interface{}{\n724: \t\t\t\t\t\t\t\"fromChainType\": 1,\n725: \t\t\t\t\t\t\t\"toChainType\": 3,\n726: \t\t\t\t\t\t},\n727: \t\t\t\t\t})\n728: \t\t\t\t}\n729: \t\t\t}\n730: \t\t}\n731: \n732: \t\tfor i, hop := range chainHops {\n733: \t\t\tfor _, currentNode := range hop {\n734: \t\t\t\tif i+1 < len(chainHops) {\n735: \t\t\t\t\tfor _, nextNode := range chainHops[i+1] {\n736: \t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->第%d跳(%s)\", i+1, currentNode.NodeName, i+2, nextNode.NodeName)\n737: \t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n738: \t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n739: \t\t\t\t\t\t\ttoNode: nextNode,\n740: \t\t\t\t\t\t\thasChainHop: true,\n741: \t\t\t\t\t\t\tipPreference: ipPreference,\n742: \t\t\t\t\t\t\tdescription: description,\n743: \t\t\t\t\t\t\tmetadata: map[string]interface{}{\n744: \t\t\t\t\t\t\t\t\"fromChainType\": 2,\n745: \t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n746: \t\t\t\t\t\t\t\t\"toChainType\": 2,\n747: \t\t\t\t\t\t\t\t\"toInx\": nextNode.Inx,\n748: \t\t\t\t\t\t\t},\n749: \t\t\t\t\t\t})\n750: \t\t\t\t\t}\n751: \t\t\t\t} else {\n752: \t\t\t\t\tfor _, outNode := range outNodes {\n753: \t\t\t\t\t\tdescription := fmt.Sprintf(\"第%d跳(%s)->出口(%s)\", i+1, currentNode.NodeName, outNode.NodeName)\n754: \t\t\t\t\t\tworkItems = append(workItems, diagnosisWorkItem{\n755: \t\t\t\t\t\t\tfromNodeID: currentNode.NodeID,\n756: \t\t\t\t\t\t\ttoNode: outNode,\n757: \t\t\t\t\t\t\thasChainHop: true,\n758: \t\t\t\t\t\t\tipPreference: ipPreference,\n759: \t\t\t\t\t\t\tdescription: description,\n760: \t\t\t\t\t\t\tmetadata: map[string]interface{}{\n761: \t\t\t\t\t\t\t\t\"fromChainType\": 2,\n762: \t\t\t\t\t\t\t\t\"fromInx\": currentNode.Inx,\n763: \t\t\t\t\t\t\t\t\"toChainType\": 3,\n764: \t\t\t\t\t\t\t},\n765: \t\t\t\t\t\t})\n766: \t\t\t\t\t}\n767: \t\t\t\t}\n768: \t\t\t}\n769: \t\t}\n770: \n771: \t\tfor _, outNode := range outNodes {\n772: \t\t\tdescription := fmt.Sprintf(\"出口(%s)->外网\", outNode.NodeName)\n773: \t\t\tworkItems = append(workItems, diagnosisWorkItem{\n774: \t\t\t\tfromNodeID: outNode.NodeID,\n775: \t\t\t\ttargetIP: \"www.bing.com\",\n776: \t\t\t\ttargetPort: 443,\n777: \t\t\t\tdescription: description,\n778: \t\t\t\tmetadata: map[string]interface{}{\n779: \t\t\t\t\t\"fromChainType\": 3,\n780: \t\t\t\t},\n781: \t\t\t})\n782: \t\t}\n783: \tdefault:\n784: \t\tfor _, inNode := range inNodes {\n785: \t\t\tdescription := fmt.Sprintf(\"入口(%s)->外网\", inNode.NodeName)\n786: \t\t\tworkItems = append(workItems, diagnosisWorkItem{\n787: \t\t\t\tfromNodeID: inNode.NodeID,\n788: \t\t\t\ttargetIP: \"www.bing.com\",\n789: \t\t\t\ttargetPort: 443,\n790: \t\t\t\tdescription: description,\n791: \t\t\t\tmetadata: map[string]interface{}{\n792: \t\t\t\t\t\"fromChainType\": 1,\n793: \t\t\t\t},\n794: \t\t\t})\n795: \t\t}\n796: \t}\n797: \n798: \ttunnelType := map[bool]string{true: \"端口转发\", false: \"隧道转发\"}[tunnel.Type == 1]\n799: \treturn tunnelName, tunnelType, workItems, nil\n800: }\n801: \n802: func splitChainNodeGroups(rows []chainNodeRecord) ([]chainNodeRecord, [][]chainNodeRecord, []chainNodeRecord) {\n803: \tinNodes := make([]chainNodeRecord, 0)\n804: \toutNodes := make([]chainNodeRecord, 0)\n805: \tchainByInx := map[int64][]chainNodeRecord{}\n806: \thopOrder := make([]int64, 0)\n807: \n808: \tfor _, row := range rows {\n809: \t\tswitch row.ChainType {\n810: \t\tcase 1:\n811: \t\t\tinNodes = append(inNodes, row)\n812: \t\tcase 2:\n813: \t\t\tif _, ok := chainByInx[row.Inx]; !ok {\n814: \t\t\t\thopOrder = append(hopOrder, row.Inx)\n815: \t\t\t}\n816: \t\t\tchainByInx[row.Inx] = append(chainByInx[row.Inx], row)\n817: \t\tcase 3:\n818: \t\t\toutNodes = append(outNodes, row)\n819: \t\t}\n820: \t}\n821: \n822: \tsort.Slice(hopOrder, func(i, j int) bool { return hopOrder[i] < hopOrder[j] })\n823: \tchainHops := make([][]chainNodeRecord, 0, len(hopOrder))\n824: \tfor _, inx := range hopOrder {\n825: \t\tchainHops = append(chainHops, chainByInx[inx])\n826: \t}\n827: \n828: \treturn inNodes, chainHops, outNodes\n829: }\n830: \n831: func resolveDiagnosisTargets(remoteAddr string) ([]diagnosisTarget, error) {\n832: \trawTargets := splitRemoteTargets(remoteAddr)\n833: \tif len(rawTargets) == 0 {\n834: \t\treturn nil, errors.New(\"目标地址不能为空\")\n835: \t}\n836: \n837: \ttargets := make([]diagnosisTarget, 0, len(rawTargets))\n838: \tfor _, raw := range rawTargets {\n839: \t\tip, port, err := parseTargetAddress(raw)\n840: \t\tif err != nil {\n841: \t\t\tcontinue\n842: \t\t}\n843: \t\ttargets = append(targets, diagnosisTarget{Address: raw, IP: ip, Port: port})\n844: \t}\n845: \tif len(targets) == 0 {\n846: \t\treturn nil, errors.New(\"目标地址格式错误\")\n847: \t}\n848: \treturn targets, nil\n849: }\n850: \n851: func diagnosisContextMessage(ctx context.Context) string {\n852: \tif ctx == nil {\n853: \t\treturn diagnosisRequestTimeoutMsg\n854: \t}\n855: \tswitch ctx.Err() {\n856: \tcase context.DeadlineExceeded:\n857: \t\treturn diagnosisRequestTimeoutMsg\n858: \tcase context.Canceled:\n859: \t\treturn \"诊断已取消\"\n860: \tdefault:\n861: \t\treturn diagnosisRequestTimeoutMsg\n862: \t}\n863: }\n864: \n865: func diagnosisExecOptionsFromContext(ctx context.Context) diagnosisExecOptions {\n866: \ttimeout := diagnosisCommandTimeout\n867: \tif ctx != nil {\n868: \t\tif deadline, ok := ctx.Deadline(); ok {\n869: \t\t\tremaining := time.Until(deadline)\n870: \t\t\tif remaining <= 0 {\n871: \t\t\t\tremaining = 100 * time.Millisecond\n872: \t\t\t}\n873: \t\t\tif remaining < timeout {\n874: \t\t\t\ttimeout = remaining\n875: \t\t\t}\n876: \t\t}\n877: \t}\n878: \tif timeout <= 0 {\n879: \t\ttimeout = 100 * time.Millisecond\n880: \t}\n881: \tpingTimeoutMS := int(timeout / time.Millisecond)\n882: \tif pingTimeoutMS <= 0 {\n883: \t\tpingTimeoutMS = 100\n884: \t}\n885: \treturn diagnosisExecOptions{\n886: \t\tcommandTimeout: timeout,\n887: \t\tpingTimeoutMS: pingTimeoutMS,\n888: \t\ttimeoutMessage: diagnosisContextMessage(ctx),\n889: \t}\n890: }\n891: \n892: func newDiagnosisTimeoutItem(workItem diagnosisWorkItem, message string) map[string]interface{} {\n893: \ttargetPort := workItem.targetPort\n894: \tif targetPort <= 0 {\n895: \t\ttargetPort = workItem.toNode.Port\n896: \t}\n897: \titem := newDiagnosisResultItem(workItem.fromNodeID, workItem.targetIP, targetPort, workItem.description, workItem.metadata)\n898: \titem[\"success\"] = false\n899: \tif strings.TrimSpace(message) == \"\" {\n900: \t\tmessage = diagnosisCommandTimeoutMsg\n901: \t}\n902: \titem[\"message\"] = message\n903: \treturn item\n904: }\n905: \n906: func (h *Handler) executeDiagnosisWorkItem(workItem diagnosisWorkItem, options diagnosisExecOptions) map[string]interface{} {\n907: \tsingle := make([]map[string]interface{}, 0, 1)\n908: \tnodeCache := map[int64]*nodeRecord{}\n909: \tif workItem.hasChainHop {\n910: \t\th.appendChainHopDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.toNode, workItem.description, workItem.metadata, workItem.ipPreference, options)\n911: \t} else {\n912: \t\th.appendPathDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.targetIP, workItem.targetPort, workItem.description, workItem.metadata, options)\n913: \t}\n914: \n915: \tif len(single) == 0 {\n916: \t\treturn newDiagnosisTimeoutItem(workItem, \"诊断任务未返回结果\")\n917: \t}\n918: \treturn single[0]\n919: }\n920: \n921: func (h *Handler) runDiagnosisWorkItems(ctx context.Context, workItems []diagnosisWorkItem, emitter diagnosisItemEmitter) []map[string]interface{} {\n922: \tif ctx == nil {\n923: \t\tctx = context.Background()\n924: \t}\n925: \tresults := make([]map[string]interface{}, len(workItems))\n926: \tif len(workItems) == 0 {\n927: \t\treturn results\n928: \t}\n929: \n930: \tworkerLimit := diagnosisMaxConcurrency\n931: \tif workerLimit < 1 {\n932: \t\tworkerLimit = 1\n933: \t}\n934: \tif workerLimit > len(workItems) {\n935: \t\tworkerLimit = len(workItems)\n936: \t}\n937: \n938: \ttype diagnosisWorkResult struct {\n939: \t\tindex int\n940: \t\titem map[string]interface{}\n941: \t}\n942: \n943: \tjobs := make(chan int)\n944: \tresultCh := make(chan diagnosisWorkResult, len(workItems))\n945: \n946: \tvar wg sync.WaitGroup\n947: \tfor i := 0; i < workerLimit; i++ {\n948: \t\twg.Add(1)\n949: \t\tgo func() {\n950: \t\t\tdefer wg.Done()\n951: \t\t\tfor index := range jobs {\n952: \t\t\t\tselect {\n953: \t\t\t\tcase <-ctx.Done():\n954: \t\t\t\t\tresultCh <- diagnosisWorkResult{index: index, item: newDiagnosisTimeoutItem(workItems[index], diagnosisContextMessage(ctx))}\n955: \t\t\t\t\tcontinue\n956: \t\t\t\tdefault:\n957: \t\t\t\t}\n958: \t\t\t\toptions := diagnosisExecOptionsFromContext(ctx)\n959: \t\t\t\tresultCh <- diagnosisWorkResult{index: index, item: h.executeDiagnosisWorkItem(workItems[index], options)}\n960: \t\t\t}\n961: \t\t}()\n962: \t}\n963: \n964: enqueueLoop:\n965: \tfor i := 0; i < len(workItems); i++ {\n966: \t\tselect {\n967: \t\tcase <-ctx.Done():\n968: \t\t\tmessage := diagnosisContextMessage(ctx)\n969: \t\t\tfor j := i; j < len(workItems); j++ {\n970: \t\t\t\tresultCh <- diagnosisWorkResult{index: j, item: newDiagnosisTimeoutItem(workItems[j], message)}\n971: \t\t\t}\n972: \t\t\tbreak enqueueLoop\n973: \t\tcase jobs <- i:\n974: \t\t}\n975: \t}\n976: \tclose(jobs)\n977: \tgo func() {\n978: \t\twg.Wait()\n979: \t\tclose(resultCh)\n980: \t}()\n981: \n982: \tprogress := diagnosisProgress{Total: len(workItems)}\n983: \tfor result := range resultCh {\n984: \t\tresults[result.index] = result.item\n985: \t\tprogress.Completed++\n986: \t\tif asBool(result.item[\"success\"], false) {\n987: \t\t\tprogress.Success++\n988: \t\t} else {\n989: \t\t\tprogress.Failed++\n990: \t\t}\n991: \t\tif emitter != nil {\n992: \t\t\temitter(result.index, result.item, progress)\n993: \t\t}\n994: \t}\n995: \n996: \tfor i := range results {\n997: \t\tif results[i] == nil {\n998: \t\t\tresults[i] = newDiagnosisTimeoutItem(workItems[i], diagnosisCommandTimeoutMsg)\n999: \t\t}\n1000: \t}\n1001: \treturn results\n1002: }\n1003: \n1004: func (h *Handler) cachedNode(nodeCache map[int64]*nodeRecord, nodeID int64) (*nodeRecord, error) {\n1005: \tif node, ok := nodeCache[nodeID]; ok {\n1006: \t\treturn node, nil\n1007: \t}\n1008: \tnode, err := h.getNodeRecord(nodeID)\n1009: \tif err != nil {\n1010: \t\treturn nil, err\n1011: \t}\n1012: \tnodeCache[nodeID] = node\n1013: \treturn node, nil\n1014: }\n1015: \n1016: func newDiagnosisResultItem(fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}) map[string]interface{} {\n1017: \titem := map[string]interface{}{\n1018: \t\t\"nodeName\": fmt.Sprintf(\"node_%d\", fromNodeID),\n1019: \t\t\"nodeId\": strconv.FormatInt(fromNodeID, 10),\n1020: \t\t\"targetIp\": targetIP,\n1021: \t\t\"targetPort\": targetPort,\n1022: \t\t\"description\": description,\n1023: \t\t\"averageTime\": 0,\n1024: \t\t\"packetLoss\": 100,\n1025: \t}\n1026: \tfor k, v := range metadata {\n1027: \t\titem[k] = v\n1028: \t}\n1029: \treturn item\n1030: }\n1031: \n1032: func (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) {\n1033: \titem := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)\n1034: \tif node, err := h.cachedNode(nodeCache, fromNodeID); err == nil {\n1035: \t\titem[\"nodeName\"] = node.Name\n1036: \t}\n1037: \tif strings.TrimSpace(message) == \"\" {\n1038: \t\tmessage = \"TCP连接失败\"\n1039: \t}\n1040: \titem[\"success\"] = false\n1041: \titem[\"message\"] = message\n1042: \t*results = append(*results, item)\n1043: }\n1044: \n1045: func (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) {\n1046: \titem := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)\n1047: \n1048: \tfromNode, err := h.cachedNode(nodeCache, fromNodeID)\n1049: \tif err != nil {\n1050: \t\titem[\"success\"] = false\n1051: \t\titem[\"message\"] = err.Error()\n1052: \t\t*results = append(*results, item)\n1053: \t\treturn\n1054: \t}\n1055: \titem[\"nodeName\"] = fromNode.Name\n1056: \n1057: \tvar (\n1058: \t\tpingData map[string]interface{}\n1059: \t\tpingErr error\n1060: \t)\n1061: \tif fromNode.IsRemote == 1 {\n1062: \t\tpingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort, options)\n1063: \t} else {\n1064: \t\tpingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort, options)\n1065: \t}\n1066: \tif pingErr != nil {\n1067: \t\titem[\"success\"] = false\n1068: \t\titem[\"message\"] = pingErr.Error()\n1069: \t\t*results = append(*results, item)\n1070: \t\treturn\n1071: \t}\n1072: \n1073: \tsuccess := asBool(pingData[\"success\"], false)\n1074: \titem[\"success\"] = success\n1075: \titem[\"averageTime\"] = asFloat(pingData[\"averageTime\"], 0)\n1076: \titem[\"packetLoss\"] = asFloat(pingData[\"packetLoss\"], 100)\n1077: \n1078: \tmessage := strings.TrimSpace(asString(pingData[\"message\"]))\n1079: \tif success {\n1080: \t\tif message == \"\" {\n1081: \t\t\tmessage = \"TCP连接成功\"\n1082: \t\t}\n1083: \t} else {\n1084: \t\tif message == \"\" {\n1085: \t\t\tmessage = strings.TrimSpace(asString(pingData[\"errorMessage\"]))\n1086: \t\t}\n1087: \t\tif message == \"\" {\n1088: \t\t\tmessage = \"TCP连接失败\"\n1089: \t\t}\n1090: \t}\n1091: \titem[\"message\"] = message\n1092: \t*results = append(*results, item)\n1093: }\n1094: \n1095: func (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) {\n1096: \tfromNode, _ := h.cachedNode(nodeCache, fromNodeID)\n1097: \ttargetNode, err := h.cachedNode(nodeCache, toNode.NodeID)\n1098: \tif err != nil {\n1099: \t\th.appendFailedDiagnosis(results, nodeCache, fromNodeID, \"\", 0, description, metadata, err.Error())\n1100: \t\treturn\n1101: \t}\n1102: \ttargetIP, targetPort, err := resolveChainProbeTarget(fromNode, targetNode, toNode.Port, ipPreference)\n1103: \tif err != nil {\n1104: \t\th.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), \"[]\"), toNode.Port, description, metadata, err.Error())\n1105: \t\treturn\n1106: \t}\n1107: \th.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata, options)\n1108: }\n1109: \n1110: func resolveChainProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string) (string, int, error) {\n1111: \tif targetNode == nil {\n1112: \t\treturn \"\", 0, errors.New(\"目标节点不存在\")\n1113: \t}\n1114: \thost, err := selectTunnelDialHost(fromNode, targetNode, ipPreference)\n1115: \tif err != nil {\n1116: \t\thost = strings.Trim(strings.TrimSpace(targetNode.ServerIP), \"[]\")\n1117: \t}\n1118: \tif host == \"\" {\n1119: \t\treturn \"\", 0, errors.New(\"目标节点地址为空\")\n1120: \t}\n1121: \tport := preferredPort\n1122: \tif port <= 0 {\n1123: \t\tport = firstPortFromRange(targetNode.PortRange)\n1124: \t}\n1125: \tif port <= 0 {\n1126: \t\tport = 443\n1127: \t}\n1128: \treturn host, port, nil\n1129: }\n1130: \n1131: func firstPortFromRange(portRange string) int {\n1132: \tportRange = strings.TrimSpace(portRange)\n1133: \tif portRange == \"\" {\n1134: \t\treturn 0\n1135: \t}\n1136: \tfirst := strings.Split(portRange, \",\")[0]\n1137: \tfirst = strings.TrimSpace(first)\n1138: \tif strings.Contains(first, \"-\") {\n1139: \t\tparts := strings.SplitN(first, \"-\", 2)\n1140: \t\tif len(parts) != 2 {\n1141: \t\t\treturn 0\n1142: \t\t}\n1143: \t\tp, err := strconv.Atoi(strings.TrimSpace(parts[0]))\n1144: \t\tif err != nil || p <= 0 {\n1145: \t\t\treturn 0\n1146: \t\t}\n1147: \t\treturn p\n1148: \t}\n1149: \tp, err := strconv.Atoi(first)\n1150: \tif err != nil || p <= 0 {\n1151: \t\treturn 0\n1152: \t}\n1153: \treturn p\n1154: }\n1155: \n1156: func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, error) {\n1157: \treturn h.repo.ListChainNodesForTunnel(tunnelID)\n1158: }\n1159: \n1160: func (h *Handler) tcpPingViaNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {\n1161: \tif options.commandTimeout <= 0 {\n1162: \t\toptions.commandTimeout = diagnosisCommandTimeout\n1163: \t}\n1164: \tif options.pingTimeoutMS <= 0 {\n1165: \t\toptions.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)\n1166: \t}\n1167: \tres, err := h.sendNodeCommandWithTimeout(nodeID, \"TcpPing\", map[string]interface{}{\n1168: \t\t\"ip\": ip,\n1169: \t\t\"port\": port,\n1170: \t\t\"count\": 4,\n1171: \t\t\"timeout\": options.pingTimeoutMS,\n1172: \t}, options.commandTimeout, false, false)\n1173: \tif err != nil {\n1174: \t\treturn nil, err\n1175: \t}\n1176: \tif res.Data == nil {\n1177: \t\treturn nil, errors.New(\"节点未返回诊断数据\")\n1178: \t}\n1179: \treturn res.Data, nil\n1180: }\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}\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:])\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}\n1440: }\n1441: \n1442: func (h *Handler) upsertLimiterOnNode(nodeID int64, limiterID int64, speed int) error {\n1443: \tname, addPayload := buildLimiterAddPayload(limiterID, speed)\n1444: \tif _, err := h.sendNodeCommand(nodeID, \"AddLimiters\", addPayload, false, false); err != nil {\n1445: \t\tif !isAlreadyExistsMessage(err.Error()) {\n1446: \t\t\treturn err\n1447: \t\t}\n1448: \t\tpayload := map[string]interface{}{\n1449: \t\t\t\"name\": name,\n1450: \t\t\t\"limits\": addPayload[\"limits\"],\n1451: \t\t}\n1452: \t\tif _, updateErr := h.sendNodeCommand(nodeID, \"UpdateLimiters\", buildLimiterUpdatePayload(name, payload), false, false); updateErr != nil {\n1453: \t\t\treturn updateErr\n1454: \t\t}\n1455: \t}\n1456: \n1457: \treturn nil\n1458: }\n\n(End of file - total 1458 lines)\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/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": false,
|
|
"loaded": [
|
|
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/AGENTS.md"
|
|
]
|
|
},
|
|
"time": {
|
|
"start": 1772444615237,
|
|
"end": 1772444615238
|
|
}
|
|
},
|
|
"id": "prt_cadee8245001tEylmI5j6YegVO",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadee425c001OJkgzM0lAvkQsk"
|
|
},
|
|
{
|
|
"type": "tool",
|
|
"callID": "call_d58f829eef3c4088bf0c5954",
|
|
"tool": "read",
|
|
"state": {
|
|
"status": "completed",
|
|
"input": {
|
|
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go",
|
|
"offset": 2450,
|
|
"limit": 500
|
|
},
|
|
"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>2450: \t}\n2451: \t_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)\n2452: }\n2453: \n2454: func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64, error) {\n2455: \tif h == nil || state == nil {\n2456: \t\treturn nil, nil, errors.New(\"invalid tunnel runtime state\")\n2457: \t}\n2458: \tcreatedChains := make([]int64, 0)\n2459: \tcreatedServices := make([]int64, 0)\n2460: \tif state.Type != 2 {\n2461: \t\treturn createdChains, createdServices, nil\n2462: \t}\n2463: \n2464: \tfor _, inNode := range state.InNodes {\n2465: \t\tnode := state.Nodes[inNode.NodeID]\n2466: \t\ttargets := state.OutNodes\n2467: \t\tif len(state.ChainHops) > 0 {\n2468: \t\t\ttargets = state.ChainHops[0]\n2469: \t\t}\n2470: \t\tchainData, err := buildTunnelChainConfig(state.TunnelID, inNode.NodeID, targets, state.Nodes, state.IPPreference)\n2471: \t\tif err != nil {\n2472: \t\t\treturn createdChains, createdServices, err\n2473: \t\t}\n2474: \t\tif _, err := h.sendNodeCommand(inNode.NodeID, \"AddChains\", chainData, true, false); err != nil {\n2475: \t\t\tif node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) {\n2476: \t\t\t\tcontinue\n2477: \t\t\t}\n2478: \t\t\treturn createdChains, createdServices, fmt.Errorf(\"入口节点 %s 下发转发链失败: %w\", nodeDisplayName(state.Nodes[inNode.NodeID]), err)\n2479: \t\t}\n2480: \t\tcreatedChains = append(createdChains, inNode.NodeID)\n2481: \t}\n2482: \n2483: \tfor i, hop := range state.ChainHops {\n2484: \t\tnextTargets := state.OutNodes\n2485: \t\tif i+1 < len(state.ChainHops) {\n2486: \t\t\tnextTargets = state.ChainHops[i+1]\n2487: \t\t}\n2488: \t\tfor _, chainNode := range hop {\n2489: \t\t\tif node := state.Nodes[chainNode.NodeID]; node != nil && node.IsRemote == 1 {\n2490: \t\t\t\tcontinue\n2491: \t\t\t}\n2492: \t\t\tchainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)\n2493: \t\t\tif err != nil {\n2494: \t\t\t\treturn createdChains, createdServices, err\n2495: \t\t\t}\n2496: \t\t\tif _, err := h.sendNodeCommand(chainNode.NodeID, \"AddChains\", chainData, true, false); err != nil {\n2497: \t\t\t\treturn createdChains, createdServices, fmt.Errorf(\"转发链节点 %s 下发转发链失败: %w\", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)\n2498: \t\t\t}\n2499: \t\t\tcreatedChains = append(createdChains, chainNode.NodeID)\n2500: \n2501: \t\t\tserviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID])\n2502: \t\t\tif _, err := h.sendNodeCommand(chainNode.NodeID, \"AddService\", serviceData, true, false); err != nil {\n2503: \t\t\t\treturn createdChains, createdServices, fmt.Errorf(\"转发链节点 %s 下发服务失败: %w\", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)\n2504: \t\t\t}\n2505: \t\t\tcreatedServices = append(createdServices, chainNode.NodeID)\n2506: \t\t}\n2507: \t}\n2508: \n2509: \tfor _, outNode := range state.OutNodes {\n2510: \t\tif node := state.Nodes[outNode.NodeID]; node != nil && node.IsRemote == 1 {\n2511: \t\t\tcontinue\n2512: \t\t}\n2513: \t\tserviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID])\n2514: \t\tif _, err := h.sendNodeCommand(outNode.NodeID, \"AddService\", serviceData, true, false); err != nil {\n2515: \t\t\treturn createdChains, createdServices, fmt.Errorf(\"出口节点 %s 下发服务失败: %w\", nodeDisplayName(state.Nodes[outNode.NodeID]), err)\n2516: \t\t}\n2517: \t\tcreatedServices = append(createdServices, outNode.NodeID)\n2518: \t}\n2519: \n2520: \treturn createdChains, createdServices, nil\n2521: }\n2522: \n2523: func (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tunnelID int64) {\n2524: \tif h == nil || tunnelID <= 0 {\n2525: \t\treturn\n2526: \t}\n2527: \tseenServices := make(map[int64]struct{})\n2528: \tserviceName := fmt.Sprintf(\"%d_tls\", tunnelID)\n2529: \tfor i := len(serviceNodeIDs) - 1; i >= 0; i-- {\n2530: \t\tnodeID := serviceNodeIDs[i]\n2531: \t\tif _, ok := seenServices[nodeID]; ok {\n2532: \t\t\tcontinue\n2533: \t\t}\n2534: \t\tseenServices[nodeID] = struct{}{}\n2535: \t\t_, _ = h.sendNodeCommand(nodeID, \"DeleteService\", map[string]interface{}{\"services\": []string{serviceName}}, false, true)\n2536: \t}\n2537: \n2538: \tseenChains := make(map[int64]struct{})\n2539: \tchainName := fmt.Sprintf(\"chains_%d\", tunnelID)\n2540: \tfor i := len(chainNodeIDs) - 1; i >= 0; i-- {\n2541: \t\tnodeID := chainNodeIDs[i]\n2542: \t\tif _, ok := seenChains[nodeID]; ok {\n2543: \t\t\tcontinue\n2544: \t\t}\n2545: \t\tseenChains[nodeID] = struct{}{}\n2546: \t\t_, _ = h.sendNodeCommand(nodeID, \"DeleteChains\", map[string]interface{}{\"chain\": chainName}, false, true)\n2547: \t}\n2548: }\n2549: \n2550: func shouldDeferTunnelRuntimeApplyError(err error) bool {\n2551: \tif err == nil {\n2552: \t\treturn false\n2553: \t}\n2554: \tmsg := strings.ToLower(strings.TrimSpace(err.Error()))\n2555: \tif msg == \"\" {\n2556: \t\treturn false\n2557: \t}\n2558: \tif strings.Contains(msg, \"节点不在线\") {\n2559: \t\treturn true\n2560: \t}\n2561: \tif strings.Contains(msg, \"等待节点响应超时\") || strings.Contains(msg, \"timeout\") || strings.Contains(msg, \"超时\") {\n2562: \t\treturn true\n2563: \t}\n2564: \treturn false\n2565: }\n2566: \n2567: func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) (map[string]interface{}, error) {\n2568: \tfromNode := nodes[fromNodeID]\n2569: \tif fromNode == nil {\n2570: \t\treturn nil, errors.New(\"节点不存在\")\n2571: \t}\n2572: \tif len(targets) == 0 {\n2573: \t\treturn nil, errors.New(\"转发链目标不能为空\")\n2574: \t}\n2575: \tnodeItems := make([]map[string]interface{}, 0, len(targets))\n2576: \tfor idx, target := range targets {\n2577: \t\ttargetNode := nodes[target.NodeID]\n2578: \t\tif targetNode == nil {\n2579: \t\t\treturn nil, errors.New(\"节点不存在\")\n2580: \t\t}\n2581: \t\thost, err := selectTunnelDialHost(fromNode, targetNode, ipPreference)\n2582: \t\tif err != nil {\n2583: \t\t\treturn nil, err\n2584: \t\t}\n2585: \t\tport := target.Port\n2586: \t\tif port <= 0 {\n2587: \t\t\treturn nil, errors.New(\"节点端口不能为空\")\n2588: \t\t}\n2589: \t\tprotocol := defaultString(target.Protocol, \"tls\")\n2590: \t\tconnector := map[string]interface{}{\n2591: \t\t\t\"type\": \"relay\",\n2592: \t\t}\n2593: \t\tif isTLSTunnelProtocol(protocol) {\n2594: \t\t\tconnector[\"metadata\"] = map[string]interface{}{\"nodelay\": true}\n2595: \t\t}\n2596: \t\tnodeItems = append(nodeItems, map[string]interface{}{\n2597: \t\t\t\"name\": fmt.Sprintf(\"node_%d\", idx+1),\n2598: \t\t\t\"addr\": processServerAddress(fmt.Sprintf(\"%s:%d\", host, port)),\n2599: \t\t\t\"connector\": connector,\n2600: \t\t\t\"dialer\": map[string]interface{}{\n2601: \t\t\t\t\"type\": protocol,\n2602: \t\t\t},\n2603: \t\t})\n2604: \t}\n2605: \n2606: \tstrategy := defaultString(strings.TrimSpace(targets[0].Strategy), \"round\")\n2607: \thop := map[string]interface{}{\n2608: \t\t\"name\": fmt.Sprintf(\"hop_%d\", tunnelID),\n2609: \t\t\"selector\": map[string]interface{}{\n2610: \t\t\t\"strategy\": strategy,\n2611: \t\t\t\"maxFails\": 1,\n2612: \t\t\t\"failTimeout\": int64(600000000000),\n2613: \t\t},\n2614: \t\t\"nodes\": nodeItems,\n2615: \t}\n2616: \tif strings.TrimSpace(fromNode.InterfaceName) != \"\" {\n2617: \t\thop[\"interface\"] = fromNode.InterfaceName\n2618: \t}\n2619: \n2620: \treturn map[string]interface{}{\n2621: \t\t\"name\": fmt.Sprintf(\"chains_%d\", tunnelID),\n2622: \t\t\"hops\": []map[string]interface{}{hop},\n2623: \t}, nil\n2624: }\n2625: \n2626: func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode, node *nodeRecord) []map[string]interface{} {\n2627: \tif node == nil {\n2628: \t\treturn nil\n2629: \t}\n2630: \tprotocol := defaultString(chainNode.Protocol, \"tls\")\n2631: \thandlerCfg := map[string]interface{}{\n2632: \t\t\"type\": \"relay\",\n2633: \t}\n2634: \tif isTLSTunnelProtocol(protocol) {\n2635: \t\thandlerCfg[\"metadata\"] = map[string]interface{}{\"nodelay\": true}\n2636: \t}\n2637: \tservice := map[string]interface{}{\n2638: \t\t\"name\": fmt.Sprintf(\"%d_tls\", tunnelID),\n2639: \t\t\"addr\": fmt.Sprintf(\"%s:%d\", node.TCPListenAddr, chainNode.Port),\n2640: \t\t\"handler\": handlerCfg,\n2641: \t\t\"listener\": map[string]interface{}{\n2642: \t\t\t\"type\": protocol,\n2643: \t\t},\n2644: \t}\n2645: \tif chainNode.ChainType == 2 {\n2646: \t\tservice[\"handler\"].(map[string]interface{})[\"chain\"] = fmt.Sprintf(\"chains_%d\", tunnelID)\n2647: \t}\n2648: \tif chainNode.ChainType == 3 && strings.TrimSpace(node.InterfaceName) != \"\" {\n2649: \t\tservice[\"metadata\"] = map[string]interface{}{\"interface\": node.InterfaceName}\n2650: \t}\n2651: \treturn []map[string]interface{}{service}\n2652: }\n2653: \n2654: func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string) (string, error) {\n2655: \tif fromNode == nil || toNode == nil {\n2656: \t\treturn \"\", errors.New(\"节点不存在\")\n2657: \t}\n2658: \tfromV4 := nodeSupportsV4(fromNode)\n2659: \tfromV6 := nodeSupportsV6(fromNode)\n2660: \ttoV4 := nodeSupportsV4(toNode)\n2661: \ttoV6 := nodeSupportsV6(toNode)\n2662: \n2663: \tswitch strings.TrimSpace(ipPreference) {\n2664: \tcase \"v6\":\n2665: \t\tif fromV6 && toV6 {\n2666: \t\t\tif host := pickNodeAddressV6(toNode); host != \"\" {\n2667: \t\t\t\treturn host, nil\n2668: \t\t\t}\n2669: \t\t}\n2670: \t\tif fromV4 && toV4 {\n2671: \t\t\tif host := pickNodeAddressV4(toNode); host != \"\" {\n2672: \t\t\t\treturn host, nil\n2673: \t\t\t}\n2674: \t\t}\n2675: \tcase \"v4\":\n2676: \t\tif fromV4 && toV4 {\n2677: \t\t\tif host := pickNodeAddressV4(toNode); host != \"\" {\n2678: \t\t\t\treturn host, nil\n2679: \t\t\t}\n2680: \t\t}\n2681: \t\tif fromV6 && toV6 {\n2682: \t\t\tif host := pickNodeAddressV6(toNode); host != \"\" {\n2683: \t\t\t\treturn host, nil\n2684: \t\t\t}\n2685: \t\t}\n2686: \tdefault:\n2687: \t\tif fromV4 && toV4 {\n2688: \t\t\tif host := pickNodeAddressV4(toNode); host != \"\" {\n2689: \t\t\t\treturn host, nil\n2690: \t\t\t}\n2691: \t\t}\n2692: \t\tif fromV6 && toV6 {\n2693: \t\t\tif host := pickNodeAddressV6(toNode); host != \"\" {\n2694: \t\t\t\treturn host, nil\n2695: \t\t\t}\n2696: \t\t}\n2697: \t}\n2698: \treturn \"\", fmt.Errorf(\"节点链路不兼容:%s(v4=%t,v6=%t) -> %s(v4=%t,v6=%t)\", nodeDisplayName(fromNode), fromV4, fromV6, nodeDisplayName(toNode), toV4, toV6)\n2699: }\n2700: \n2701: func nodeDisplayName(node *nodeRecord) string {\n2702: \tif node == nil {\n2703: \t\treturn \"node\"\n2704: \t}\n2705: \tif strings.TrimSpace(node.Name) != \"\" {\n2706: \t\treturn strings.TrimSpace(node.Name)\n2707: \t}\n2708: \treturn fmt.Sprintf(\"node_%d\", node.ID)\n2709: }\n2710: \n2711: func isTLSTunnelProtocol(protocol string) bool {\n2712: \treturn strings.EqualFold(strings.TrimSpace(defaultString(protocol, \"tls\")), \"tls\")\n2713: }\n2714: \n2715: func nodeSupportsV4(node *nodeRecord) bool {\n2716: \tif node == nil {\n2717: \t\treturn false\n2718: \t}\n2719: \tif strings.TrimSpace(node.ServerIPv4) != \"\" {\n2720: \t\treturn true\n2721: \t}\n2722: \tif strings.TrimSpace(node.ServerIPv6) != \"\" {\n2723: \t\treturn false\n2724: \t}\n2725: \tlegacy := strings.Trim(strings.TrimSpace(node.ServerIP), \"[]\")\n2726: \tif legacy == \"\" {\n2727: \t\treturn false\n2728: \t}\n2729: \tif ip := net.ParseIP(legacy); ip != nil {\n2730: \t\treturn ip.To4() != nil\n2731: \t}\n2732: \treturn true\n2733: }\n2734: \n2735: func nodeSupportsV6(node *nodeRecord) bool {\n2736: \tif node == nil {\n2737: \t\treturn false\n2738: \t}\n2739: \tif strings.TrimSpace(node.ServerIPv6) != \"\" {\n2740: \t\treturn true\n2741: \t}\n2742: \tif strings.TrimSpace(node.ServerIPv4) != \"\" {\n2743: \t\treturn false\n2744: \t}\n2745: \tlegacy := strings.Trim(strings.TrimSpace(node.ServerIP), \"[]\")\n2746: \tif legacy == \"\" {\n2747: \t\treturn false\n2748: \t}\n2749: \tif ip := net.ParseIP(legacy); ip != nil {\n2750: \t\treturn ip.To4() == nil\n2751: \t}\n2752: \treturn true\n2753: }\n2754: \n2755: func pickNodeAddressV4(node *nodeRecord) string {\n2756: \tif node == nil {\n2757: \t\treturn \"\"\n2758: \t}\n2759: \tif v := strings.TrimSpace(node.ServerIPv4); v != \"\" {\n2760: \t\treturn v\n2761: \t}\n2762: \treturn strings.TrimSpace(node.ServerIP)\n2763: }\n2764: \n2765: func pickNodeAddressV6(node *nodeRecord) string {\n2766: \tif node == nil {\n2767: \t\treturn \"\"\n2768: \t}\n2769: \tif v := strings.TrimSpace(node.ServerIPv6); v != \"\" {\n2770: \t\treturn v\n2771: \t}\n2772: \treturn strings.TrimSpace(node.ServerIP)\n2773: }\n2774: \n2775: func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[string]interface{}) error {\n2776: \tallocated := map[int64]int{}\n2777: \tinNodes := asMapSlice(req[\"inNodeId\"])\n2778: \tfor i, n := range inNodes {\n2779: \t\tnodeID := asInt64(n[\"nodeId\"], 0)\n2780: \t\tif nodeID <= 0 {\n2781: \t\t\tcontinue\n2782: \t\t}\n2783: \t\tif err := h.repo.CreateChainTunnelTx(\n2784: \t\t\ttx,\n2785: \t\t\ttunnelID,\n2786: \t\t\t\"1\",\n2787: \t\t\tnodeID,\n2788: \t\t\tsql.NullInt64{},\n2789: \t\t\tdefaultString(asString(n[\"strategy\"]), \"round\"),\n2790: \t\t\ti+1,\n2791: \t\t\tdefaultString(asString(n[\"protocol\"]), \"tls\"),\n2792: \t\t); err != nil {\n2793: \t\t\treturn err\n2794: \t\t}\n2795: \t}\n2796: \tfor i, n := range asMapSlice(req[\"outNodeId\"]) {\n2797: \t\tnodeID := asInt64(n[\"nodeId\"], 0)\n2798: \t\tif nodeID <= 0 {\n2799: \t\t\tcontinue\n2800: \t\t}\n2801: \t\tport := asInt(n[\"port\"], 0)\n2802: \t\tif port <= 0 {\n2803: \t\t\tvar pickErr error\n2804: \t\t\tport, pickErr = h.repo.PickNodePortTx(tx, nodeID, allocated, 0)\n2805: \t\t\tif pickErr != nil {\n2806: \t\t\t\treturn pickErr\n2807: \t\t\t}\n2808: \t\t}\n2809: \t\tif err := h.repo.CreateChainTunnelTx(\n2810: \t\t\ttx,\n2811: \t\t\ttunnelID,\n2812: \t\t\t\"3\",\n2813: \t\t\tnodeID,\n2814: \t\t\tsql.NullInt64{Int64: int64(port), Valid: true},\n2815: \t\t\tdefaultString(asString(n[\"strategy\"]), \"round\"),\n2816: \t\t\ti+1,\n2817: \t\t\tdefaultString(asString(n[\"protocol\"]), \"tls\"),\n2818: \t\t); err != nil {\n2819: \t\t\treturn err\n2820: \t\t}\n2821: \t}\n2822: \tchainNodes := asAnySlice(req[\"chainNodes\"])\n2823: \tfor i, grp := range chainNodes {\n2824: \t\tfor _, n := range asMapSlice(grp) {\n2825: \t\t\tnodeID := asInt64(n[\"nodeId\"], 0)\n2826: \t\t\tif nodeID <= 0 {\n2827: \t\t\t\tcontinue\n2828: \t\t\t}\n2829: \t\t\tport := asInt(n[\"port\"], 0)\n2830: \t\t\tif port <= 0 {\n2831: \t\t\t\tvar pickErr error\n2832: \t\t\t\tport, pickErr = h.repo.PickNodePortTx(tx, nodeID, allocated, 0)\n2833: \t\t\t\tif pickErr != nil {\n2834: \t\t\t\t\treturn pickErr\n2835: \t\t\t\t}\n2836: \t\t\t}\n2837: \t\t\tif err := h.repo.CreateChainTunnelTx(\n2838: \t\t\t\ttx,\n2839: \t\t\t\ttunnelID,\n2840: \t\t\t\t\"2\",\n2841: \t\t\t\tnodeID,\n2842: \t\t\t\tsql.NullInt64{Int64: int64(port), Valid: true},\n2843: \t\t\t\tdefaultString(asString(n[\"strategy\"]), \"round\"),\n2844: \t\t\t\ti+1,\n2845: \t\t\t\tdefaultString(asString(n[\"protocol\"]), \"tls\"),\n2846: \t\t\t); err != nil {\n2847: \t\t\t\treturn err\n2848: \t\t\t}\n2849: \t\t}\n2850: \t}\n2851: \treturn nil\n2852: }\n2853: \n2854: func (h *Handler) deleteNodeByID(id int64) error {\n2855: \treturn h.repo.DeleteNodeCascade(id)\n2856: }\n2857: \n2858: func (h *Handler) deleteTunnelByID(id int64) error {\n2859: \tif h == nil || h.repo == nil {\n2860: \t\treturn errors.New(\"repository not initialized\")\n2861: \t}\n2862: \n2863: \ttunnelName, _ := h.repo.GetTunnelName(id)\n2864: \tif err := h.repo.DeleteTunnelCascade(id); err != nil {\n2865: \t\treturn err\n2866: \t}\n2867: \n2868: \tshareID, port, ok := parsePeerShareInfoFromFederationTunnelName(tunnelName)\n2869: \tif !ok {\n2870: \t\treturn nil\n2871: \t}\n2872: \n2873: \treturn h.repo.MarkPeerShareRuntimeReleasedByPort(shareID, port, time.Now().UnixMilli())\n2874: }\n2875: \n2876: func (h *Handler) deleteForwardByID(id int64) error {\n2877: \treturn h.repo.DeleteForwardCascade(id)\n2878: }\n2879: \n2880: func (h *Handler) batchForwardDelete(ids []int64) (int, int) {\n2881: \ts := 0\n2882: \tf := 0\n2883: \tfor _, id := range ids {\n2884: \t\tif err := h.deleteForwardByID(id); err != nil {\n2885: \t\t\tf++\n2886: \t\t} else {\n2887: \t\t\ts++\n2888: \t\t}\n2889: \t}\n2890: \treturn s, f\n2891: }\n2892: \n2893: func (h *Handler) batchForwardStatus(ids []int64, status int) (int, int) {\n2894: \treturn h.repo.BatchUpdateForwardStatus(ids, status)\n2895: }\n2896: \n2897: func (h *Handler) tunnelEntryNodeIDs(tunnelID int64) ([]int64, error) {\n2898: \treturn h.repo.TunnelEntryNodeIDs(tunnelID)\n2899: }\n2900: \n2901: func (h *Handler) pickTunnelPort(tunnelID int64) int {\n2902: \tentryNodes, err := h.tunnelEntryNodeIDs(tunnelID)\n2903: \tif err != nil || len(entryNodes) == 0 {\n2904: \t\treturn 10000\n2905: \t}\n2906: \n2907: \tvar commonAvailable []int\n2908: \tfirstNode := true\n2909: \n2910: \tfor _, nodeID := range entryNodes {\n2911: \t\tportRange, err := h.repo.GetNodePortRange(nodeID)\n2912: \t\tif err != nil {\n2913: \t\t\tcontinue\n2914: \t\t}\n2915: \t\tif portRange == \"\" {\n2916: \t\t\tportRange = \"1000-65535\"\n2917: \t\t}\n2918: \n2919: \t\tnodePorts, err := parsePorts(portRange)\n2920: \t\tif err != nil {\n2921: \t\t\tcontinue\n2922: \t\t}\n2923: \n2924: \t\tused, err := h.getUsedPorts(nodeID)\n2925: \t\tif err != nil {\n2926: \t\t\tcontinue\n2927: \t\t}\n2928: \n2929: \t\tvar available []int\n2930: \t\tfor _, p := range nodePorts {\n2931: \t\t\tif !used[p] {\n2932: \t\t\t\tavailable = append(available, p)\n2933: \t\t\t}\n2934: \t\t}\n2935: \n2936: \t\tif firstNode {\n2937: \t\t\tcommonAvailable = available\n2938: \t\t\tfirstNode = false\n2939: \t\t} else {\n2940: \t\t\tset := make(map[int]bool)\n2941: \t\t\tfor _, p := range available {\n2942: \t\t\t\tset[p] = true\n2943: \t\t\t}\n2944: \t\t\tvar newCommon []int\n2945: \t\t\tfor _, p := range commonAvailable {\n2946: \t\t\t\tif set[p] {\n2947: \t\t\t\t\tnewCommon = append(newCommon, p)\n2948: \t\t\t\t}\n2949: \t\t\t}\n\n(Showing lines 2450-2949 of 3400. Use offset=2950 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/http/handler/mutations.go",
|
|
"metadata": {
|
|
"preview": "\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}",
|
|
"truncated": true,
|
|
"loaded": [
|
|
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/AGENTS.md"
|
|
]
|
|
},
|
|
"time": {
|
|
"start": 1772444615237,
|
|
"end": 1772444615239
|
|
}
|
|
},
|
|
"id": "prt_cadee8245002OEpyG8N69YC3sr",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadee425c001OJkgzM0lAvkQsk"
|
|
},
|
|
{
|
|
"type": "tool",
|
|
"callID": "call_5ae584da09884a9c98557818",
|
|
"tool": "read",
|
|
"state": {
|
|
"status": "completed",
|
|
"input": {
|
|
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go",
|
|
"offset": 120,
|
|
"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>120: \tInIP sql.NullString `gorm:\"column:in_ip;type:text\"`\n121: \tInx int `gorm:\"not null;default:0\"`\n122: \tIPPreference string `gorm:\"column:ip_preference;type:varchar(10);not null;default:''\"`\n123: }\n124: \n125: func (Tunnel) TableName() string { return \"tunnel\" }\n126: \n127: type ChainTunnel struct {\n128: \tID int64 `gorm:\"primaryKey;autoIncrement\"`\n129: \tTunnelID int64 `gorm:\"column:tunnel_id;not null\"`\n130: \tChainType string `gorm:\"column:chain_type;type:varchar(10);not null\"`\n131: \tNodeID int64 `gorm:\"column:node_id;not null\"`\n132: \tPort sql.NullInt64 `gorm:\"column:port\"`\n133: \tStrategy sql.NullString `gorm:\"type:varchar(10)\"`\n134: \tInx sql.NullInt64 `gorm:\"column:inx\"`\n135: \tProtocol sql.NullString `gorm:\"type:varchar(10)\"`\n136: }\n137: \n138: func (ChainTunnel) TableName() string { return \"chain_tunnel\" }\n139: \n140: type UserTunnel struct {\n141: \tID int64 `gorm:\"primaryKey;autoIncrement\"`\n142: \tUserID int64 `gorm:\"column:user_id;not null;uniqueIndex:idx_user_tunnel_unique\"`\n143: \tTunnelID int64 `gorm:\"column:tunnel_id;not null;uniqueIndex:idx_user_tunnel_unique\"`\n144: \tSpeedID sql.NullInt64 `gorm:\"column:speed_id\"`\n145: \tNum int `gorm:\"not null\"`\n146: \tFlow int64 `gorm:\"not null\"`\n147: \tInFlow int64 `gorm:\"column:in_flow;not null;default:0\"`\n148: \tOutFlow int64 `gorm:\"column:out_flow;not null;default:0\"`\n149: \tFlowResetTime int64 `gorm:\"column:flow_reset_time;not null\"`\n150: \tExpTime int64 `gorm:\"column:exp_time;not null\"`\n151: \tStatus int `gorm:\"not null\"`\n152: }\n153: \n154: func (UserTunnel) TableName() string { return \"user_tunnel\" }\n155: \n156: type TunnelGroup struct {\n157: \tID int64 `gorm:\"primaryKey;autoIncrement\"`\n158: \tName string `gorm:\"type:varchar(100);not null;uniqueIndex:idx_tunnel_group_name\"`\n159: \tCreatedTime int64 `gorm:\"column:created_time;not null\"`\n\n(Showing lines 120-159 of 594. Use offset=160 to continue.)\n</content>",
|
|
"title": "go-backend/internal/store/model/model.go",
|
|
"metadata": {
|
|
"preview": "\tInIP sql.NullString `gorm:\"column:in_ip;type:text\"`\n\tInx int `gorm:\"not null;default:0\"`\n\tIPPreference string `gorm:\"column:ip_preference;type:varchar(10);not null;default:''\"`\n}\n\nfunc (Tunnel) TableName() string { return \"tunnel\" }\n\ntype ChainTunnel struct {\n\tID int64 `gorm:\"primaryKey;autoIncrement\"`\n\tTunnelID int64 `gorm:\"column:tunnel_id;not null\"`\n\tChainType string `gorm:\"column:chain_type;type:varchar(10);not null\"`\n\tNodeID int64 `gorm:\"column:node_id;not null\"`\n\tPort sql.NullInt64 `gorm:\"column:port\"`\n\tStrategy sql.NullString `gorm:\"type:varchar(10)\"`\n\tInx sql.NullInt64 `gorm:\"column:inx\"`\n\tProtocol sql.NullString `gorm:\"type:varchar(10)\"`\n}\n\nfunc (ChainTunnel) TableName() string { return \"chain_tunnel\" }\n",
|
|
"truncated": true,
|
|
"loaded": []
|
|
},
|
|
"time": {
|
|
"start": 1772444615238,
|
|
"end": 1772444615238
|
|
}
|
|
},
|
|
"id": "prt_cadee8245003oRxlIebz6z3XpX",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadee425c001OJkgzM0lAvkQsk"
|
|
},
|
|
{
|
|
"type": "step-finish",
|
|
"reason": "tool-calls",
|
|
"snapshot": "73ca391522f6954b1825f9d1b6c4a29d9e2b8eb3",
|
|
"cost": 0,
|
|
"tokens": {
|
|
"total": 9375,
|
|
"input": 2714,
|
|
"output": 261,
|
|
"reasoning": 0,
|
|
"cache": {
|
|
"read": 6400,
|
|
"write": 0
|
|
}
|
|
},
|
|
"id": "prt_cadee8247001ztmYJFYFJkQnkc",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadee425c001OJkgzM0lAvkQsk"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"info": {
|
|
"role": "assistant",
|
|
"time": {
|
|
"created": 1772444615290,
|
|
"completed": 1772444636292
|
|
},
|
|
"parentID": "msg_cadede8e5001rqRhy8g3MBaUkL",
|
|
"modelID": "glm-5",
|
|
"providerID": "zai-coding-plan",
|
|
"mode": "explore",
|
|
"agent": "explore",
|
|
"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": 34210,
|
|
"input": 24910,
|
|
"output": 212,
|
|
"reasoning": 0,
|
|
"cache": {
|
|
"read": 9088,
|
|
"write": 0
|
|
}
|
|
},
|
|
"finish": "tool-calls",
|
|
"id": "msg_cadee827a001LvKhV98jyoADuy",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP"
|
|
},
|
|
"parts": [
|
|
{
|
|
"type": "step-start",
|
|
"snapshot": "73ca391522f6954b1825f9d1b6c4a29d9e2b8eb3",
|
|
"id": "prt_cadeebe10001mQWrINAVQAG3Hd",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadee827a001LvKhV98jyoADuy"
|
|
},
|
|
{
|
|
"type": "reasoning",
|
|
"text": "现在我对隧道链路构建流程有了更好的理解。让我继续查看一些关键函数:\n\n1. `buildTunnelChainConfig` - 构建转发链配置\n2. `selectTunnelDialHost` - 选择连接地址\n3. `applyTunnelRuntime` 的完整逻辑\n4. 需要查看 `tunnelRuntimeNode` 结构定义\n\n让我继续搜索和查看这些代码。",
|
|
"time": {
|
|
"start": 1772444630544,
|
|
"end": 1772444636211
|
|
},
|
|
"id": "prt_cadeebe10002Dh8rXNoCVhH8A9",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadee827a001LvKhV98jyoADuy"
|
|
},
|
|
{
|
|
"type": "text",
|
|
"text": "让我继续查看 `tunnelRuntimeNode` 结构定义和更多相关代码:",
|
|
"time": {
|
|
"start": 1772444636211,
|
|
"end": 1772444636211
|
|
},
|
|
"id": "prt_cadeed421001WmPV36vw0Sbkp7",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadee827a001LvKhV98jyoADuy"
|
|
},
|
|
{
|
|
"type": "tool",
|
|
"callID": "call_4d618adbfcee4ac7b69ba058",
|
|
"tool": "grep",
|
|
"state": {
|
|
"status": "completed",
|
|
"input": {
|
|
"pattern": "tunnelRuntimeNode",
|
|
"include": "*.go"
|
|
},
|
|
"output": "Found 24 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go:\n Line 862: \t\tInNodes: make([]tunnelRuntimeNode, 0),\n Line 863: \t\tChainHops: make([][]tunnelRuntimeNode, 0),\n Line 864: \t\tOutNodes: make([]tunnelRuntimeNode, 0),\n Line 872: \t\tstate.InNodes = append(state.InNodes, tunnelRuntimeNode{\n Line 882: \t\tstate.OutNodes = append(state.OutNodes, tunnelRuntimeNode{\n Line 893: \t\tstateHop := make([]tunnelRuntimeNode, 0)\n Line 895: \t\t\tstateHop = append(stateHop, tunnelRuntimeNode{\n Line 1950: type tunnelRuntimeNode struct {\n Line 1963: \tInNodes []tunnelRuntimeNode\n Line 1964: \tChainHops [][]tunnelRuntimeNode\n Line 1965: \tOutNodes []tunnelRuntimeNode\n Line 1973: \t\tInNodes: make([]tunnelRuntimeNode, 0),\n Line 1974: \t\tChainHops: make([][]tunnelRuntimeNode, 0),\n Line 1975: \t\tOutNodes: make([]tunnelRuntimeNode, 0),\n Line 1986: \t\tstate.InNodes = append(state.InNodes, tunnelRuntimeNode{\n Line 2024: \t\t\tstate.OutNodes = append(state.OutNodes, tunnelRuntimeNode{\n Line 2037: \t\t\thop := make([]tunnelRuntimeNode, 0)\n Line 2058: \t\t\t\thop = append(hop, tunnelRuntimeNode{\n Line 2112: func buildTunnelInIP(inNodes []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) string {\n Line 2567: func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) (map[string]interface{}, error) {\n Line 2626: func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode, node *nodeRecord) []map[string]interface{} {\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/federation_runtime_test.go:\n Line 87: \t\tInNodes: []tunnelRuntimeNode{},\n Line 88: \t\tChainHops: [][]tunnelRuntimeNode{\n Line 93: \t\tOutNodes: []tunnelRuntimeNode{",
|
|
"title": "tunnelRuntimeNode",
|
|
"metadata": {
|
|
"matches": 24,
|
|
"truncated": false
|
|
},
|
|
"time": {
|
|
"start": 1772444636201,
|
|
"end": 1772444636236
|
|
}
|
|
},
|
|
"id": "prt_cadeed4240011skCnGerjn6GzD",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadee827a001LvKhV98jyoADuy"
|
|
},
|
|
{
|
|
"type": "tool",
|
|
"callID": "call_b888dbadbb774a48a36db799",
|
|
"tool": "grep",
|
|
"state": {
|
|
"status": "completed",
|
|
"input": {
|
|
"pattern": "tunnelCreateState",
|
|
"include": "*.go"
|
|
},
|
|
"output": "Found 9 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go:\n Line 845: func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, error) {\n Line 858: \tstate := &tunnelCreateState{\n Line 1959: type tunnelCreateState struct {\n Line 1970: func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) {\n Line 1971: \tstate := &tunnelCreateState{\n Line 2154: func applyTunnelPortsToRequest(req map[string]interface{}, state *tunnelCreateState) {\n Line 2217: func (h *Handler) applyFederationRuntime(state *tunnelCreateState, localDomain string) ([]repo.FederationTunnelBinding, []federationRuntimeReleaseRef, error) {\n Line 2454: func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64, error) {\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/federation_runtime_test.go:\n Line 84: \tstate := &tunnelCreateState{",
|
|
"title": "tunnelCreateState",
|
|
"metadata": {
|
|
"matches": 9,
|
|
"truncated": false
|
|
},
|
|
"time": {
|
|
"start": 1772444636205,
|
|
"end": 1772444636237
|
|
}
|
|
},
|
|
"id": "prt_cadeed42a0016iaIZstgdLkinW",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadee827a001LvKhV98jyoADuy"
|
|
},
|
|
{
|
|
"type": "tool",
|
|
"callID": "call_d37df62c881149d18fa40c5f",
|
|
"tool": "read",
|
|
"state": {
|
|
"status": "completed",
|
|
"input": {
|
|
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go",
|
|
"offset": 450,
|
|
"limit": 200
|
|
},
|
|
"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>450: \t\treturn\n451: \t}\n452: \tvar req map[string]interface{}\n453: \tif err := decodeJSON(r.Body, &req); err != nil {\n454: \t\tresponse.WriteJSON(w, response.ErrDefault(\"请求参数错误\"))\n455: \t\treturn\n456: \t}\n457: \tname := asString(req[\"name\"])\n458: \tif name == \"\" {\n459: \t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道名称不能为空\"))\n460: \t\treturn\n461: \t}\n462: \tnameDup, err := h.repo.TunnelNameExists(name)\n463: \tif err != nil {\n464: \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n465: \t\treturn\n466: \t}\n467: \tif nameDup {\n468: \t\tresponse.WriteJSON(w, response.ErrDefault(\"隧道名称重复\"))\n469: \t\treturn\n470: \t}\n471: \n472: \ttypeVal := asInt(req[\"type\"], 1)\n473: \tflow := asInt64(req[\"flow\"], 1)\n474: \tstatus := asInt(req[\"status\"], 1)\n475: \ttrafficRatio := asFloat(req[\"trafficRatio\"], 1.0)\n476: \tinIP := asString(req[\"inIp\"])\n477: \tipPreference := asString(req[\"ipPreference\"])\n478: \tnow := time.Now().UnixMilli()\n479: \tinx := h.repo.NextIndex(\"tunnel\")\n480: \tlocalDomain := h.federationLocalDomain()\n481: \n482: \ttx := h.repo.BeginTx()\n483: \tif tx.Error != nil {\n484: \t\tresponse.WriteJSON(w, response.Err(-2, tx.Error.Error()))\n485: \t\treturn\n486: \t}\n487: \tdefer func() { tx.Rollback() }()\n488: \n489: \truntimeState, err := h.prepareTunnelCreateState(tx, req, typeVal, 0)\n490: \tif err != nil {\n491: \t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n492: \t\treturn\n493: \t}\n494: \truntimeState.IPPreference = ipPreference\n495: \tif strings.TrimSpace(inIP) == \"\" {\n496: \t\tinIP = buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes, ipPreference)\n497: \t}\n498: \n499: \tif len(runtimeState.InNodes) > 0 {\n500: \t\tfirstNodeID := runtimeState.InNodes[0].NodeID\n501: \t\tisRemote, rUrl, rToken, _ := h.repo.GetNodeRemoteFieldsTx(tx, firstNodeID)\n502: \t\tif isRemote == 1 {\n503: \t\t\tfc := client.NewFederationClient()\n504: \n505: \t\t\ttargetProto := \"tcp\"\n506: \t\t\ttargetPort := 0\n507: \t\t\ttargetAddr := \"\"\n508: \n509: \t\t\tif typeVal == 1 {\n510: \t\t\t\tif len(runtimeState.OutNodes) > 0 {\n511: \t\t\t\t\toutNode := runtimeState.OutNodes[0]\n512: \t\t\t\t\toutNodeRec := runtimeState.Nodes[outNode.NodeID]\n513: \t\t\t\t\ttargetAddr = processServerAddress(outNodeRec.ServerIP)\n514: \t\t\t\t\tif outNode.Port > 0 {\n515: \t\t\t\t\t\ttargetAddr = fmt.Sprintf(\"%s:%d\", targetAddr, outNode.Port)\n516: \t\t\t\t\t}\n517: \t\t\t\t}\n518: \t\t\t\tif len(runtimeState.InNodes) > 0 {\n519: \t\t\t\t\tinNodesRaw := asMapSlice(req[\"inNodeId\"])\n520: \t\t\t\t\tif len(inNodesRaw) > 0 {\n521: \t\t\t\t\t\ttargetPort = asInt(inNodesRaw[0][\"port\"], 0)\n522: \t\t\t\t\t\ttargetProto = defaultString(asString(inNodesRaw[0][\"protocol\"]), \"tcp\")\n523: \t\t\t\t\t}\n524: \t\t\t\t}\n525: \n526: \t\t\t\tif targetPort > 0 && targetAddr != \"\" {\n527: \t\t\t\t\tinNodeRec := runtimeState.Nodes[firstNodeID]\n528: \t\t\t\t\tif err := validateRemoteNodePort(inNodeRec, targetPort); err != nil {\n529: \t\t\t\t\t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n530: \t\t\t\t\t\treturn\n531: \t\t\t\t\t}\n532: \t\t\t\t\tdomainCfg, _ := h.repo.GetConfigByName(\"panel_domain\")\n533: \t\t\t\t\tlocalDomain := \"\"\n534: \t\t\t\t\tif domainCfg != nil {\n535: \t\t\t\t\t\tlocalDomain = domainCfg.Value\n536: \t\t\t\t\t}\n537: \t\t\t\t\t_, err := fc.CreateTunnel(rUrl.String, rToken.String, localDomain, targetProto, targetPort, targetAddr)\n538: \t\t\t\t\tif err != nil {\n539: \t\t\t\t\t\tresponse.WriteJSON(w, response.ErrDefault(\"Remote tunnel creation failed: \"+err.Error()))\n540: \t\t\t\t\t\treturn\n541: \t\t\t\t\t}\n542: \t\t\t\t}\n543: \t\t\t}\n544: \t\t}\n545: \t}\n546: \n547: \tvar tunnelInIP sql.NullString\n548: \tif trimmed := strings.TrimSpace(inIP); trimmed != \"\" {\n549: \t\ttunnelInIP = sql.NullString{String: trimmed, Valid: true}\n550: \t}\n551: \ttunnel := model.Tunnel{\n552: \t\tName: name,\n553: \t\tTrafficRatio: trafficRatio,\n554: \t\tType: typeVal,\n555: \t\tProtocol: \"tls\",\n556: \t\tFlow: flow,\n557: \t\tCreatedTime: now,\n558: \t\tUpdatedTime: now,\n559: \t\tStatus: status,\n560: \t\tInIP: tunnelInIP,\n561: \t\tInx: inx,\n562: \t\tIPPreference: ipPreference,\n563: \t}\n564: \tif err := tx.Create(&tunnel).Error; err != nil {\n565: \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n566: \t\treturn\n567: \t}\n568: \ttunnelID := tunnel.ID\n569: \truntimeState.TunnelID = tunnelID\n570: \tvar federationBindings []repo.FederationTunnelBinding\n571: \tvar federationReleaseRefs []federationRuntimeReleaseRef\n572: \tfederationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState, localDomain)\n573: \tif err != nil {\n574: \t\tresponse.WriteJSON(w, response.ErrDefault(err.Error()))\n575: \t\treturn\n576: \t}\n577: \tapplyTunnelPortsToRequest(req, runtimeState)\n578: \tif err := h.replaceTunnelChainsTx(tx, tunnelID, req); err != nil {\n579: \t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n580: \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n581: \t\treturn\n582: \t}\n583: \tif err := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); err != nil {\n584: \t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n585: \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n586: \t\treturn\n587: \t}\n588: \tif err := tx.Commit().Error; err != nil {\n589: \t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n590: \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n591: \t\treturn\n592: \t}\n593: \tif typeVal == 2 {\n594: \t\tcreatedChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)\n595: \t\tif applyErr != nil {\n596: \t\t\th.rollbackTunnelRuntime(createdChains, createdServices, tunnelID)\n597: \t\t\th.releaseFederationRuntimeRefs(federationReleaseRefs)\n598: \t\t\t_ = h.deleteTunnelByID(tunnelID)\n599: \t\t\tresponse.WriteJSON(w, response.ErrDefault(applyErr.Error()))\n600: \t\t\treturn\n601: \t\t}\n602: \t}\n603: \tresponse.WriteJSON(w, response.OKEmpty())\n604: }\n605: \n606: func (h *Handler) cleanupTunnelRuntime(tunnelID int64) {\n607: \ttunnel, err := h.getTunnelRecord(tunnelID)\n608: \tif err != nil || tunnel.Type != 2 {\n609: \t\treturn\n610: \t}\n611: \tchainRows, err := h.listChainNodesForTunnel(tunnelID)\n612: \tif err != nil {\n613: \t\treturn\n614: \t}\n615: \n616: \tserviceName := fmt.Sprintf(\"%d_tls\", tunnelID)\n617: \tchainName := fmt.Sprintf(\"chains_%d\", tunnelID)\n618: \n619: \tfor _, row := range chainRows {\n620: \t\tif row.ChainType == 1 {\n621: \t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteChains\", map[string]interface{}{\"chain\": chainName}, false, true)\n622: \t\t} else if row.ChainType == 2 {\n623: \t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteChains\", map[string]interface{}{\"chain\": chainName}, false, true)\n624: \t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteService\", map[string]interface{}{\"services\": []string{serviceName}}, false, true)\n625: \t\t} else if row.ChainType == 3 {\n626: \t\t\t_, _ = h.sendNodeCommand(row.NodeID, \"DeleteService\", map[string]interface{}{\"services\": []string{serviceName}}, false, true)\n627: \t\t}\n628: \t}\n629: }\n630: \n631: func (h *Handler) tunnelGet(w http.ResponseWriter, r *http.Request) {\n632: \tif r.Method != http.MethodPost {\n633: \t\tresponse.WriteJSON(w, response.ErrDefault(\"请求失败\"))\n634: \t\treturn\n635: \t}\n636: \tid := idFromBody(r, w)\n637: \tif id <= 0 {\n638: \t\treturn\n639: \t}\n640: \titems, err := h.repo.ListTunnels()\n641: \tif err != nil {\n642: \t\tresponse.WriteJSON(w, response.Err(-2, err.Error()))\n643: \t\treturn\n644: \t}\n645: \tfor _, it := range items {\n646: \t\tif asInt64(it[\"id\"], 0) == id {\n647: \t\t\tresponse.WriteJSON(w, response.OK(it))\n648: \t\t\treturn\n649: \t\t}\n\n(Showing lines 450-649 of 3400. Use offset=650 to continue.)\n</content>",
|
|
"title": "go-backend/internal/http/handler/mutations.go",
|
|
"metadata": {
|
|
"preview": "\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",
|
|
"truncated": true,
|
|
"loaded": []
|
|
},
|
|
"time": {
|
|
"start": 1772444636209,
|
|
"end": 1772444636235
|
|
}
|
|
},
|
|
"id": "prt_cadeed42f001IsGzn6Wksaby64",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadee827a001LvKhV98jyoADuy"
|
|
},
|
|
{
|
|
"type": "step-finish",
|
|
"reason": "tool-calls",
|
|
"snapshot": "73ca391522f6954b1825f9d1b6c4a29d9e2b8eb3",
|
|
"cost": 0,
|
|
"tokens": {
|
|
"total": 34210,
|
|
"input": 24910,
|
|
"output": 212,
|
|
"reasoning": 0,
|
|
"cache": {
|
|
"read": 9088,
|
|
"write": 0
|
|
}
|
|
},
|
|
"id": "prt_cadeed44d001dfqZLDGTFnuj7Z",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadee827a001LvKhV98jyoADuy"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"info": {
|
|
"role": "assistant",
|
|
"time": {
|
|
"created": 1772444636292,
|
|
"completed": 1772444658317
|
|
},
|
|
"parentID": "msg_cadede8e5001rqRhy8g3MBaUkL",
|
|
"modelID": "glm-5",
|
|
"providerID": "zai-coding-plan",
|
|
"mode": "explore",
|
|
"agent": "explore",
|
|
"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": 37832,
|
|
"input": 3713,
|
|
"output": 135,
|
|
"reasoning": 0,
|
|
"cache": {
|
|
"read": 33984,
|
|
"write": 0
|
|
}
|
|
},
|
|
"finish": "tool-calls",
|
|
"id": "msg_cadeed484001LH33mLdBx6cN5q",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP"
|
|
},
|
|
"parts": [
|
|
{
|
|
"type": "step-start",
|
|
"snapshot": "73ca391522f6954b1825f9d1b6c4a29d9e2b8eb3",
|
|
"id": "prt_cadef2a57001RpQVVSKTq6IWBi",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadeed484001LH33mLdBx6cN5q"
|
|
},
|
|
{
|
|
"type": "reasoning",
|
|
"text": "现在让我查看 `tunnelRuntimeNode` 和 `tunnelCreateState` 结构的完整定义,以及 `prepareTunnelCreateState` 函数:",
|
|
"time": {
|
|
"start": 1772444658264,
|
|
"end": 1772444658266
|
|
},
|
|
"id": "prt_cadef2a58001BH9bK9bILFefgn",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadeed484001LH33mLdBx6cN5q"
|
|
},
|
|
{
|
|
"type": "text",
|
|
"text": "让我查看 `tunnelCreateState` 和 `tunnelRuntimeNode` 的结构定义以及 `prepareTunnelCreateState` 函数:",
|
|
"time": {
|
|
"start": 1772444658267,
|
|
"end": 1772444658267
|
|
},
|
|
"id": "prt_cadef2a59001TD9WOGfftdM4PG",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadeed484001LH33mLdBx6cN5q"
|
|
},
|
|
{
|
|
"type": "tool",
|
|
"callID": "call_9f1125bae3b74cd28ac1a3e6",
|
|
"tool": "read",
|
|
"state": {
|
|
"status": "completed",
|
|
"input": {
|
|
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go",
|
|
"limit": 200,
|
|
"offset": 1945
|
|
},
|
|
"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>1945: \t\t_ = h.applyGroupPermission(p[0], p[1])\n1946: \t}\n1947: \treturn nil\n1948: }\n1949: \n1950: type tunnelRuntimeNode struct {\n1951: \tNodeID int64\n1952: \tProtocol string\n1953: \tStrategy string\n1954: \tInx int\n1955: \tChainType int\n1956: \tPort int\n1957: }\n1958: \n1959: type tunnelCreateState struct {\n1960: \tTunnelID int64\n1961: \tType int\n1962: \tIPPreference string // \"\" = auto, \"v4\" = prefer IPv4, \"v6\" = prefer IPv6\n1963: \tInNodes []tunnelRuntimeNode\n1964: \tChainHops [][]tunnelRuntimeNode\n1965: \tOutNodes []tunnelRuntimeNode\n1966: \tNodes map[int64]*nodeRecord\n1967: \tNodeIDList []int64\n1968: }\n1969: \n1970: func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) {\n1971: \tstate := &tunnelCreateState{\n1972: \t\tType: tunnelType,\n1973: \t\tInNodes: make([]tunnelRuntimeNode, 0),\n1974: \t\tChainHops: make([][]tunnelRuntimeNode, 0),\n1975: \t\tOutNodes: make([]tunnelRuntimeNode, 0),\n1976: \t\tNodes: make(map[int64]*nodeRecord),\n1977: \t}\n1978: \tnodeIDs := make([]int64, 0)\n1979: \n1980: \tfor _, item := range asMapSlice(req[\"inNodeId\"]) {\n1981: \t\tnodeID := asInt64(item[\"nodeId\"], 0)\n1982: \t\tif nodeID <= 0 {\n1983: \t\t\tcontinue\n1984: \t\t}\n1985: \t\tnodeIDs = append(nodeIDs, nodeID)\n1986: \t\tstate.InNodes = append(state.InNodes, tunnelRuntimeNode{\n1987: \t\t\tNodeID: nodeID,\n1988: \t\t\tProtocol: defaultString(asString(item[\"protocol\"]), \"tls\"),\n1989: \t\t\tStrategy: defaultString(asString(item[\"strategy\"]), \"round\"),\n1990: \t\t\tChainType: 1,\n1991: \t\t})\n1992: \t}\n1993: \tif len(state.InNodes) == 0 {\n1994: \t\treturn nil, errors.New(\"入口不能为空\")\n1995: \t}\n1996: \n1997: \tif tunnelType == 2 {\n1998: \t\toutNodesRaw := asMapSlice(req[\"outNodeId\"])\n1999: \t\tif len(outNodesRaw) == 0 {\n2000: \t\t\treturn nil, errors.New(\"出口不能为空\")\n2001: \t\t}\n2002: \n2003: \t\tallocated := map[int64]int{}\n2004: \t\tfor _, item := range outNodesRaw {\n2005: \t\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n2006: \t\t\tif nodeID <= 0 {\n2007: \t\t\t\tcontinue\n2008: \t\t\t}\n2009: \t\t\tnodeIDs = append(nodeIDs, nodeID)\n2010: \t\t\tport := asInt(item[\"port\"], 0)\n2011: \t\t\tif port <= 0 {\n2012: \t\t\t\tisRemote, remoteErr := h.repo.IsRemoteNodeTx(tx, nodeID)\n2013: \t\t\t\tif remoteErr != nil {\n2014: \t\t\t\t\treturn nil, remoteErr\n2015: \t\t\t\t}\n2016: \t\t\t\tif !isRemote {\n2017: \t\t\t\t\tvar err error\n2018: \t\t\t\t\tport, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)\n2019: \t\t\t\t\tif err != nil {\n2020: \t\t\t\t\t\treturn nil, err\n2021: \t\t\t\t\t}\n2022: \t\t\t\t}\n2023: \t\t\t}\n2024: \t\t\tstate.OutNodes = append(state.OutNodes, tunnelRuntimeNode{\n2025: \t\t\t\tNodeID: nodeID,\n2026: \t\t\t\tProtocol: defaultString(asString(item[\"protocol\"]), \"tls\"),\n2027: \t\t\t\tStrategy: defaultString(asString(item[\"strategy\"]), \"round\"),\n2028: \t\t\t\tChainType: 3,\n2029: \t\t\t\tPort: port,\n2030: \t\t\t})\n2031: \t\t}\n2032: \t\tif len(state.OutNodes) == 0 {\n2033: \t\t\treturn nil, errors.New(\"出口不能为空\")\n2034: \t\t}\n2035: \n2036: \t\tfor hopIdx, hopRaw := range asAnySlice(req[\"chainNodes\"]) {\n2037: \t\t\thop := make([]tunnelRuntimeNode, 0)\n2038: \t\t\tfor _, item := range asMapSlice(hopRaw) {\n2039: \t\t\t\tnodeID := asInt64(item[\"nodeId\"], 0)\n2040: \t\t\t\tif nodeID <= 0 {\n2041: \t\t\t\t\tcontinue\n2042: \t\t\t\t}\n2043: \t\t\t\tnodeIDs = append(nodeIDs, nodeID)\n2044: \t\t\t\tport := asInt(item[\"port\"], 0)\n2045: \t\t\t\tif port <= 0 {\n2046: \t\t\t\t\tisRemote, remoteErr := h.repo.IsRemoteNodeTx(tx, nodeID)\n2047: \t\t\t\t\tif remoteErr != nil {\n2048: \t\t\t\t\t\treturn nil, remoteErr\n2049: \t\t\t\t\t}\n2050: \t\t\t\t\tif !isRemote {\n2051: \t\t\t\t\t\tvar err error\n2052: \t\t\t\t\t\tport, err = h.repo.PickNodePortTx(tx, nodeID, allocated, excludeTunnelID)\n2053: \t\t\t\t\t\tif err != nil {\n2054: \t\t\t\t\t\t\treturn nil, err\n2055: \t\t\t\t\t\t}\n2056: \t\t\t\t\t}\n2057: \t\t\t\t}\n2058: \t\t\t\thop = append(hop, tunnelRuntimeNode{\n2059: \t\t\t\t\tNodeID: nodeID,\n2060: \t\t\t\t\tProtocol: defaultString(asString(item[\"protocol\"]), \"tls\"),\n2061: \t\t\t\t\tStrategy: defaultString(asString(item[\"strategy\"]), \"round\"),\n2062: \t\t\t\t\tInx: hopIdx + 1,\n2063: \t\t\t\t\tChainType: 2,\n2064: \t\t\t\t\tPort: port,\n2065: \t\t\t\t})\n2066: \t\t\t}\n2067: \t\t\tif len(hop) > 0 {\n2068: \t\t\t\tstate.ChainHops = append(state.ChainHops, hop)\n2069: \t\t\t}\n2070: \t\t}\n2071: \t}\n2072: \n2073: \tseen := make(map[int64]struct{}, len(nodeIDs))\n2074: \tfor _, nodeID := range nodeIDs {\n2075: \t\tif _, ok := seen[nodeID]; ok {\n2076: \t\t\treturn nil, errors.New(\"节点重复\")\n2077: \t\t}\n2078: \t\tseen[nodeID] = struct{}{}\n2079: \t\tstate.NodeIDList = append(state.NodeIDList, nodeID)\n2080: \t\tnode, err := h.repo.GetNodeRecordTx(tx, nodeID)\n2081: \t\tif err != nil {\n2082: \t\t\tif strings.Contains(err.Error(), \"不存在\") {\n2083: \t\t\t\treturn nil, errors.New(\"节点不存在\")\n2084: \t\t\t}\n2085: \t\t\treturn nil, err\n2086: \t\t}\n2087: \t\tif node == nil {\n2088: \t\t\treturn nil, errors.New(\"节点不存在\")\n2089: \t\t}\n2090: \t\tif node.IsRemote != 1 && node.Status != 1 {\n2091: \t\t\treturn nil, errors.New(\"部分节点不在线\")\n2092: \t\t}\n2093: \t\tstate.Nodes[nodeID] = node\n2094: \t}\n2095: \n2096: \tfor _, outNode := range state.OutNodes {\n2097: \t\tif err := validateRemoteNodePort(state.Nodes[outNode.NodeID], outNode.Port); err != nil {\n2098: \t\t\treturn nil, err\n2099: \t\t}\n2100: \t}\n2101: \tfor _, hop := range state.ChainHops {\n2102: \t\tfor _, chainNode := range hop {\n2103: \t\t\tif err := validateRemoteNodePort(state.Nodes[chainNode.NodeID], chainNode.Port); err != nil {\n2104: \t\t\t\treturn nil, err\n2105: \t\t\t}\n2106: \t\t}\n2107: \t}\n2108: \n2109: \treturn state, nil\n2110: }\n2111: \n2112: func buildTunnelInIP(inNodes []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) string {\n2113: \tset := make(map[string]struct{})\n2114: \tordered := make([]string, 0)\n2115: \tpreferV6 := strings.TrimSpace(ipPreference) == \"v6\"\n2116: \tfor _, inNode := range inNodes {\n2117: \t\tnode := nodes[inNode.NodeID]\n2118: \t\tif node == nil {\n2119: \t\t\tcontinue\n2120: \t\t}\n2121: \t\tv4 := strings.TrimSpace(node.ServerIPv4)\n2122: \t\tv6 := strings.TrimSpace(node.ServerIPv6)\n2123: \t\tvar addrs []string\n2124: \t\tif preferV6 {\n2125: \t\t\tif v6 != \"\" {\n2126: \t\t\t\taddrs = append(addrs, v6)\n2127: \t\t\t}\n2128: \t\t\tif v4 != \"\" {\n2129: \t\t\t\taddrs = append(addrs, v4)\n2130: \t\t\t}\n2131: \t\t} else {\n2132: \t\t\tif v4 != \"\" {\n2133: \t\t\t\taddrs = append(addrs, v4)\n2134: \t\t\t}\n2135: \t\t\tif v6 != \"\" {\n2136: \t\t\t\taddrs = append(addrs, v6)\n2137: \t\t\t}\n2138: \t\t}\n2139: \t\tif len(addrs) == 0 {\n2140: \t\t\tif v := strings.TrimSpace(node.ServerIP); v != \"\" {\n2141: \t\t\t\taddrs = append(addrs, v)\n2142: \t\t\t}\n2143: \t\t}\n2144: \t\tfor _, a := range addrs {\n\n(Showing lines 1945-2144 of 3400. Use offset=2145 to continue.)\n</content>",
|
|
"title": "go-backend/internal/http/handler/mutations.go",
|
|
"metadata": {
|
|
"preview": "\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}\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",
|
|
"truncated": true,
|
|
"loaded": []
|
|
},
|
|
"time": {
|
|
"start": 1772444658266,
|
|
"end": 1772444658267
|
|
}
|
|
},
|
|
"id": "prt_cadef2a5a001Npd6WP2LZj7qcU",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadeed484001LH33mLdBx6cN5q"
|
|
},
|
|
{
|
|
"type": "step-finish",
|
|
"reason": "tool-calls",
|
|
"snapshot": "73ca391522f6954b1825f9d1b6c4a29d9e2b8eb3",
|
|
"cost": 0,
|
|
"tokens": {
|
|
"total": 37832,
|
|
"input": 3713,
|
|
"output": 135,
|
|
"reasoning": 0,
|
|
"cache": {
|
|
"read": 33984,
|
|
"write": 0
|
|
}
|
|
},
|
|
"id": "prt_cadef2a5b001L5nu0becmsjn7w",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadeed484001LH33mLdBx6cN5q"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"info": {
|
|
"role": "assistant",
|
|
"time": {
|
|
"created": 1772444658318,
|
|
"completed": 1772444676909
|
|
},
|
|
"parentID": "msg_cadede8e5001rqRhy8g3MBaUkL",
|
|
"modelID": "glm-5",
|
|
"providerID": "zai-coding-plan",
|
|
"mode": "explore",
|
|
"agent": "explore",
|
|
"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": 40469,
|
|
"input": 2508,
|
|
"output": 265,
|
|
"reasoning": 0,
|
|
"cache": {
|
|
"read": 37696,
|
|
"write": 0
|
|
}
|
|
},
|
|
"finish": "tool-calls",
|
|
"id": "msg_cadef2a8e0013vuBe1vAHb3AFp",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP"
|
|
},
|
|
"parts": [
|
|
{
|
|
"type": "step-start",
|
|
"snapshot": "73ca391522f6954b1825f9d1b6c4a29d9e2b8eb3",
|
|
"id": "prt_cadef58f90012UWhp1Znm3EtXM",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadef2a8e0013vuBe1vAHb3AFp"
|
|
},
|
|
{
|
|
"type": "reasoning",
|
|
"text": "现在我有了很好的理解。让我总结一下隧道链路构建时下一跳连接地址的选择流程:\n\n1. **核心函数**: `applyTunnelRuntime` (mutations.go:2454)\n2. **配置构建**: `buildTunnelChainConfig` (mutations.go:2567)\n3. **地址选择**: `selectTunnelDialHost` (mutations.go:2654)\n\n让我也查看一下 `ChainTunnel` 表的 `Port` 字段是如何被使用的,特别是在 `replaceTunnelChainsTx` 中:",
|
|
"time": {
|
|
"start": 1772444670201,
|
|
"end": 1772444676828
|
|
},
|
|
"id": "prt_cadef58f90020w0dIutWpvWGuW",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadef2a8e0013vuBe1vAHb3AFp"
|
|
},
|
|
{
|
|
"type": "text",
|
|
"text": "现在让我查看 `ChainTunnel` 的 `Port` 字段在数据库读取时如何被使用:",
|
|
"time": {
|
|
"start": 1772444676828,
|
|
"end": 1772444676828
|
|
},
|
|
"id": "prt_cadef72d0001bkNTaT7mYLo2Fo",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadef2a8e0013vuBe1vAHb3AFp"
|
|
},
|
|
{
|
|
"type": "tool",
|
|
"callID": "call_5c52ed5561c24932ae58acf2",
|
|
"tool": "read",
|
|
"state": {
|
|
"status": "completed",
|
|
"input": {
|
|
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_control.go"
|
|
},
|
|
"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>1: package repo\n2: \n3: import (\n4: \t\"database/sql\"\n5: \t\"errors\"\n6: \t\"fmt\"\n7: \t\"strconv\"\n8: \t\"strings\"\n9: \n10: \t\"gorm.io/gorm\"\n11: \n12: \t\"go-backend/internal/store/model\"\n13: )\n14: \n15: func (r *Repository) UserTunnelExistsByUserAndTunnel(userID, tunnelID int64) (bool, error) {\n16: \tif r == nil || r.db == nil {\n17: \t\treturn false, errors.New(\"repository not initialized\")\n18: \t}\n19: \tvar count int64\n20: \terr := r.db.Model(&model.UserTunnel{}).\n21: \t\tWhere(\"user_id = ? AND tunnel_id = ? AND status = 1\", userID, tunnelID).\n22: \t\tCount(&count).Error\n23: \tif err != nil {\n24: \t\treturn false, err\n25: \t}\n26: \treturn count > 0, nil\n27: }\n28: \n29: func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord, error) {\n30: \tif r == nil || r.db == nil {\n31: \t\treturn nil, errors.New(\"repository not initialized\")\n32: \t}\n33: \tvar forwards []model.Forward\n34: \terr := r.db.Where(\"tunnel_id = ?\", tunnelID).Order(\"id ASC\").Find(&forwards).Error\n35: \tif err != nil {\n36: \t\treturn nil, err\n37: \t}\n38: \trows := make([]model.ForwardRecord, 0, len(forwards))\n39: \tfor _, f := range forwards {\n40: \t\trows = append(rows, model.ForwardRecord{\n41: \t\t\tID: f.ID,\n42: \t\t\tUserID: f.UserID,\n43: \t\t\tUserName: f.UserName,\n44: \t\t\tName: f.Name,\n45: \t\t\tTunnelID: f.TunnelID,\n46: \t\t\tRemoteAddr: f.RemoteAddr,\n47: \t\t\tStrategy: f.Strategy,\n48: \t\t\tStatus: f.Status,\n49: \t\t\tSpeedID: f.SpeedID,\n50: \t\t})\n51: \t}\n52: \tfor i := range rows {\n53: \t\tif strings.TrimSpace(rows[i].Strategy) == \"\" {\n54: \t\t\trows[i].Strategy = \"fifo\"\n55: \t\t}\n56: \t}\n57: \treturn rows, nil\n58: }\n59: \n60: func (r *Repository) ListActiveTunnelIDsByNode(nodeID int64) ([]int64, error) {\n61: \tif r == nil || r.db == nil {\n62: \t\treturn nil, errors.New(\"repository not initialized\")\n63: \t}\n64: \tvar ids []int64\n65: \terr := r.db.Model(&model.ChainTunnel{}).\n66: \t\tJoins(\"JOIN tunnel ON tunnel.id = chain_tunnel.tunnel_id\").\n67: \t\tWhere(\"chain_tunnel.node_id = ? AND tunnel.status = 1\", nodeID).\n68: \t\tSelect(\"DISTINCT chain_tunnel.tunnel_id\").\n69: \t\tOrder(\"chain_tunnel.tunnel_id ASC\").\n70: \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\trows = append(rows, model.ForwardPortRecord{NodeID: p.NodeID, Port: p.Port})\n106: \t}\n107: \treturn rows, nil\n108: }\n109: \n110: func (r *Repository) GetTunnelOutProtocol(tunnelID int64) (string, error) {\n111: \tif r == nil || r.db == nil {\n112: \t\treturn \"\", errors.New(\"repository not initialized\")\n113: \t}\n114: \tvar ct model.ChainTunnel\n115: \terr := r.db.Select(\"protocol\").\n116: \t\tWhere(\"tunnel_id = ? AND chain_type = ?\", tunnelID, \"3\").\n117: \t\tOrder(\"id ASC\").\n118: \t\tTake(&ct).Error\n119: \tif err != nil {\n120: \t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n121: \t\t\treturn \"\", nil\n122: \t\t}\n123: \t\treturn \"\", err\n124: \t}\n125: \tif ct.Protocol.Valid {\n126: \t\treturn ct.Protocol.String, nil\n127: \t}\n128: \treturn \"\", nil\n129: }\n130: \n131: func (r *Repository) GetNodeRecord(nodeID int64) (*model.NodeRecord, error) {\n132: \tif r == nil || r.db == nil {\n133: \t\treturn nil, errors.New(\"repository not initialized\")\n134: \t}\n135: \tvar n model.Node\n136: \terr := r.db.Where(\"id = ?\", nodeID).First(&n).Error\n137: \tif errors.Is(err, gorm.ErrRecordNotFound) {\n138: \t\treturn nil, nil\n139: \t}\n140: \tif err != nil {\n141: \t\treturn nil, err\n142: \t}\n143: \treturn nodeRecordFromModel(&n), nil\n144: }\n145: \n146: func (r *Repository) GetNodeRecordTx(tx *gorm.DB, nodeID int64) (*model.NodeRecord, error) {\n147: \tif tx == nil {\n148: \t\treturn nil, errors.New(\"database unavailable\")\n149: \t}\n150: \tvar n model.Node\n151: \terr := tx.Where(\"id = ?\", nodeID).First(&n).Error\n152: \tif errors.Is(err, gorm.ErrRecordNotFound) {\n153: \t\treturn nil, nil\n154: \t}\n155: \tif err != nil {\n156: \t\treturn nil, err\n157: \t}\n158: \treturn nodeRecordFromModel(&n), nil\n159: }\n160: \n161: func nodeRecordFromModel(n *model.Node) *model.NodeRecord {\n162: \tif n == nil {\n163: \t\treturn nil\n164: \t}\n165: \trec := &model.NodeRecord{\n166: \t\tID: n.ID,\n167: \t\tName: n.Name,\n168: \t\tServerIP: n.ServerIP,\n169: \t\tStatus: n.Status,\n170: \t\tPortRange: n.Port,\n171: \t\tTCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,\n172: \t\tIsRemote: n.IsRemote,\n173: \t}\n174: \tif n.ServerIPV4.Valid {\n175: \t\trec.ServerIPv4 = strings.TrimSpace(n.ServerIPV4.String)\n176: \t}\n177: \tif n.ServerIPV6.Valid {\n178: \t\trec.ServerIPv6 = strings.TrimSpace(n.ServerIPV6.String)\n179: \t}\n180: \tif n.InterfaceName.Valid {\n181: \t\trec.InterfaceName = strings.TrimSpace(n.InterfaceName.String)\n182: \t}\n183: \tif n.RemoteURL.Valid {\n184: \t\trec.RemoteURL = strings.TrimSpace(n.RemoteURL.String)\n185: \t}\n186: \tif n.RemoteToken.Valid {\n187: \t\trec.RemoteToken = strings.TrimSpace(n.RemoteToken.String)\n188: \t}\n189: \tif n.RemoteConfig.Valid {\n190: \t\trec.RemoteConfig = strings.TrimSpace(n.RemoteConfig.String)\n191: \t}\n192: \tif rec.TCPListenAddr == \"\" {\n193: \t\trec.TCPListenAddr = \"[::]\"\n194: \t}\n195: \tif rec.UDPListenAddr == \"\" {\n196: \t\trec.UDPListenAddr = \"[::]\"\n197: \t}\n198: \tif strings.TrimSpace(rec.Name) == \"\" {\n199: \t\trec.Name = fmt.Sprintf(\"node_%d\", rec.ID)\n200: \t}\n201: \treturn rec\n202: }\n203: \n204: func (r *Repository) ResolveUserTunnelAndLimiter(userID, tunnelID int64) (*model.UserTunnelLimiterInfo, error) {\n205: \tif r == nil || r.db == nil {\n206: \t\treturn nil, errors.New(\"repository not initialized\")\n207: \t}\n208: \ttype row struct {\n209: \t\tUserTunnelID int64 `gorm:\"column:user_tunnel_id\"`\n210: \t\tLimiterID sql.NullInt64 `gorm:\"column:limiter_id\"`\n211: \t\tSpeed sql.NullInt64 `gorm:\"column:speed\"`\n212: \t}\n213: \tvar rec row\n214: \terr := r.db.Model(&model.UserTunnel{}).\n215: \t\tSelect(\"user_tunnel.id AS user_tunnel_id, speed_limit.id AS limiter_id, speed_limit.speed AS speed\").\n216: \t\tJoins(\"LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id\").\n217: \t\tWhere(\"user_tunnel.user_id = ? AND user_tunnel.tunnel_id = ?\", userID, tunnelID).\n218: \t\tOrder(\"user_tunnel.id ASC\").\n219: \t\tLimit(1).\n220: \t\tTake(&rec).Error\n221: \tif err != nil {\n222: \t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n223: \t\t\treturn &model.UserTunnelLimiterInfo{}, nil\n224: \t\t}\n225: \t\treturn nil, err\n226: \t}\n227: \tinfo := &model.UserTunnelLimiterInfo{UserTunnelID: rec.UserTunnelID}\n228: \tif rec.LimiterID.Valid && rec.LimiterID.Int64 > 0 {\n229: \t\tv := rec.LimiterID.Int64\n230: \t\tinfo.LimiterID = &v\n231: \t\ts := int(rec.Speed.Int64)\n232: \t\tinfo.Speed = &s\n233: \t}\n234: \treturn info, nil\n235: }\n236: \n237: func (r *Repository) ListUserTunnelIDs(userID, tunnelID int64) ([]int64, error) {\n238: \tif r == nil || r.db == nil {\n239: \t\treturn nil, errors.New(\"repository not initialized\")\n240: \t}\n241: \tvar ids []int64\n242: \terr := r.db.Model(&model.UserTunnel{}).\n243: \t\tWhere(\"user_id = ? AND tunnel_id = ?\", userID, tunnelID).\n244: \t\tOrder(\"id ASC\").Pluck(\"id\", &ids).Error\n245: \tif err != nil {\n246: \t\treturn nil, err\n247: \t}\n248: \treturn ids, nil\n249: }\n250: \n251: func (r *Repository) ListUserTunnelIDsByUser(userID int64) ([]int64, error) {\n252: \tif r == nil || r.db == nil {\n253: \t\treturn nil, errors.New(\"repository not initialized\")\n254: \t}\n255: \tvar ids []int64\n256: \terr := r.db.Model(&model.UserTunnel{}).\n257: \t\tWhere(\"user_id = ?\", userID).\n258: \t\tOrder(\"id ASC\").Pluck(\"id\", &ids).Error\n259: \tif err != nil {\n260: \t\treturn nil, err\n261: \t}\n262: \treturn ids, nil\n263: }\n264: \n265: func (r *Repository) GetTunnelName(tunnelID int64) (string, error) {\n266: \tif r == nil || r.db == nil {\n267: \t\treturn \"\", errors.New(\"repository not initialized\")\n268: \t}\n269: \tvar name string\n270: \terr := r.db.Model(&model.Tunnel{}).Where(\"id = ?\", tunnelID).Pluck(\"name\", &name).Error\n271: \tif err != nil {\n272: \t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n273: \t\t\treturn \"\", nil\n274: \t\t}\n275: \t\treturn \"\", err\n276: \t}\n277: \treturn name, nil\n278: }\n279: \n280: func (r *Repository) ListChainNodesForTunnel(tunnelID int64) ([]model.ChainNodeRecord, error) {\n281: \tif r == nil || r.db == nil {\n282: \t\treturn nil, errors.New(\"repository not initialized\")\n283: \t}\n284: \ttype row struct {\n285: \t\tChainType string\n286: \t\tInx sql.NullInt64\n287: \t\tNodeID int64\n288: \t\tPort sql.NullInt64\n289: \t\tName sql.NullString\n290: \t\tProtocol sql.NullString\n291: \t\tStrategy sql.NullString\n292: \t}\n293: \tvar rows []row\n294: \terr := r.db.Model(&model.ChainTunnel{}).\n295: \t\tSelect(\"chain_tunnel.chain_type, chain_tunnel.inx, chain_tunnel.node_id, chain_tunnel.port, node.name, chain_tunnel.protocol, chain_tunnel.strategy\").\n296: \t\tJoins(\"LEFT JOIN node ON node.id = chain_tunnel.node_id\").\n297: \t\tWhere(\"chain_tunnel.tunnel_id = ?\", tunnelID).\n298: \t\tOrder(\"chain_tunnel.chain_type ASC, chain_tunnel.inx ASC, chain_tunnel.id ASC\").\n299: \t\tFind(&rows).Error\n300: \tif err != nil {\n301: \t\treturn nil, err\n302: \t}\n303: \tresult := make([]model.ChainNodeRecord, 0, len(rows))\n304: \tfor _, row := range rows {\n305: \t\tchainType := 0\n306: \t\tif v := strings.TrimSpace(row.ChainType); v != \"\" {\n307: \t\t\tif parsed, parseErr := strconv.Atoi(v); parseErr == nil {\n308: \t\t\t\tchainType = parsed\n309: \t\t\t}\n310: \t\t}\n311: \t\tinx := int64(0)\n312: \t\tif row.Inx.Valid {\n313: \t\t\tinx = row.Inx.Int64\n314: \t\t}\n315: \t\tport := 0\n316: \t\tif row.Port.Valid {\n317: \t\t\tport = int(row.Port.Int64)\n318: \t\t}\n319: \t\titem := model.ChainNodeRecord{\n320: \t\t\tChainType: chainType,\n321: \t\t\tInx: inx,\n322: \t\t\tNodeID: row.NodeID,\n323: \t\t\tPort: port,\n324: \t\t}\n325: \t\tif strings.TrimSpace(row.Name.String) == \"\" {\n326: \t\t\titem.NodeName = fmt.Sprintf(\"node_%d\", row.NodeID)\n327: \t\t} else {\n328: \t\t\titem.NodeName = row.Name.String\n329: \t\t}\n330: \t\tif strings.TrimSpace(row.Protocol.String) == \"\" {\n331: \t\t\titem.Protocol = \"tls\"\n332: \t\t} else {\n333: \t\t\titem.Protocol = row.Protocol.String\n334: \t\t}\n335: \t\tif strings.TrimSpace(row.Strategy.String) == \"\" {\n336: \t\t\titem.Strategy = \"round\"\n337: \t\t} else {\n338: \t\t\titem.Strategy = row.Strategy.String\n339: \t\t}\n340: \t\tresult = append(result, item)\n341: \t}\n342: \treturn result, nil\n343: }\n\n(End of file - total 343 lines)\n</content>",
|
|
"title": "go-backend/internal/store/repo/repository_control.go",
|
|
"metadata": {
|
|
"preview": "package repo\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"gorm.io/gorm\"\n\n\t\"go-backend/internal/store/model\"\n)\n\nfunc (r *Repository) UserTunnelExistsByUserAndTunnel(userID, tunnelID int64) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.UserTunnel{}).",
|
|
"truncated": false,
|
|
"loaded": []
|
|
},
|
|
"time": {
|
|
"start": 1772444676822,
|
|
"end": 1772444676832
|
|
}
|
|
},
|
|
"id": "prt_cadef72d3001RzYisUG9G7IJmO",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadef2a8e0013vuBe1vAHb3AFp"
|
|
},
|
|
{
|
|
"type": "tool",
|
|
"callID": "call_bfa3c17c51354f79a33d7ec6",
|
|
"tool": "read",
|
|
"state": {
|
|
"status": "completed",
|
|
"input": {
|
|
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go",
|
|
"limit": 100,
|
|
"offset": 840
|
|
},
|
|
"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>840: \t\t}\n841: \t}\n842: \tresponse.WriteJSON(w, response.OK(map[string]interface{}{\"successCount\": success, \"failCount\": fail}))\n843: }\n844: \n845: func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, error) {\n846: \ttunnel, err := h.getTunnelRecord(tunnelID)\n847: \tif err != nil {\n848: \t\treturn nil, err\n849: \t}\n850: \n851: \tchainRows, err := h.listChainNodesForTunnel(tunnelID)\n852: \tif err != nil {\n853: \t\treturn nil, err\n854: \t}\n855: \n856: \tipPreference := h.repo.GetTunnelIPPreference(tunnelID)\n857: \n858: \tstate := &tunnelCreateState{\n859: \t\tTunnelID: tunnelID,\n860: \t\tType: tunnel.Type,\n861: \t\tIPPreference: ipPreference,\n862: \t\tInNodes: make([]tunnelRuntimeNode, 0),\n863: \t\tChainHops: make([][]tunnelRuntimeNode, 0),\n864: \t\tOutNodes: make([]tunnelRuntimeNode, 0),\n865: \t\tNodes: make(map[int64]*nodeRecord),\n866: \t\tNodeIDList: make([]int64, 0),\n867: \t}\n868: \n869: \tinNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)\n870: \n871: \tfor _, r := range inNodes {\n872: \t\tstate.InNodes = append(state.InNodes, tunnelRuntimeNode{\n873: \t\t\tNodeID: r.NodeID,\n874: \t\t\tProtocol: r.Protocol,\n875: \t\t\tStrategy: r.Strategy,\n876: \t\t\tChainType: 1,\n877: \t\t})\n878: \t\tstate.NodeIDList = append(state.NodeIDList, r.NodeID)\n879: \t}\n880: \n881: \tfor _, r := range outNodes {\n882: \t\tstate.OutNodes = append(state.OutNodes, tunnelRuntimeNode{\n883: \t\t\tNodeID: r.NodeID,\n884: \t\t\tProtocol: r.Protocol,\n885: \t\t\tStrategy: r.Strategy,\n886: \t\t\tChainType: 3,\n887: \t\t\tPort: r.Port,\n888: \t\t})\n889: \t\tstate.NodeIDList = append(state.NodeIDList, r.NodeID)\n890: \t}\n891: \n892: \tfor _, hop := range chainHops {\n893: \t\tstateHop := make([]tunnelRuntimeNode, 0)\n894: \t\tfor _, r := range hop {\n895: \t\t\tstateHop = append(stateHop, tunnelRuntimeNode{\n896: \t\t\t\tNodeID: r.NodeID,\n897: \t\t\t\tProtocol: r.Protocol,\n898: \t\t\t\tStrategy: r.Strategy,\n899: \t\t\t\tChainType: 2,\n900: \t\t\t\tInx: int(r.Inx),\n901: \t\t\t\tPort: r.Port,\n902: \t\t\t})\n903: \t\t\tstate.NodeIDList = append(state.NodeIDList, r.NodeID)\n904: \t\t}\n905: \t\tstate.ChainHops = append(state.ChainHops, stateHop)\n906: \t}\n907: \n908: \tseen := make(map[int64]struct{})\n909: \tfor _, id := range state.NodeIDList {\n910: \t\tif _, ok := seen[id]; ok {\n911: \t\t\tcontinue\n912: \t\t}\n913: \t\tseen[id] = struct{}{}\n914: \t\tnode, err := h.getNodeRecord(id)\n915: \t\tif err != nil {\n916: \t\t\treturn nil, err\n917: \t\t}\n918: \t\tstate.Nodes[id] = node\n919: \t}\n920: \n921: \treturn state, nil\n922: }\n923: \n924: func (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {\n925: \ttunnel, err := h.getTunnelRecord(tunnelID)\n926: \tif err != nil {\n927: \t\treturn err\n928: \t}\n929: \n930: \tif tunnel.Type == 2 {\n931: \t\th.cleanupTunnelRuntime(tunnelID)\n932: \t\th.cleanupFederationRuntime(tunnelID)\n933: \t\tstate, err := h.reconstructTunnelState(tunnelID)\n934: \t\tif err != nil {\n935: \t\t\treturn err\n936: \t\t}\n937: \t\tfederationBindings, federationReleaseRefs, fedErr := h.applyFederationRuntime(state, h.federationLocalDomain())\n938: \t\tif fedErr != nil {\n939: \t\t\treturn fedErr\n\n(Showing lines 840-939 of 3400. Use offset=940 to continue.)\n</content>",
|
|
"title": "go-backend/internal/http/handler/mutations.go",
|
|
"metadata": {
|
|
"preview": "\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,",
|
|
"truncated": true,
|
|
"loaded": []
|
|
},
|
|
"time": {
|
|
"start": 1772444676826,
|
|
"end": 1772444676834
|
|
}
|
|
},
|
|
"id": "prt_cadef72d80018yCONP7rV5u3Ak",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadef2a8e0013vuBe1vAHb3AFp"
|
|
},
|
|
{
|
|
"type": "step-finish",
|
|
"reason": "tool-calls",
|
|
"snapshot": "73ca391522f6954b1825f9d1b6c4a29d9e2b8eb3",
|
|
"cost": 0,
|
|
"tokens": {
|
|
"total": 40469,
|
|
"input": 2508,
|
|
"output": 265,
|
|
"reasoning": 0,
|
|
"cache": {
|
|
"read": 37696,
|
|
"write": 0
|
|
}
|
|
},
|
|
"id": "prt_cadef72e2001YfVkp8NOeSKL53",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadef2a8e0013vuBe1vAHb3AFp"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"info": {
|
|
"role": "assistant",
|
|
"time": {
|
|
"created": 1772444676910,
|
|
"completed": 1772444689145
|
|
},
|
|
"parentID": "msg_cadede8e5001rqRhy8g3MBaUkL",
|
|
"modelID": "glm-5",
|
|
"providerID": "zai-coding-plan",
|
|
"mode": "explore",
|
|
"agent": "explore",
|
|
"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": 45521,
|
|
"input": 5271,
|
|
"output": 58,
|
|
"reasoning": 0,
|
|
"cache": {
|
|
"read": 40192,
|
|
"write": 0
|
|
}
|
|
},
|
|
"finish": "tool-calls",
|
|
"id": "msg_cadef732e001LHp3t2nV3Sw2TO",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP"
|
|
},
|
|
"parts": [
|
|
{
|
|
"type": "step-start",
|
|
"snapshot": "73ca391522f6954b1825f9d1b6c4a29d9e2b8eb3",
|
|
"id": "prt_cadefa2c6001rMXc5Bd2OJAY4z",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadef732e001LHp3t2nV3Sw2TO"
|
|
},
|
|
{
|
|
"type": "reasoning",
|
|
"text": "现在我已经有了完整的理解。让我总结一下隧道链路构建时下一跳连接地址的选择流程。",
|
|
"time": {
|
|
"start": 1772444689095,
|
|
"end": 1772444689096
|
|
},
|
|
"id": "prt_cadefa2c7001hzh8b6dUTMRbMo",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadef732e001LHp3t2nV3Sw2TO"
|
|
},
|
|
{
|
|
"type": "text",
|
|
"text": "现在让我查看 `ChainNodeRecord` 模型定义以完成分析:",
|
|
"time": {
|
|
"start": 1772444689096,
|
|
"end": 1772444689096
|
|
},
|
|
"id": "prt_cadefa2c7002zIeCZq3Xgpvjce",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadef732e001LHp3t2nV3Sw2TO"
|
|
},
|
|
{
|
|
"type": "tool",
|
|
"callID": "call_4fa9f48717df454ea955249e",
|
|
"tool": "grep",
|
|
"state": {
|
|
"status": "completed",
|
|
"input": {
|
|
"include": "*.go",
|
|
"pattern": "ChainNodeRecord struct"
|
|
},
|
|
"output": "Found 1 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go:\n Line 533: type ChainNodeRecord struct {",
|
|
"title": "ChainNodeRecord struct",
|
|
"metadata": {
|
|
"matches": 1,
|
|
"truncated": false
|
|
},
|
|
"time": {
|
|
"start": 1772444689096,
|
|
"end": 1772444689097
|
|
}
|
|
},
|
|
"id": "prt_cadefa2c8001o0XK8RlcTGdFfv",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadef732e001LHp3t2nV3Sw2TO"
|
|
},
|
|
{
|
|
"type": "step-finish",
|
|
"reason": "tool-calls",
|
|
"snapshot": "73ca391522f6954b1825f9d1b6c4a29d9e2b8eb3",
|
|
"cost": 0,
|
|
"tokens": {
|
|
"total": 45521,
|
|
"input": 5271,
|
|
"output": 58,
|
|
"reasoning": 0,
|
|
"cache": {
|
|
"read": 40192,
|
|
"write": 0
|
|
}
|
|
},
|
|
"id": "prt_cadefa2c9001G1XKb45ePVpj12",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadef732e001LHp3t2nV3Sw2TO"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"info": {
|
|
"role": "assistant",
|
|
"time": {
|
|
"created": 1772444689146,
|
|
"completed": 1772444705144
|
|
},
|
|
"parentID": "msg_cadede8e5001rqRhy8g3MBaUkL",
|
|
"modelID": "glm-5",
|
|
"providerID": "zai-coding-plan",
|
|
"mode": "explore",
|
|
"agent": "explore",
|
|
"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": 45671,
|
|
"input": 151,
|
|
"output": 80,
|
|
"reasoning": 0,
|
|
"cache": {
|
|
"read": 45440,
|
|
"write": 0
|
|
}
|
|
},
|
|
"finish": "tool-calls",
|
|
"id": "msg_cadefa2fa001f3p16nCXRDGPgy",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP"
|
|
},
|
|
"parts": [
|
|
{
|
|
"type": "step-start",
|
|
"snapshot": "73ca391522f6954b1825f9d1b6c4a29d9e2b8eb3",
|
|
"id": "prt_cadefe144001fnm5ynJm2Nb6z2",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadefa2fa001f3p16nCXRDGPgy"
|
|
},
|
|
{
|
|
"type": "reasoning",
|
|
"text": "Let me read the ChainNodeRecord definition.",
|
|
"time": {
|
|
"start": 1772444705093,
|
|
"end": 1772444705094
|
|
},
|
|
"id": "prt_cadefe145001wPQboJHEA3cA0v",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadefa2fa001f3p16nCXRDGPgy"
|
|
},
|
|
{
|
|
"type": "tool",
|
|
"callID": "call_6b42031586434a46913f8007",
|
|
"tool": "read",
|
|
"state": {
|
|
"status": "completed",
|
|
"input": {
|
|
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go",
|
|
"limit": 30,
|
|
"offset": 530
|
|
},
|
|
"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>530: \tRemoteConfig string\n531: }\n532: \n533: type ChainNodeRecord struct {\n534: \tChainType int\n535: \tInx int64\n536: \tNodeID int64\n537: \tPort int\n538: \tNodeName string\n539: \tProtocol string\n540: \tStrategy string\n541: }\n542: \n543: type UserTunnelLimiterInfo struct {\n544: \tUserTunnelID int64\n545: \tLimiterID *int64\n546: \tSpeed *int\n547: }\n548: \n549: // UserFlowSnapshot holds a user's current flow counters (used by stats job).\n550: type UserFlowSnapshot struct {\n551: \tUserID int64\n552: \tInFlow int64\n553: \tOutFlow int64\n554: }\n555: \n556: // ExpiredUserTunnel holds minimal info for an expired user_tunnel row.\n557: type ExpiredUserTunnel struct {\n558: \tID int64\n559: \tUserID int64\n\n(Showing lines 530-559 of 594. Use offset=560 to continue.)\n</content>",
|
|
"title": "go-backend/internal/store/model/model.go",
|
|
"metadata": {
|
|
"preview": "\tRemoteConfig string\n}\n\ntype ChainNodeRecord struct {\n\tChainType int\n\tInx int64\n\tNodeID int64\n\tPort int\n\tNodeName string\n\tProtocol string\n\tStrategy string\n}\n\ntype UserTunnelLimiterInfo struct {\n\tUserTunnelID int64\n\tLimiterID *int64\n\tSpeed *int\n}\n\n// UserFlowSnapshot holds a user's current flow counters (used by stats job).",
|
|
"truncated": true,
|
|
"loaded": []
|
|
},
|
|
"time": {
|
|
"start": 1772444705094,
|
|
"end": 1772444705094
|
|
}
|
|
},
|
|
"id": "prt_cadefe145002VCK9RKfMuOSK4J",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadefa2fa001f3p16nCXRDGPgy"
|
|
},
|
|
{
|
|
"type": "step-finish",
|
|
"reason": "tool-calls",
|
|
"snapshot": "73ca391522f6954b1825f9d1b6c4a29d9e2b8eb3",
|
|
"cost": 0,
|
|
"tokens": {
|
|
"total": 45671,
|
|
"input": 151,
|
|
"output": 80,
|
|
"reasoning": 0,
|
|
"cache": {
|
|
"read": 45440,
|
|
"write": 0
|
|
}
|
|
},
|
|
"id": "prt_cadefe1460010paVsfAc0iPzf1",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadefa2fa001f3p16nCXRDGPgy"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"info": {
|
|
"role": "assistant",
|
|
"time": {
|
|
"created": 1772444705145,
|
|
"completed": 1772444774988
|
|
},
|
|
"parentID": "msg_cadede8e5001rqRhy8g3MBaUkL",
|
|
"modelID": "glm-5",
|
|
"providerID": "zai-coding-plan",
|
|
"mode": "explore",
|
|
"agent": "explore",
|
|
"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": 47720,
|
|
"input": 462,
|
|
"output": 1690,
|
|
"reasoning": 0,
|
|
"cache": {
|
|
"read": 45568,
|
|
"write": 0
|
|
}
|
|
},
|
|
"finish": "stop",
|
|
"id": "msg_cadefe1790015yJYbwqLvg0Kk4",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP"
|
|
},
|
|
"parts": [
|
|
{
|
|
"type": "step-start",
|
|
"snapshot": "73ca391522f6954b1825f9d1b6c4a29d9e2b8eb3",
|
|
"id": "prt_cadf04882001KFe1qUTGdxkTYw",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadefe1790015yJYbwqLvg0Kk4"
|
|
},
|
|
{
|
|
"type": "reasoning",
|
|
"text": "现在我已经有了完整的理解。让我总结一下隧道链路构建时下一跳连接地址的选择流程。",
|
|
"time": {
|
|
"start": 1772444731522,
|
|
"end": 1772444774901
|
|
},
|
|
"id": "prt_cadf048820027XimN4e47n507H",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadefe1790015yJYbwqLvg0Kk4"
|
|
},
|
|
{
|
|
"type": "text",
|
|
"text": "根据我的分析,以下是隧道链路构建时下一跳连接地址选择流程的完整报告:\n\n---\n\n## 隧道链路构建流程分析报告\n\n### 1. 隧道链路配置发送到节点的代码位置\n\n**主函数**: `applyTunnelRuntime()` \n- 文件: `go-backend/internal/http/handler/mutations.go`\n- 行号: 2454-2521\n\n**核心流程**:\n\n```\napplyTunnelRuntime (L2454)\n├── 入口节点: 发送 AddChains 命令 (L2474)\n├── 跳转节点: 发送 AddChains (L2496) + AddService (L2502) 命令\n└── 出口节点: 发送 AddService 命令 (L2514)\n```\n\n**配置构建函数**:\n| 函数 | 位置 | 作用 |\n|------|------|------|\n| `buildTunnelChainConfig()` | L2567-2624 | 构建转发链配置 |\n| `buildTunnelChainServiceConfig()` | L2626-2652 | 构建服务配置 |\n| `selectTunnelDialHost()` | L2654-2699 | **选择下一跳连接地址** |\n\n---\n\n### 2. 下一跳连接地址是如何确定的\n\n**地址选择核心函数**: `selectTunnelDialHost()` (L2654-2699)\n\n```go\n// 关键逻辑 (简化版)\nfunc selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string) (string, error) {\n // 1. 检测源节点和目标节点的 IPv4/IPv6 支持\n fromV4 := nodeSupportsV4(fromNode)\n fromV6 := nodeSupportsV6(fromNode)\n toV4 := nodeSupportsV4(toNode)\n toV6 := nodeSupportsV6(toNode)\n \n // 2. 根据偏好选择地址\n switch ipPreference {\n case \"v6\": // 优先 IPv6\n if fromV6 && toV6 { return pickNodeAddressV6(toNode) }\n if fromV4 && toV4 { return pickNodeAddressV4(toNode) }\n case \"v4\": // 优先 IPv4\n if fromV4 && toV4 { return pickNodeAddressV4(toNode) }\n if fromV6 && toV6 { return pickNodeAddressV6(toNode) }\n default: // 默认优先 IPv4\n if fromV4 && toV4 { return pickNodeAddressV4(toNode) }\n if fromV6 && toV6 { return pickNodeAddressV6(toNode) }\n }\n // 3. 不兼容时报错\n return \"\", fmt.Errorf(\"节点链路不兼容\")\n}\n```\n\n**地址选择优先级** (在 `pickNodeAddressV4/V6` 中, L2755-2773):\n- **IPv4**: `ServerIPv4` > `ServerIP` (旧字段)\n- **IPv6**: `ServerIPv6` > `ServerIP` (旧字段)\n\n**端口来源** (在 `buildTunnelChainConfig` 中, L2585-2588):\n```go\nport := target.Port // 来自 ChainTunnel.Port 字段\nif port <= 0 {\n return nil, errors.New(\"节点端口不能为空\")\n}\n```\n\n---\n\n### 3. ChainTunnel.Port 字段的使用流程\n\n**数据模型** (`model.go` L127-138):\n```go\ntype ChainTunnel struct {\n ID int64\n TunnelID int64\n ChainType string // \"1\"=入口, \"2\"=跳转, \"3\"=出口\n NodeID int64\n Port sql.NullInt64 // ★ 连接端口\n Strategy sql.NullString\n Inx sql.NullInt64 // 跳转序号\n Protocol sql.NullString\n}\n```\n\n**Port 字段流转**:\n\n```\n创建隧道时\n │\n ▼\nprepareTunnelCreateState (L1970)\n├── 出口节点: 从请求读取 port,若为空则调用 PickNodePortTx 自动分配\n├── 跳转节点: 从请求读取 port,若为空则调用 PickNodePortTx 自动分配\n │\n ▼\nreplaceTunnelChainsTx (L2775) \n└── CreateChainTunnelTx 写入数据库 (L2809, L2837)\n │\n ▼\n读取/重建状态时\n │\n ▼\nListChainNodesForTunnel (repository_control.go L280)\n└── 从 chain_tunnel 表读取 Port 字段 (L295)\n │\n ▼\nreconstructTunnelState (L845)\n└── 将 Port 复制到 tunnelRuntimeNode.Port (L887, L901)\n │\n ▼\nbuildTunnelChainConfig (L2567)\n└── 使用 target.Port 构建连接地址 (L2585)\n```\n\n---\n\n### 4. 修改连接地址需要改动的关键位置\n\n如果要修改下一跳连接地址的选择逻辑,需要关注以下位置:\n\n| 修改目标 | 文件 | 行号 | 函数 |\n|---------|------|------|------|\n| **IP地址选择策略** | `mutations.go` | L2654-2699 | `selectTunnelDialHost()` |\n| **IPv4地址提取** | `mutations.go` | L2755-2763 | `pickNodeAddressV4()` |\n| **IPv6地址提取** | `mutations.go` | L2765-2773 | `pickNodeAddressV6()` |\n| **端口获取** | `mutations.go` | L2585-2588 | `buildTunnelChainConfig()` |\n| **端口分配逻辑** | `repository_mutations.go` | ~L459 | `PickNodePortTx()` |\n| **端口写入数据库** | `repository_mutations.go` | L398-406 | `CreateChainTunnelTx()` |\n| **端口从数据库读取** | `repository_control.go` | L280-343 | `ListChainNodesForTunnel()` |\n\n**关键配置结构**:\n```go\n// mutations.go L1950-1957\ntype tunnelRuntimeNode struct {\n NodeID int64\n Protocol string // 连接协议\n Strategy string // 选择策略\n Inx int // 跳转序号\n ChainType int // 1=入口, 2=跳转, 3=出口\n Port int // ★ 连接端口\n}\n```\n\n---\n\n### 5. 调用链总结\n\n```\n用户创建隧道请求\n │\n ▼\ntunnelAdd (mutations.go L450)\n │\n ├── prepareTunnelCreateState() ──► 分配端口,构建 state\n │\n ├── replaceTunnelChainsTx() ──► 写入 chain_tunnel 表\n │\n └── applyTunnelRuntime()\n │\n ├── buildTunnelChainConfig()\n │ │\n │ ├── selectTunnelDialHost() ──► 选择 IP 地址\n │ │\n │ └── 使用 target.Port ──► 端口\n │\n └── sendNodeCommand(\"AddChains/AddService\") ──► 发送到节点\n```",
|
|
"time": {
|
|
"start": 1772444774902,
|
|
"end": 1772444774902
|
|
},
|
|
"id": "prt_cadf04883001a0YK8vj4spq9m7",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadefe1790015yJYbwqLvg0Kk4"
|
|
},
|
|
{
|
|
"type": "step-finish",
|
|
"reason": "stop",
|
|
"snapshot": "73ca391522f6954b1825f9d1b6c4a29d9e2b8eb3",
|
|
"cost": 0,
|
|
"tokens": {
|
|
"total": 47720,
|
|
"input": 462,
|
|
"output": 1690,
|
|
"reasoning": 0,
|
|
"cache": {
|
|
"read": 45568,
|
|
"write": 0
|
|
}
|
|
},
|
|
"id": "prt_cadf0f1f9001BjubvrP0ZUKrqq",
|
|
"sessionID": "ses_35212171dffd9vra00U17BWVxP",
|
|
"messageID": "msg_cadefe1790015yJYbwqLvg0Kk4"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|