From 95929bf82e04627ccfa5806fb5c08145549caad4 Mon Sep 17 00:00:00 2001 From: sagit <36596628+Sagit-chu@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:28:10 +0800 Subject: [PATCH 1/2] feat: add comprehensive migration test for legacy columns (#245) - 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 5bd21e2ac182cf1a9c55978f2e2b2e91186126db Mon Sep 17 00:00:00 2001 From: sagit <36596628+Sagit-chu@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:59:00 +0800 Subject: [PATCH 2/2] feat: custom IP selection and forward list enhancements (#247) * 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 * 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,