diff --git a/web/service/outbound.go b/web/service/outbound.go index 47031b942..cb2f9e078 100644 --- a/web/service/outbound.go +++ b/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 diff --git a/web/service/outbound_subscription_test.go b/web/service/outbound_subscription_test.go index 3a7072caf..432c95600 100644 --- a/web/service/outbound_subscription_test.go +++ b/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.