diff --git a/internal/web/service/outbound/outbound.go b/internal/web/service/outbound/outbound.go index 0ea638d53..8b28eedfb 100644 --- a/internal/web/service/outbound/outbound.go +++ b/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 diff --git a/internal/web/service/outbound/probe_http.go b/internal/web/service/outbound/probe_http.go index 10e5767ff..600759ac1 100644 --- a/internal/web/service/outbound/probe_http.go +++ b/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 { diff --git a/internal/web/service/outbound/probe_protocol_case_test.go b/internal/web/service/outbound/probe_protocol_case_test.go new file mode 100644 index 000000000..4079d515f --- /dev/null +++ b/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) + } +}