From efcf152950f51f15a1721c7cca1754651d21166a Mon Sep 17 00:00:00 2001 From: BlindMaster24 <375291171150z@gmail.com> Date: Mon, 14 Sep 2026 19:53:05 +0300 Subject: [PATCH] fix(outbound): read the probe testability gate's ids like the core (#6527) A direct, DNS, loopback or blackhole outbound is not a proxy, so the probe must reject it instead of measuring the panel host's own reachability. The gate compared the protocol id exactly while the core lowercases it in LoadWithID before resolving the handler, so "Freedom" and "DNS" were not recognised: the HTTP probe ran through the direct outbound and returned Success=true with a full egress block, and the row's Test button stayed enabled because isUntestable compared exactly as well. The operator reads the panel host's own country and delay as a working tunnel. The batch gate now folds the id once before its switch, and isUntestable goes through the shared isOutboundProtocol helper. --- .../xray/outbounds/outbounds-tab-helpers.ts | 8 +-- .../test/outbounds-testability-gate.test.ts | 26 +++++++++ internal/web/service/outbound/probe_http.go | 2 + .../outbound/probe_testability_gate_test.go | 54 +++++++++++++++++++ 4 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 frontend/src/test/outbounds-testability-gate.test.ts create mode 100644 internal/web/service/outbound/probe_testability_gate_test.go 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) + } + }) + } +}