diff --git a/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts b/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts index 82a11e1df..108eea1c6 100644 --- a/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts +++ b/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts @@ -1,6 +1,6 @@ import type { TFunction } from 'i18next'; -import { OutboundProtocols as Protocols } from '@/schemas/primitives'; +import { isOutboundProtocol, OutboundProtocols as Protocols } from '@/schemas/primitives'; import { isUdpOutbound } from '@/hooks/useXraySetting'; import type { OutboundTestMode, @@ -57,15 +57,15 @@ export function outboundAddresses(o: OutboundRow): string[] { export function isUntestable(o: OutboundRow): boolean { if (!o) return true; if ( - o.protocol === Protocols.Blackhole || - o.protocol === Protocols.Loopback || + isOutboundProtocol(o, Protocols.Blackhole) || + isOutboundProtocol(o, Protocols.Loopback) || o.tag === 'blocked' ) return true; // freedom ("direct") and dns aren't proxies — a TCP dial has no endpoint and // an HTTP probe would only measure the host's own direct reachability, so // they're untestable in every mode. - if (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS) return true; + if (isOutboundProtocol(o, Protocols.Freedom) || isOutboundProtocol(o, Protocols.DNS)) return true; return false; } diff --git a/frontend/src/test/outbounds-testability-gate.test.ts b/frontend/src/test/outbounds-testability-gate.test.ts new file mode 100644 index 000000000..31301d2b1 --- /dev/null +++ b/frontend/src/test/outbounds-testability-gate.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { isUntestable } from '@/pages/xray/outbounds/outbounds-tab-helpers'; +import type { OutboundRow } from '@/pages/xray/outbounds/outbounds-tab-types'; + +// The row's Test button is disabled by this gate alone: a spelling the core +// resolves but the gate misses reports the panel host's own reachability as a tunnel. +const row = (protocol: string, tag = 'probe'): OutboundRow => ({ key: 0, tag, protocol }); + +describe('isUntestable', () => { + it.each(['Freedom', 'FREEDOM', 'fReEdOm'])('disables a %s row', (protocol) => { + expect(isUntestable(row(protocol))).toBe(true); + }); + + it.each(['DNS', 'Dns'])('disables a %s row', (protocol) => { + expect(isUntestable(row(protocol))).toBe(true); + }); + + it.each(['Blackhole', 'BLACKHOLE', 'Loopback'])('disables a %s row', (protocol) => { + expect(isUntestable(row(protocol))).toBe(true); + }); + + it.each(['vless', 'VMess', 'Trojan', 'Socks'])('leaves a %s row testable', (protocol) => { + expect(isUntestable(row(protocol))).toBe(false); + }); +}); diff --git a/internal/web/service/outbound/probe_http.go b/internal/web/service/outbound/probe_http.go index 600759ac1..08396c491 100644 --- a/internal/web/service/outbound/probe_http.go +++ b/internal/web/service/outbound/probe_http.go @@ -178,6 +178,8 @@ func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL st r := &TestOutboundResult{Tag: tag, Mode: probeLabel} results[i] = r protocol, _ := ob["protocol"].(string) + // The core lowercases the id before it resolves the handler. + protocol = strings.ToLower(protocol) switch { case tag == "": r.Error = "Outbound has no tag" diff --git a/internal/web/service/outbound/probe_testability_gate_test.go b/internal/web/service/outbound/probe_testability_gate_test.go new file mode 100644 index 000000000..c00652199 --- /dev/null +++ b/internal/web/service/outbound/probe_testability_gate_test.go @@ -0,0 +1,54 @@ +package outbound + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +// A direct, DNS, loopback or blackhole outbound is not a tunnel, so the probe +// rejects it whatever the spelling: the core resolves "Freedom" to freedom. +func TestTestOutboundsRejectsCaseVariantUntestableIDs(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess { + return &stubProcess{cfg: cfg, serveSocks: true} + }) + withEgressTraceProbe(t, func(*url.URL) *TestEgressResult { + return &TestEgressResult{IPv4: "198.51.100.2", Country: "ZZ", Warp: "off"} + }) + + tests := []struct { + name string + protocol string + wantErr string + }{ + {"freedom", "freedom", "Direct/DNS outbound cannot be tested"}, + {"Freedom", "Freedom", "Direct/DNS outbound cannot be tested"}, + {"FREEDOM", "FREEDOM", "Direct/DNS outbound cannot be tested"}, + {"DNS", "DNS", "Direct/DNS outbound cannot be tested"}, + {"Blackhole", "Blackhole", "Blocked/blackhole outbound cannot be tested"}, + {"Loopback", "Loopback", "Loopback outbound cannot be tested"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + batch := mustJSON(t, []any{map[string]any{"tag": "probe", "protocol": tt.protocol}}) + results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "http") + if err != nil { + t.Fatalf("TestOutbounds: %v", err) + } + r := results[0] + if r.Success || r.Error != tt.wantErr { + t.Errorf("%q = success=%v err=%q egress=%+v, want the rejection %q", + tt.protocol, r.Success, r.Error, r.Egress, tt.wantErr) + } + }) + } +}