Browse Source

fix(outbound): include tested outbound in HTTP probe config (#5120)

HTTP-pinging a subscription outbound always reported "Probe timed out".
The frontend sends only the template outbounds as allOutbounds, but
subscription outbounds are injected at runtime and aren't in that list,
so burstObservatory had no outbound matching the tag to probe.

Append the tested outbound when its tag is missing instead of only when
allOutbounds is empty, so the probe always has a target while preserving
the template outbounds that back dialerProxy chains.
MHSanaei 11 giờ trước cách đây
mục cha
commit
0bed552292
2 tập tin đã thay đổi với 46 bổ sung2 xóa
  1. 20 2
      web/service/outbound.go
  2. 26 0
      web/service/outbound_subscription_test.go

+ 20 - 2
web/service/outbound.go

@@ -352,8 +352,14 @@ func (s *OutboundService) testOutboundHTTP(outboundJSON string, testURL string,
 			return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid allOutbounds JSON: %v", err)}, nil
 		}
 	}
-	if len(allOutbounds) == 0 {
-		allOutbounds = []any{testOutbound}
+	// The outbound under test must be present in the config so burstObservatory
+	// has something with outboundTag to probe. allOutbounds is the template's
+	// outbounds (for dialerProxy chains); subscription outbounds are injected at
+	// runtime and aren't part of it, so without this the probe targets a tag that
+	// doesn't exist in the config and every test times out. Append (don't replace)
+	// so manual outbounds' dialerProxy chains keep resolving.
+	if !outboundsContainTag(allOutbounds, outboundTag) {
+		allOutbounds = append(allOutbounds, testOutbound)
 	}
 
 	metricsPort, err := findAvailablePort()
@@ -396,6 +402,18 @@ func (s *OutboundService) testOutboundHTTP(outboundJSON string, testURL string,
 	return pollObservatoryResult(testProcess, metricsPort, outboundTag, 12*time.Second), nil
 }
 
+// outboundsContainTag reports whether any outbound in the slice has the given tag.
+func outboundsContainTag(outbounds []any, tag string) bool {
+	for _, ob := range outbounds {
+		if m, ok := ob.(map[string]any); ok {
+			if t, _ := m["tag"].(string); t == tag {
+				return true
+			}
+		}
+	}
+	return false
+}
+
 // createTestConfig builds a probe-only xray config: the original outbounds
 // are kept as-is so dialerProxy chains still resolve, a burstObservatory
 // is wired to probe the target tag, and a metrics listener exposes the

+ 26 - 0
web/service/outbound_subscription_test.go

@@ -85,6 +85,32 @@ func TestAssignStableTags(t *testing.T) {
 	})
 }
 
+// TestOutboundsContainTag covers the guard that ensures the outbound under test
+// is present in the HTTP-probe config. Subscription outbounds aren't part of the
+// template outbounds the frontend sends as allOutbounds, so the probe must append
+// the tested outbound when its tag is missing (otherwise burstObservatory has
+// nothing to probe and every subscription test times out).
+func TestOutboundsContainTag(t *testing.T) {
+	template := []any{
+		map[string]any{"tag": "direct", "protocol": "freedom"},
+		map[string]any{"tag": "blocked", "protocol": "blackhole"},
+	}
+	if !outboundsContainTag(template, "direct") {
+		t.Fatal("expected tag 'direct' to be found")
+	}
+	if outboundsContainTag(template, "sub1-tokyo") {
+		t.Fatal("expected subscription tag to be absent from template outbounds")
+	}
+	if outboundsContainTag(nil, "anything") {
+		t.Fatal("expected empty slice to contain no tags")
+	}
+	// Tolerates non-map / untagged entries without panicking.
+	mixed := []any{"not-a-map", map[string]any{"protocol": "freedom"}}
+	if outboundsContainTag(mixed, "direct") {
+		t.Fatal("expected no match among untagged/non-map entries")
+	}
+}
+
 // TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes covers the SSRF guard used
 // when fetching subscription URLs. All rejected cases use literal IPs or bad
 // schemes so the test never performs real DNS resolution.