소스 검색

fix(outbound): read the probe protocol id and transport name like the core (#6526)

* fix(outbound): read the probe protocol id and transport name like the core

The probe lane gate and the endpoint extractor behind it compared both
strings exactly, so a template the core is running was probed as something
else. With mode=tcp an outbound spelled "WireGuard" stayed in the dial-only
TCP lane, where extractOutboundEndpoints matched no case and the caller got
"No testable endpoint" for an outbound that is passing traffic.

The core lowercases a protocol id (infra/conf/loader.go) and a transport
name (TransportProtocol.Build) before it resolves either, and resolves both
"kcp" and "mkcp" to mKCP, so both readers now normalise the same way.

The panel no longer reaches the lane gate itself — the browser now sends
http for these outbounds — but the endpoint documents "tcp" for fast
dial-only probes with UDP-transport outbounds still probed over HTTP, and
that promise has to hold for direct API callers too.

* fix(outbound): read the batch probe protocol id like the core

Review of #6526 found that folding "WireGuard"/"AmneziaWG" into the UDP lane
newly routed those spellings onto two readers in buildBatchTestConfig that
still compared the id exactly. A case-variant WireGuard outbound therefore
reached the temp probe instance without noKernelTun -- which on Linux creates
a kernel TUN device alongside the live panel's own -- and a case-variant
AmneziaWG entry was appended raw, rejecting the whole temp config and
degrading the batch to serial per-item retries.

Both readers now fold the id the way the core does (infra/conf/loader.go
lowercases it before the protocol is resolved).
BlindMaster24 15 시간 전
부모
커밋
f69d1e869d
3개의 변경된 파일161개의 추가작업 그리고 4개의 파일을 삭제
  1. 16 2
      internal/web/service/outbound/outbound.go
  2. 2 2
      internal/web/service/outbound/probe_http.go
  3. 143 0
      internal/web/service/outbound/probe_protocol_case_test.go

+ 16 - 2
internal/web/service/outbound/outbound.go

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"net"
 	"strconv"
+	"strings"
 	"sync"
 	"time"
 
@@ -221,11 +222,23 @@ func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult
 // dial neither proves reachability nor measures latency. Such outbounds
 // must go through the real xray handshake probe instead.
 func outboundTransportIsUDP(ob map[string]any) bool {
-	if protocol, _ := ob["protocol"].(string); protocol == "hysteria" || protocol == "wireguard" || protocol == "amneziawg" {
+	if protocol, _ := ob["protocol"].(string); equalsAnyFold(protocol, "hysteria", "wireguard", "amneziawg") {
 		return true
 	}
 	if stream, ok := ob["streamSettings"].(map[string]any); ok {
-		if n, _ := stream["network"].(string); n == "hysteria" || n == "kcp" || n == "quic" {
+		// The core resolves "kcp" and "mkcp" to the same mKCP transport.
+		if n, _ := stream["network"].(string); equalsAnyFold(n, "hysteria", "kcp", "mkcp", "quic") {
+			return true
+		}
+	}
+	return false
+}
+
+// equalsAnyFold mirrors the core, which lowercases a protocol id and a
+// transport name before it resolves either of them.
+func equalsAnyFold(value string, want ...string) bool {
+	for _, w := range want {
+		if strings.EqualFold(value, w) {
 			return true
 		}
 	}
@@ -234,6 +247,7 @@ func outboundTransportIsUDP(ob map[string]any) bool {
 
 func extractOutboundEndpoints(ob map[string]any) []string {
 	protocol, _ := ob["protocol"].(string)
+	protocol = strings.ToLower(protocol)
 	settings, _ := ob["settings"].(map[string]any)
 	if settings == nil {
 		return nil

+ 2 - 2
internal/web/service/outbound/probe_http.go

@@ -394,7 +394,7 @@ func buildBatchTestConfig(items []*httpBatchItem, allOutbounds []any, ports []in
 			bridged = append(bridged, ob)
 			continue
 		}
-		if p, _ := m["protocol"].(string); p != "amneziawg" {
+		if p, _ := m["protocol"].(string); !strings.EqualFold(p, "amneziawg") {
 			bridged = append(bridged, ob)
 			continue
 		}
@@ -418,7 +418,7 @@ func buildBatchTestConfig(items []*httpBatchItem, allOutbounds []any, ports []in
 			continue
 		}
 		// The temp instance must not touch kernel WireGuard devices.
-		if protocol, ok := outbound["protocol"].(string); ok && protocol == "wireguard" {
+		if protocol, ok := outbound["protocol"].(string); ok && strings.EqualFold(protocol, "wireguard") {
 			if settings, ok := outbound["settings"].(map[string]any); ok {
 				settings["noKernelTun"] = true
 			} else {

+ 143 - 0
internal/web/service/outbound/probe_protocol_case_test.go

@@ -0,0 +1,143 @@
+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 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)
+	}
+}