mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-30 00:06:38 +08:00
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
This commit is contained in:
@@ -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).
|
- 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.
|
- PostgreSQL migration supported via `panel_install.sh` menu option using pgloader.
|
||||||
- Repository layer is large: `repository.go` (83k LOC), `repository_mutations.go` (43k LOC).
|
- 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.
|
- 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-<plan-summary>.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.
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func (h *Handler) buildDiagnosisStreamStartItems(workItems []diagnosisWorkItem)
|
|||||||
fromNode, _ := h.cachedNode(nodeCache, workItem.fromNodeID)
|
fromNode, _ := h.cachedNode(nodeCache, workItem.fromNodeID)
|
||||||
targetNode, err := h.cachedNode(nodeCache, workItem.toNode.NodeID)
|
targetNode, err := h.cachedNode(nodeCache, workItem.toNode.NodeID)
|
||||||
if err == nil {
|
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 {
|
if resolveErr == nil {
|
||||||
targetIP = resolvedIP
|
targetIP = resolvedIP
|
||||||
targetPort = resolvedPort
|
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())
|
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, "", 0, description, metadata, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
targetIP, targetPort, err := resolveChainProbeTarget(fromNode, targetNode, toNode.Port, ipPreference, "")
|
targetIP, targetPort, err := resolveChainProbeTarget(fromNode, targetNode, toNode.Port, ipPreference, toNode.ConnectIP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]"), toNode.Port, description, metadata, err.Error())
|
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]"), toNode.Port, description, metadata, err.Error())
|
||||||
return
|
return
|
||||||
@@ -1317,12 +1317,19 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
|
|||||||
if protocol == "udp" {
|
if protocol == "udp" {
|
||||||
listenerAddr = node.UDPListenAddr
|
listenerAddr = node.UDPListenAddr
|
||||||
}
|
}
|
||||||
|
var serviceAddr string
|
||||||
if bindIP != "" {
|
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{}{
|
service := map[string]interface{}{
|
||||||
"name": fmt.Sprintf("%s_%s", baseName, protocol),
|
"name": fmt.Sprintf("%s_%s", baseName, protocol),
|
||||||
"addr": processServerAddress(fmt.Sprintf("%s:%d", listenerAddr, port)),
|
"addr": serviceAddr,
|
||||||
"handler": map[string]interface{}{
|
"handler": map[string]interface{}{
|
||||||
"type": protocol,
|
"type": protocol,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -97,3 +97,18 @@ func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.
|
|||||||
t.Fatalf("expected udp addr [::]:22001, got %q", udpAddr)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -887,6 +887,7 @@ func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, er
|
|||||||
Strategy: r.Strategy,
|
Strategy: r.Strategy,
|
||||||
ChainType: 3,
|
ChainType: 3,
|
||||||
Port: r.Port,
|
Port: r.Port,
|
||||||
|
ConnectIP: r.ConnectIP,
|
||||||
})
|
})
|
||||||
state.NodeIDList = append(state.NodeIDList, r.NodeID)
|
state.NodeIDList = append(state.NodeIDList, r.NodeID)
|
||||||
}
|
}
|
||||||
@@ -901,6 +902,7 @@ func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, er
|
|||||||
ChainType: 2,
|
ChainType: 2,
|
||||||
Inx: int(r.Inx),
|
Inx: int(r.Inx),
|
||||||
Port: r.Port,
|
Port: r.Port,
|
||||||
|
ConnectIP: r.ConnectIP,
|
||||||
})
|
})
|
||||||
state.NodeIDList = append(state.NodeIDList, r.NodeID)
|
state.NodeIDList = append(state.NodeIDList, r.NodeID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package contract_test
|
package contract_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -461,3 +462,167 @@ func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
|
|||||||
t.Fatalf("expected federation runtime diagnose endpoint to be called")
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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`.
|
||||||
Reference in New Issue
Block a user