From 9cf9f4f1f7cfbf43cd25254dbf5c6c92e90e51b8 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Tue, 3 Mar 2026 10:27:29 +0800 Subject: [PATCH 1/3] feat: add comprehensive migration test for legacy columns - Add ExtraIPs, TCPListenAddr, UDPListenAddr to Node migration - Add ip_preference to Tunnel migration - Add test for very legacy database migration (1.x schema) - Include issue #211 tracking document Entire-Checkpoint: 0d086883c34a --- 211-custom-ip-selection.md | 33 +++++++ go-backend/internal/store/repo/repository.go | 2 +- .../tests/contract/migration_contract_test.go | 98 ++++++++++++++++++- 3 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 211-custom-ip-selection.md diff --git a/211-custom-ip-selection.md b/211-custom-ip-selection.md new file mode 100644 index 0000000..1ae1714 --- /dev/null +++ b/211-custom-ip-selection.md @@ -0,0 +1,33 @@ +# Issue #211: 转发自定义监听IP / 隧道指定连接IP + +## 需求总结 +1. **节点**: 高级配置增加"额外IP地址"字段(逗号分隔) +2. **转发**: 创建/编辑时可指定入口监听IP +3. **隧道**: 配置出口节点时可指定连接IP + +--- + +## 任务清单 + +### 后端 +- [x] 1. 数据模型扩展 - Node/ForwardPort/ChainTunnel 增加字段 +- [x] 2. Repository - CreateNode/UpdateNode 处理 extraIPs +- [x] 3. Repository - resolveForwardIngress 使用 forward_port.in_ip +- [x] 4. Repository - GetNodeAllIPs 辅助函数(返回节点所有可用IP) +- [x] 5. Handler - 转发创建/更新处理 inIp 参数 +- [x] 6. Handler - 隧道出口节点处理 connectIp 参数 +- [x] 7. Handler - 节点API返回 extraIPs 字段 + +### 前端 +- [x] 8. 节点编辑页 - 高级配置增加"额外IP"输入 +- [x] 9. 转发编辑弹窗 - 增加"监听IP"下拉选择 +- [x] 10. 隧道配置页 - 出口节点增加"连接IP"输入 + +--- + +## 完成进度 +- 开始时间: 2026-03-02 +- 完成时间: 2026-03-02 +- 完成任务: 10/10 +- 后端完成: ✅ +- 前端完成: ✅ diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index 7727721..5feb851 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -260,7 +260,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error { m := db.Migrator() if m.HasTable(&model.Node{}) { - for _, field := range []string{"ServerIPV4", "ServerIPV6", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig"} { + for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig"} { if m.HasColumn(&model.Node{}, field) { continue } diff --git a/go-backend/tests/contract/migration_contract_test.go b/go-backend/tests/contract/migration_contract_test.go index 6292e7a..83a4e9a 100644 --- a/go-backend/tests/contract/migration_contract_test.go +++ b/go-backend/tests/contract/migration_contract_test.go @@ -684,15 +684,107 @@ func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) { columns := readTableColumns(t, r.DB(), "node") - for _, required := range []string{"server_ip_v4", "server_ip_v6", "inx"} { + for _, required := range []string{"server_ip_v4", "server_ip_v6", "inx", "extra_ips"} { if !columns[required] { t.Fatalf("expected node column %q to exist after migration", required) } } tunnelColumns := readTableColumns(t, r.DB(), "tunnel") - if !tunnelColumns["inx"] { - t.Fatalf("expected tunnel column %q to exist after migration", "inx") + for _, required := range []string{"inx", "ip_preference"} { + if !tunnelColumns[required] { + t.Fatalf("expected tunnel column %q to exist after migration", required) + } + } +} + +func TestOpenMigratesVeryLegacyNodeAndTunnelColumns(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legacy-1.x.db") + legacyDB, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("open legacy sqlite: %v", err) + } + + t.Cleanup(func() { + _ = legacyDB.Close() + }) + + if _, err := legacyDB.Exec(` + CREATE TABLE IF NOT EXISTS node ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL, + secret VARCHAR(100) NOT NULL, + server_ip VARCHAR(100) NOT NULL, + port TEXT NOT NULL, + interface_name VARCHAR(200), + version VARCHAR(100), + http INTEGER NOT NULL DEFAULT 0, + tls INTEGER NOT NULL DEFAULT 0, + socks INTEGER NOT NULL DEFAULT 0, + created_time INTEGER NOT NULL, + updated_time INTEGER, + status INTEGER NOT NULL + ) + `); err != nil { + t.Fatalf("create very legacy node table: %v", err) + } + + if _, err := legacyDB.Exec(` + CREATE TABLE IF NOT EXISTS tunnel ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL, + traffic_ratio REAL NOT NULL DEFAULT 1.0, + type INTEGER NOT NULL, + protocol VARCHAR(10) NOT NULL DEFAULT 'tls', + flow INTEGER NOT NULL, + created_time INTEGER NOT NULL, + updated_time INTEGER NOT NULL, + status INTEGER NOT NULL, + in_ip TEXT + ) + `); err != nil { + t.Fatalf("create very legacy tunnel table: %v", err) + } + + now := time.Now().UnixMilli() + if _, err := legacyDB.Exec(` + INSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "legacy-node", "legacy-secret", "10.10.0.1", "10000-10010", "eth0", "v-old", 1, 1, 1, now, now, 1); err != nil { + t.Fatalf("seed legacy node row: %v", err) + } + + r, err := repo.Open(dbPath) + if err != nil { + t.Fatalf("open migrated sqlite: %v", err) + } + t.Cleanup(func() { + _ = r.Close() + }) + + columns := readTableColumns(t, r.DB(), "node") + for _, required := range []string{ + "server_ip_v4", + "server_ip_v6", + "extra_ips", + "tcp_listen_addr", + "udp_listen_addr", + "inx", + "is_remote", + "remote_url", + "remote_token", + "remote_config", + } { + if !columns[required] { + t.Fatalf("expected node column %q to exist after migration", required) + } + } + + tunnelColumns := readTableColumns(t, r.DB(), "tunnel") + for _, required := range []string{"inx", "ip_preference"} { + if !tunnelColumns[required] { + t.Fatalf("expected tunnel column %q to exist after migration", required) + } } } From e38335973d8338c4431e9407bdd99c46be9df312 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Tue, 3 Mar 2026 10:58:15 +0800 Subject: [PATCH 2/3] fix: apply custom IP binding to forward and tunnel chain services Entire-Checkpoint: ceff329d4cf4 --- .../internal/http/handler/control_plane.go | 9 ++++-- .../http/handler/control_plane_test.go | 32 +++++++++++++++++++ .../internal/http/handler/dual_stack_test.go | 26 +++++++++++++++ go-backend/internal/http/handler/mutations.go | 2 +- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/go-backend/internal/http/handler/control_plane.go b/go-backend/internal/http/handler/control_plane.go index 5dc4b33..0b4b2de 100644 --- a/go-backend/internal/http/handler/control_plane.go +++ b/go-backend/internal/http/handler/control_plane.go @@ -281,7 +281,7 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all if err != nil { return err } - services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID, tunnelTLSProtocol) + services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol) _, err = h.sendNodeCommand(node.ID, method, services, true, false) if err != nil && allowFallbackAdd && method == "UpdateService" { _, err = h.sendNodeCommand(node.ID, "AddService", services, true, false) @@ -1303,7 +1303,7 @@ func isAlreadyExistsMessage(message string) bool { return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在") } -func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} { +func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} { protocols := []string{"tcp", "udp"} services := make([]map[string]interface{}, 0, 2) targets := splitRemoteTargets(forward.RemoteAddr) @@ -1317,9 +1317,12 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel if protocol == "udp" { listenerAddr = node.UDPListenAddr } + if bindIP != "" { + listenerAddr = bindIP + } service := map[string]interface{}{ "name": fmt.Sprintf("%s_%s", baseName, protocol), - "addr": fmt.Sprintf("%s:%d", listenerAddr, port), + "addr": processServerAddress(fmt.Sprintf("%s:%d", listenerAddr, port)), "handler": map[string]interface{}{ "type": protocol, }, diff --git a/go-backend/internal/http/handler/control_plane_test.go b/go-backend/internal/http/handler/control_plane_test.go index 963d7a9..a09d8fa 100644 --- a/go-backend/internal/http/handler/control_plane_test.go +++ b/go-backend/internal/http/handler/control_plane_test.go @@ -65,3 +65,35 @@ func TestIsAlreadyExistsMessage(t *testing.T) { t.Fatalf("address already in use must not be treated as already exists") } } + +func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) { + forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7} + node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"} + services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22000, "10.9.8.7", nil, false) + if len(services) != 2 { + t.Fatalf("expected 2 services, got %d", len(services)) + } + for _, svc := range services { + addr, _ := svc["addr"].(string) + if addr != "10.9.8.7:22000" { + t.Fatalf("expected bind IP address 10.9.8.7:22000, got %q", addr) + } + } +} + +func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.T) { + forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7} + node := &nodeRecord{TCPListenAddr: "0.0.0.0", UDPListenAddr: "[::]"} + services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22001, "", nil, false) + if len(services) != 2 { + t.Fatalf("expected 2 services, got %d", len(services)) + } + tcpAddr, _ := services[0]["addr"].(string) + udpAddr, _ := services[1]["addr"].(string) + if tcpAddr != "0.0.0.0:22001" { + t.Fatalf("expected tcp addr 0.0.0.0:22001, got %q", tcpAddr) + } + if udpAddr != "[::]:22001" { + t.Fatalf("expected udp addr [::]:22001, got %q", udpAddr) + } +} diff --git a/go-backend/internal/http/handler/dual_stack_test.go b/go-backend/internal/http/handler/dual_stack_test.go index 3c423a4..476f1ce 100644 --- a/go-backend/internal/http/handler/dual_stack_test.go +++ b/go-backend/internal/http/handler/dual_stack_test.go @@ -30,6 +30,32 @@ func TestSelectTunnelDialHost_ConnectIpPriority(t *testing.T) { } } +func TestBuildTunnelChainServiceConfig_UsesConnectIPForListen(t *testing.T) { + node := &nodeRecord{TCPListenAddr: "[::]"} + chain := tunnelRuntimeNode{Protocol: "tls", Port: 21000, ConnectIP: "2001:db8::88"} + services := buildTunnelChainServiceConfig(99, chain, node) + if len(services) != 1 { + t.Fatalf("expected 1 service, got %d", len(services)) + } + addr, _ := services[0]["addr"].(string) + if addr != "[2001:db8::88]:21000" { + t.Fatalf("expected connectIp listen [2001:db8::88]:21000, got %q", addr) + } +} + +func TestBuildTunnelChainServiceConfig_DefaultListenAddrWhenConnectIPEmpty(t *testing.T) { + node := &nodeRecord{TCPListenAddr: "[::]"} + chain := tunnelRuntimeNode{Protocol: "tls", Port: 21001} + services := buildTunnelChainServiceConfig(99, chain, node) + if len(services) != 1 { + t.Fatalf("expected 1 service, got %d", len(services)) + } + addr, _ := services[0]["addr"].(string) + if addr != "[::]:21001" { + t.Fatalf("expected default listen [::]:21001, got %q", addr) + } +} + func TestNodeSupportsV6_Nil(t *testing.T) { if nodeSupportsV6(nil) { t.Fatal("nil node must not support v6") diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index d314e87..6f3b431 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -2643,7 +2643,7 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode, } service := map[string]interface{}{ "name": fmt.Sprintf("%d_tls", tunnelID), - "addr": fmt.Sprintf("%s:%d", node.TCPListenAddr, chainNode.Port), + "addr": processServerAddress(fmt.Sprintf("%s:%d", defaultString(strings.TrimSpace(chainNode.ConnectIP), node.TCPListenAddr), chainNode.Port)), "handler": handlerCfg, "listener": map[string]interface{}{ "type": protocol, From 1c103473579348def1e9ef48d6dafe75231481e4 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Tue, 3 Mar 2026 14:17:36 +0800 Subject: [PATCH 3/3] fix: use configured connectIp for tunnel chain diagnosis - Pass connectIp through resolveChainProbeTarget in diagnosis stream start items - Pass connectIp in appendChainHopDiagnosis for full chain probes - Reconstruct tunnel state with connectIp field preserved - Fix forward service config when bindIP already contains port - Add contract tests for connectIp diagnosis scenarios - Add unit test for bindIP with port in buildForwardServiceConfigs - Update AGENTS.md with plan document rules Entire-Checkpoint: 35a2e61c2431 --- AGENTS.md | 9 +- .../internal/http/handler/control_plane.go | 15 +- .../http/handler/control_plane_test.go | 15 ++ go-backend/internal/http/handler/mutations.go | 2 + .../handler/reconstruct_tunnel_state_test.go | 79 +++++++++ .../tests/contract/diagnosis_contract_test.go | 165 ++++++++++++++++++ plans/001-fix-211-connectip-full-chain.md | 15 ++ 7 files changed, 295 insertions(+), 5 deletions(-) create mode 100644 go-backend/internal/http/handler/reconstruct_tunnel_state_test.go create mode 100644 plans/001-fix-211-connectip-full-chain.md diff --git a/AGENTS.md b/AGENTS.md index d248e54..d88d47d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,4 +112,11 @@ docker compose -f docker-compose-v6.yml up -d - CI workflows: `ci-build.yml` (build check), `docker-build.yml` (multi-arch images + release), `deploy-docs.yml` (MkDocs). - PostgreSQL migration supported via `panel_install.sh` menu option using pgloader. - Repository layer is large: `repository.go` (83k LOC), `repository_mutations.go` (43k LOC). -- Button visual parity relies on `vite-frontend/src/shadcn-bridge/heroui/button.tsx` color mapping + `vite-frontend/src/styles/tailwind-theme.pcss` token export. \ No newline at end of file +- Button visual parity relies on `vite-frontend/src/shadcn-bridge/heroui/button.tsx` color mapping + `vite-frontend/src/styles/tailwind-theme.pcss` token export. + +## PLAN DOCUMENT RULE +- Every new implementation plan must have a dedicated Markdown plan document. +- Store plan documents under `plans/`. +- Use an incrementing numeric prefix and a short plan-summary name: `NNN-.md` (for example, `001-auth-refactor.md`, `002-federation-api-cleanup.md`). +- The numeric prefix must increase by 1 for each new plan. +- In each plan document, keep a task checklist and mark each task as completed immediately after finishing it. diff --git a/go-backend/internal/http/handler/control_plane.go b/go-backend/internal/http/handler/control_plane.go index 0b4b2de..0dfce59 100644 --- a/go-backend/internal/http/handler/control_plane.go +++ b/go-backend/internal/http/handler/control_plane.go @@ -72,7 +72,7 @@ func (h *Handler) buildDiagnosisStreamStartItems(workItems []diagnosisWorkItem) fromNode, _ := h.cachedNode(nodeCache, workItem.fromNodeID) targetNode, err := h.cachedNode(nodeCache, workItem.toNode.NodeID) if err == nil { - resolvedIP, resolvedPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, workItem.toNode.Port, workItem.ipPreference, "") + resolvedIP, resolvedPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, workItem.toNode.Port, workItem.ipPreference, workItem.toNode.ConnectIP) if resolveErr == nil { targetIP = resolvedIP targetPort = resolvedPort @@ -1099,7 +1099,7 @@ func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nod h.appendFailedDiagnosis(results, nodeCache, fromNodeID, "", 0, description, metadata, err.Error()) return } - targetIP, targetPort, err := resolveChainProbeTarget(fromNode, targetNode, toNode.Port, ipPreference, "") + targetIP, targetPort, err := resolveChainProbeTarget(fromNode, targetNode, toNode.Port, ipPreference, toNode.ConnectIP) if err != nil { h.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]"), toNode.Port, description, metadata, err.Error()) return @@ -1317,12 +1317,19 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel if protocol == "udp" { listenerAddr = node.UDPListenAddr } + var serviceAddr string if bindIP != "" { - listenerAddr = bindIP + if strings.Contains(bindIP, ":") { + serviceAddr = processServerAddress(bindIP) + } else { + serviceAddr = processServerAddress(fmt.Sprintf("%s:%d", bindIP, port)) + } + } else { + serviceAddr = processServerAddress(fmt.Sprintf("%s:%d", listenerAddr, port)) } service := map[string]interface{}{ "name": fmt.Sprintf("%s_%s", baseName, protocol), - "addr": processServerAddress(fmt.Sprintf("%s:%d", listenerAddr, port)), + "addr": serviceAddr, "handler": map[string]interface{}{ "type": protocol, }, diff --git a/go-backend/internal/http/handler/control_plane_test.go b/go-backend/internal/http/handler/control_plane_test.go index a09d8fa..3a2754b 100644 --- a/go-backend/internal/http/handler/control_plane_test.go +++ b/go-backend/internal/http/handler/control_plane_test.go @@ -97,3 +97,18 @@ func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing. t.Fatalf("expected udp addr [::]:22001, got %q", udpAddr) } } + +func TestBuildForwardServiceConfigs_BindIPAlreadyContainsPort(t *testing.T) { + forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7} + node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"} + services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 55555, "3.3.3.3:12345", nil, false) + if len(services) != 2 { + t.Fatalf("expected 2 services, got %d", len(services)) + } + for _, svc := range services { + addr, _ := svc["addr"].(string) + if addr != "3.3.3.3:12345" { + t.Fatalf("expected bind IP with port 3.3.3.3:12345, got %q", addr) + } + } +} diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 6f3b431..0811fb5 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -887,6 +887,7 @@ func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, er Strategy: r.Strategy, ChainType: 3, Port: r.Port, + ConnectIP: r.ConnectIP, }) state.NodeIDList = append(state.NodeIDList, r.NodeID) } @@ -901,6 +902,7 @@ func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, er ChainType: 2, Inx: int(r.Inx), Port: r.Port, + ConnectIP: r.ConnectIP, }) state.NodeIDList = append(state.NodeIDList, r.NodeID) } diff --git a/go-backend/internal/http/handler/reconstruct_tunnel_state_test.go b/go-backend/internal/http/handler/reconstruct_tunnel_state_test.go new file mode 100644 index 0000000..c299e8d --- /dev/null +++ b/go-backend/internal/http/handler/reconstruct_tunnel_state_test.go @@ -0,0 +1,79 @@ +package handler + +import ( + "path/filepath" + "testing" + "time" + + "go-backend/internal/store/repo" +) + +func TestReconstructTunnelState_PreservesConnectIP(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "reconstruct-connect-ip.db") + r, err := repo.Open(dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = r.Close() }) + + h := New(r, "secret") + now := time.Now().UnixMilli() + + if err := r.DB().Exec(` + INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) + VALUES(1, 'reconstruct-tunnel', 1.0, 2, 'tls', 1, ?, ?, 1, NULL, 0) + `, now, now).Error; err != nil { + t.Fatalf("insert tunnel: %v", err) + } + + insertNode := func(id int64, name, ip string) { + if err := r.DB().Exec(` + INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, id, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil { + t.Fatalf("insert node %s: %v", name, err) + } + } + + insertNode(101, "entry", "10.90.0.10") + insertNode(102, "middle", "10.90.0.20") + insertNode(103, "exit", "10.90.0.30") + + if err := r.DB().Exec(` + INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) + VALUES(1, '1', 101, 30001, 'round', 1, 'tls') + `).Error; err != nil { + t.Fatalf("insert entry chain: %v", err) + } + if err := r.DB().Exec(` + INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol, connect_ip) + VALUES(1, '2', 102, 30002, 'round', 1, 'tls', '10.99.9.22') + `).Error; err != nil { + t.Fatalf("insert middle chain: %v", err) + } + if err := r.DB().Exec(` + INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol, connect_ip) + VALUES(1, '3', 103, 30003, 'round', 1, 'tls', '10.99.9.33') + `).Error; err != nil { + t.Fatalf("insert exit chain: %v", err) + } + + state, err := h.reconstructTunnelState(1) + if err != nil { + t.Fatalf("reconstructTunnelState: %v", err) + } + + if len(state.ChainHops) != 1 || len(state.ChainHops[0]) != 1 { + t.Fatalf("unexpected chain hops: %+v", state.ChainHops) + } + if got := state.ChainHops[0][0].ConnectIP; got != "10.99.9.22" { + t.Fatalf("expected middle connectIp 10.99.9.22, got %q", got) + } + + if len(state.OutNodes) != 1 { + t.Fatalf("unexpected out nodes: %+v", state.OutNodes) + } + if got := state.OutNodes[0].ConnectIP; got != "10.99.9.33" { + t.Fatalf("expected exit connectIp 10.99.9.33, got %q", got) + } +} diff --git a/go-backend/tests/contract/diagnosis_contract_test.go b/go-backend/tests/contract/diagnosis_contract_test.go index e8983e9..2bf6f57 100644 --- a/go-backend/tests/contract/diagnosis_contract_test.go +++ b/go-backend/tests/contract/diagnosis_contract_test.go @@ -1,6 +1,7 @@ package contract_test import ( + "bufio" "bytes" "encoding/json" "net/http" @@ -461,3 +462,167 @@ func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) { t.Fatalf("expected federation runtime diagnose endpoint to be called") } } + +func TestTunnelDiagnosisUsesConfiguredConnectIPContract(t *testing.T) { + secret := "contract-jwt-secret" + router, r := setupContractRouter(t, secret) + now := time.Now().UnixMilli() + + insertNode := func(name, ip string) int64 { + if err := r.DB().Exec(` + INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, name, name+"-secret", ip, ip, "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil { + t.Fatalf("insert node %s: %v", name, err) + } + return mustLastInsertID(t, r, name) + } + + entryNodeID := insertNode("entry-connectip", "10.80.0.10") + middleNodeID := insertNode("middle-connectip", "10.80.0.20") + exitNodeID := insertNode("exit-connectip", "10.80.0.30") + + if err := r.DB().Exec(` + INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "diagnose-connectip-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil { + t.Fatalf("insert tunnel: %v", err) + } + tunnelID := mustLastInsertID(t, r, "diagnose-connectip-tunnel") + + if err := r.DB().Exec(` + INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) + VALUES(?, 1, ?, 30001, 'round', 1, 'tls') + `, tunnelID, entryNodeID).Error; err != nil { + t.Fatalf("insert entry chain: %v", err) + } + if err := r.DB().Exec(` + INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol, connect_ip) + VALUES(?, 2, ?, 30002, 'round', 1, 'tls', ?) + `, tunnelID, middleNodeID, "10.99.0.22").Error; err != nil { + t.Fatalf("insert middle chain: %v", err) + } + if err := r.DB().Exec(` + INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol, connect_ip) + VALUES(?, 3, ?, 30003, 'round', 1, 'tls', ?) + `, tunnelID, exitNodeID, "10.99.0.33").Error; err != nil { + t.Fatalf("insert exit chain: %v", err) + } + + adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate admin token: %v", err) + } + + t.Run("normal diagnose should use configured connectIp", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/diagnose", bytes.NewBufferString(`{"tunnelId":`+strconv.FormatInt(tunnelID, 10)+`}`)) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg) + } + + payload, ok := out.Data.(map[string]interface{}) + if !ok { + t.Fatalf("expected object payload, got %T", out.Data) + } + results, ok := payload["results"].([]interface{}) + if !ok || len(results) == 0 { + t.Fatalf("expected non-empty results, got %v", payload["results"]) + } + + entryToMiddleOK := false + middleToExitOK := false + for _, raw := range results { + item, ok := raw.(map[string]interface{}) + if !ok { + continue + } + from := valueAsInt(item["fromChainType"]) + to := valueAsInt(item["toChainType"]) + targetIP := strings.TrimSpace(valueAsString(item["targetIp"])) + + if from == 1 && to == 2 && targetIP == "10.99.0.22" { + entryToMiddleOK = true + } + if from == 2 && to == 3 && targetIP == "10.99.0.33" { + middleToExitOK = true + } + } + + if !entryToMiddleOK || !middleToExitOK { + t.Fatalf("expected connectIp targets 10.99.0.22/10.99.0.33, got entry=%v middle=%v", entryToMiddleOK, middleToExitOK) + } + }) + + t.Run("stream diagnose start items should use configured connectIp", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/diagnose/stream", bytes.NewBufferString(`{"tunnelId":`+strconv.FormatInt(tunnelID, 10)+`}`)) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + + router.ServeHTTP(res, req) + + if res.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", res.Code) + } + + scanner := bufio.NewScanner(bytes.NewReader(res.Body.Bytes())) + startFound := false + entryToMiddleOK := false + middleToExitOK := false + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var event map[string]interface{} + if err := json.Unmarshal([]byte(line), &event); err != nil { + continue + } + if strings.TrimSpace(valueAsString(event["type"])) != "start" { + continue + } + startFound = true + data, ok := event["data"].(map[string]interface{}) + if !ok { + break + } + items, ok := data["items"].([]interface{}) + if !ok { + break + } + for _, raw := range items { + item, ok := raw.(map[string]interface{}) + if !ok { + continue + } + from := valueAsInt(item["fromChainType"]) + to := valueAsInt(item["toChainType"]) + targetIP := strings.TrimSpace(valueAsString(item["targetIp"])) + if from == 1 && to == 2 && targetIP == "10.99.0.22" { + entryToMiddleOK = true + } + if from == 2 && to == 3 && targetIP == "10.99.0.33" { + middleToExitOK = true + } + } + break + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan stream body: %v", err) + } + if !startFound { + t.Fatalf("expected start event in stream response") + } + if !entryToMiddleOK || !middleToExitOK { + t.Fatalf("expected start items with connectIp targets 10.99.0.22/10.99.0.33, got entry=%v middle=%v", entryToMiddleOK, middleToExitOK) + } + }) +} diff --git a/plans/001-fix-211-connectip-full-chain.md b/plans/001-fix-211-connectip-full-chain.md new file mode 100644 index 0000000..170254b --- /dev/null +++ b/plans/001-fix-211-connectip-full-chain.md @@ -0,0 +1,15 @@ +# 001 Fix 211 ConnectIP Full Chain + +## Checklist + +- [x] Analyze connectIp/inIp full chain across diagnosis/runtime/redeploy paths. +- [x] Fix diagnosis target resolution to honor selected `connectIp` for chain hops. +- [x] Fix tunnel state reconstruction to preserve `connectIp` on chain/out nodes. +- [x] Add contract regression tests for normal + stream diagnosis target IP behavior. +- [x] Add handler regression test for redeploy state reconstruction preserving `connectIp`. +- [x] Run backend handler and contract test suites. + +## Notes + +- Diagnosis now uses `chain_tunnel.connect_ip` for both stream start preview and runtime probing. +- Redeploy/batch-redeploy no longer drops `connectIp` during `reconstructTunnelState`.