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`.