Files
3x-ui/internal/web/service/outbound/probe_protocol_case_test.go
T
BlindMaster24 a810f497e6 fix(xray): read the last two inboundTag protocol ids like the core (#6530)
The core lowercases an outbound's protocol id before it resolves the handler,
so an outbound spelled "Loopback" still is the loopback outbound. Both
readers that keep a loopback outbound's inboundTag in step with the inbound
it names compared the id exactly, so such an outbound was skipped: renaming
or deleting that inbound left settings.inboundTag pointing at a tag that no
longer exists, and traffic returning through the loopback outbound arrives
under a tag no routing rule can match (infra/conf/loopback.go:15 carries the
tag, proxy/loopback/loopback.go:43 uses it as the inbound identity).

The probe lane's "nothing to test here" gate had the same exact comparison,
so a "Freedom"/"Blackhole" outbound reported the vaguer "No testable
endpoint" where the canonical spelling reports "Outbound has no testable
endpoint" — the two spellings took different paths to the same rejection.

Both readers now compare case-insensitively; the outbound package reuses its
existing equalsAnyFold helper rather than adding a second one. The service
reads the config template an operator edits, so a case variant is reachable
there; server.go's GetDefaultLogOutboundTags scans the embedded config.json
instead, whose protocols are canonical by construction, so it is left as is
and no test can tell a case-insensitive read there from an exact one.
2026-09-14 21:18:31 +03:00

175 lines
5.6 KiB
Go

package outbound
import (
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// The core lowercases a protocol id and a transport name before it resolves
// either, so every reader here has to accept the spelling the core accepts.
func TestTestOutboundsTCPModeForcesCoreSpelledUDPToHTTPProbe(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"}
})
batch := mustJSON(t, []any{map[string]any{"tag": "wg", "protocol": "WireGuard"}})
results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "tcp")
if err != nil {
t.Fatalf("TestOutbounds: %v", err)
}
r := results[0]
if !r.Success || r.Mode != "http" {
t.Errorf(`"WireGuard" outbound in tcp mode = %+v, want success with mode %q`, r, "http")
}
if r.Egress == nil || r.Egress.IPv4 != "198.51.100.2" {
t.Errorf(`"WireGuard" outbound egress = %+v`, r.Egress)
}
}
func TestOutboundTransportIsUDPMatchesTheCore(t *testing.T) {
tests := []struct {
name string
ob map[string]any
want bool
}{
{"canonical wireguard", map[string]any{"protocol": "wireguard"}, true},
{"capitalised wireguard", map[string]any{"protocol": "WireGuard"}, true},
{"upper hysteria", map[string]any{"protocol": "HYSTERIA"}, true},
{"amneziawg", map[string]any{"protocol": "amneziawg"}, true},
{"kcp transport", map[string]any{"streamSettings": map[string]any{"network": "kcp"}}, true},
{"kcp transport capitalised", map[string]any{"streamSettings": map[string]any{"network": "KCP"}}, true},
{"mkcp alias", map[string]any{"streamSettings": map[string]any{"network": "mkcp"}}, true},
{"mkcp alias capitalised", map[string]any{"streamSettings": map[string]any{"network": "MKCP"}}, true},
{"tcp transport", map[string]any{"streamSettings": map[string]any{"network": "tcp"}}, false},
{"plain vless", map[string]any{"protocol": "vless"}, false},
{"matched but tcp", map[string]any{"protocol": "vless", "streamSettings": map[string]any{"network": "ws"}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := outboundTransportIsUDP(tt.ob); got != tt.want {
t.Errorf("outboundTransportIsUDP(%v) = %v, want %v", tt.ob, got, tt.want)
}
})
}
}
func TestBuildBatchTestConfigReadsTheProtocolIDLikeTheCore(t *testing.T) {
items := []*httpBatchItem{
{tag: "wg", outbound: map[string]any{"tag": "wg", "protocol": "WireGuard"}},
{tag: "awg", outbound: map[string]any{"tag": "awg", "protocol": "AmneziaWG"}},
}
cfg := buildBatchTestConfig(items, nil, []int{61011, 61012})
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal config: %v", err)
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("unmarshal config: %v", err)
}
outbounds, _ := m["outbounds"].([]any)
byTag := make(map[string]map[string]any, len(outbounds))
for _, entry := range outbounds {
ob, _ := entry.(map[string]any)
tag, _ := ob["tag"].(string)
byTag[tag] = ob
}
wg := byTag["wg"]
if wg == nil {
t.Fatalf("wg outbound missing from the temp config: %v", outbounds)
}
if settings, _ := wg["settings"].(map[string]any); settings == nil || settings["noKernelTun"] != true {
t.Errorf(`"WireGuard" settings = %v, want noKernelTun: the probe instance must not create a kernel device`, wg["settings"])
}
awg := byTag["awg"]
if awg == nil {
t.Fatalf("awg outbound missing from the temp config: %v", outbounds)
}
if protocol, _ := awg["protocol"].(string); protocol != "socks" {
t.Errorf(`"AmneziaWG" protocol = %q, want %q: a raw amneziawg entry fails the whole temp config`, protocol, "socks")
}
}
func TestTestOutboundsRejectsUntestableIDsInAnyCase(t *testing.T) {
tests := []struct {
name string
protocol string
}{
{"canonical freedom", "freedom"},
{"capitalised freedom", "Freedom"},
{"upper freedom", "FREEDOM"},
{"canonical blackhole", "blackhole"},
{"capitalised blackhole", "Blackhole"},
}
const wantErr = "Outbound has no testable endpoint"
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
batch := mustJSON(t, []any{map[string]any{"tag": "t1", "protocol": tt.protocol}})
results, err := (&OutboundService{}).TestOutbounds(batch, "", "", "tcp")
if err != nil {
t.Fatalf("TestOutbounds: %v", err)
}
r := results[0]
if r.Success {
t.Errorf("%q outbound = %+v, want a rejection", tt.protocol, r)
}
if r.Error != wantErr {
t.Errorf("%q error = %q, want %q", tt.protocol, r.Error, wantErr)
}
})
}
}
func TestTestOutboundsTCPLaneReadsProtocolIDCaseInsensitively(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer l.Close()
go func() {
for {
conn, err := l.Accept()
if err != nil {
return
}
conn.Close()
}
}()
port := l.Addr().(*net.TCPAddr).Port
batch := mustJSON(t, []any{map[string]any{
"tag": "t1",
"protocol": "SOCKS",
"settings": map[string]any{"servers": []any{map[string]any{"address": "127.0.0.1", "port": port}}},
}})
results, err := (&OutboundService{}).TestOutbounds(batch, "", "", "tcp")
if err != nil {
t.Fatalf("TestOutbounds: %v", err)
}
r := results[0]
if !r.Success || r.Mode != "tcp" || len(r.Endpoints) != 1 {
t.Errorf(`"SOCKS" outbound in tcp mode = %+v, want a successful tcp probe with one endpoint`, r)
}
}