Просмотр исходного кода

fix(nodes): route 'load inbounds' through the connection outbound

Loading a node's inbound list bypassed the configured connection
outbound and dialed the remote panel directly, so a node only reachable
through that outbound timed out with 'context deadline exceeded' even
though Test Connection succeeded.

Extract the temporary loopback SOCKS5 bridge setup from ProbeWithOutbound
into a shared withOutboundBridge helper and route GetRemoteInboundOptions
through it when an outbound tag is set.
MHSanaei 3 часов назад
Родитель
Сommit
c1fdcd98d2
1 измененных файлов с 59 добавлено и 25 удалено
  1. 59 25
      internal/web/service/node.go

+ 59 - 25
internal/web/service/node.go

@@ -357,9 +357,27 @@ func (s *NodeService) GetRemoteInboundOptions(ctx context.Context, n *model.Node
 	if err := s.normalize(n); err != nil {
 		return nil, err
 	}
-	return runtime.NewRemote(n, nil).ListInboundOptions(ctx)
+	if n.OutboundTag == "" {
+		return runtime.NewRemote(n, nil).ListInboundOptions(ctx)
+	}
+	// Mirror ProbeWithOutbound: a node being added/edited has no persistent
+	// egress bridge yet, so route the list call through a temporary one or the
+	// remote panel stays unreachable and the request times out.
+	var options []runtime.RemoteInboundOption
+	var err error
+	s.withOutboundBridge(n.Id, n.OutboundTag, func(proxyURL string) {
+		options, err = runtime.NewRemote(n, staticEgressResolver(proxyURL)).ListInboundOptions(ctx)
+	})
+	return options, err
 }
 
+// staticEgressResolver hands a fixed proxy URL to runtime.NewRemote. An empty
+// string yields a direct connection, so it doubles as the graceful fallback
+// when a temporary bridge can't be built.
+type staticEgressResolver string
+
+func (r staticEgressResolver) NodeEgressProxyURL(int) string { return string(r) }
+
 // EnsureInboundTagAllowed adds a panel-managed inbound's tag to the node's
 // selection when the node syncs in "selected" mode. Without it, the next
 // traffic sync would filter the tag out of the snapshot and the orphan sweep
@@ -611,23 +629,46 @@ func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outb
 	if outboundTag == "" {
 		return s.Probe(ctx, n)
 	}
+	var patch HeartbeatPatch
+	var err error
+	s.withOutboundBridge(n.Id, outboundTag, func(proxyURL string) {
+		if proxyURL == "" {
+			patch, err = s.Probe(ctx, n)
+			return
+		}
+		patch, err = s.probe(ctx, n, proxyURL)
+	})
+	return patch, err
+}
+
+// withOutboundBridge stands up a temporary loopback SOCKS5 inbound in the
+// running Xray, routes it through outboundTag, and runs fn with the bridge's
+// proxy URL before tearing it down. It is used to reach a node through its
+// connection outbound before the persistent egress bridge has been injected
+// into the config (e.g. while the node is still being added or edited). When
+// Xray isn't running or the bridge can't be built, fn runs with an empty
+// proxyURL so callers fall back to a direct connection.
+func (s *NodeService) withOutboundBridge(nodeID int, outboundTag string, fn func(proxyURL string)) {
 	proc := XrayProcess()
 	if proc == nil || !proc.IsRunning() {
-		return s.Probe(ctx, n)
+		fn("")
+		return
 	}
 	apiPort := proc.GetAPIPort()
 	if apiPort <= 0 {
-		return s.Probe(ctx, n)
+		fn("")
+		return
 	}
 
 	listener, err := net.Listen("tcp", "127.0.0.1:0")
 	if err != nil {
-		return s.Probe(ctx, n)
+		fn("")
+		return
 	}
 	port := listener.Addr().(*net.TCPAddr).Port
 	listener.Close()
 
-	tag := fmt.Sprintf("node-test-%d-%d", n.Id, time.Now().UnixNano())
+	tag := fmt.Sprintf("node-test-%d-%d", nodeID, time.Now().UnixNano())
 	proxyURL := fmt.Sprintf("socks5://127.0.0.1:%d", port)
 
 	inboundJSON, err := json.Marshal(xray.InboundConfig{
@@ -638,7 +679,8 @@ func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outb
 		Tag:      tag,
 	})
 	if err != nil {
-		return s.Probe(ctx, n)
+		fn("")
+		return
 	}
 
 	cfg := proc.GetConfig()
@@ -659,31 +701,31 @@ func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outb
 	routing["rules"] = append([]any{rule}, rules...)
 	routingJSON, err := json.Marshal(routing)
 	if err != nil {
-		return s.Probe(ctx, n)
+		fn("")
+		return
 	}
 	originalRoutingJSON := cfg.RouterConfig
 
 	api := xray.XrayAPI{}
 	if err := api.Init(apiPort); err != nil {
-		return s.Probe(ctx, n)
+		fn("")
+		return
 	}
 	defer api.Close()
 
 	if err := api.AddInbound(inboundJSON); err != nil {
-		return s.Probe(ctx, n)
+		fn("")
+		return
 	}
-	removed := false
 	defer func() {
-		if removed {
-			return
-		}
 		if err := api.DelInbound(tag); err != nil {
-			logger.Warning("remove temp node test inbound failed:", err)
+			logger.Warning("remove temp node bridge inbound failed:", err)
 		}
 	}()
 
 	if err := api.ApplyRoutingConfig(routingJSON); err != nil {
-		return s.Probe(ctx, n)
+		fn("")
+		return
 	}
 	defer func() {
 		restore := originalRoutingJSON
@@ -691,19 +733,11 @@ func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outb
 			restore = []byte("{}")
 		}
 		if err := api.ApplyRoutingConfig(restore); err != nil {
-			logger.Warning("restore routing after node test failed:", err)
+			logger.Warning("restore routing after node bridge failed:", err)
 		}
 	}()
 
-	patch, err := s.probe(ctx, n, proxyURL)
-	removed = true
-	if delErr := api.DelInbound(tag); delErr != nil {
-		logger.Warning("remove temp node test inbound failed:", delErr)
-	}
-	if err != nil {
-		return patch, err
-	}
-	return patch, nil
+	fn(proxyURL)
 }
 
 func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string) (HeartbeatPatch, error) {