From c2116bb8698ee461c5e9386ac2d990eb625a4b4d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 07:22:38 +0000 Subject: [PATCH 01/44] feat: add browserDialer under sockopt and wire transports Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/56665ec5-84ea-4bc3-a812-2e699e0e880d Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- infra/conf/transport_internet.go | 2 + transport/internet/browser_dialer/dialer.go | 189 +++++++++++++----- transport/internet/config.pb.go | 15 +- transport/internet/config.proto | 2 + .../internet/splithttp/browser_client.go | 5 +- transport/internet/splithttp/dialer.go | 11 +- transport/internet/websocket/dialer.go | 8 +- 7 files changed, 169 insertions(+), 63 deletions(-) diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index 15d5cfbd7607..b06a8894ded3 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -1074,6 +1074,7 @@ type SocketConfig struct { AddressPortStrategy string `json:"addressPortStrategy"` HappyEyeballsSettings *HappyEyeballsConfig `json:"happyEyeballs"` TrustedXForwardedFor []string `json:"trustedXForwardedFor"` + BrowserDialer string `json:"browserDialer"` } // Build implements Buildable. @@ -1194,6 +1195,7 @@ func (c *SocketConfig) Build() (*internet.SocketConfig, error) { AddressPortStrategy: addressPortStrategy, HappyEyeballs: happyEyeballs, TrustedXForwardedFor: c.TrustedXForwardedFor, + BrowserDialer: c.BrowserDialer, }, nil } diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 53955bc41fda..d7cfb8107b5a 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -20,14 +20,15 @@ import ( var webpage []byte type task struct { - Method string `json:"method"` - URL string `json:"url"` - Extra any `json:"extra,omitempty"` - StreamResponse bool `json:"streamResponse"` + Method string `json:"method"` + URL string `json:"url"` + Extra any `json:"extra,omitempty"` + StreamResponse bool `json:"streamResponse"` } var conns chan *websocket.Conn var server *http.Server +var sockoptDialers map[string]*dialerInstance var mu sync.Mutex var upgrader = &websocket.Upgrader{ @@ -41,46 +42,18 @@ var upgrader = &websocket.Upgrader{ // Used by external projects when using xray as a go module func Reload() { - addr := platform.NewEnvFlag(platform.BrowserDialerAddress).GetValue(func() string { return "" }) + addr := getEnvAddress() mu.Lock() defer mu.Unlock() - if server != nil { - server.Close() - } - if HasBrowserDialer() { - for len(conns) > 0 { - select { - case c := <-conns: - c.Close() - default: - } - } - conns = nil - } + closeDialerInstance(&dialerInstance{conns: conns, server: server}) + conns = nil + server = nil + if addr != "" { - token := uuid.New() - csrfToken := token.String() - webpage := bytes.ReplaceAll(webpage, []byte("csrfToken"), []byte(csrfToken)) - conns = make(chan *websocket.Conn, 256) - server = &http.Server{ - Addr: addr, - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/websocket" { - if r.URL.Query().Get("token") == csrfToken { - if conn, err := upgrader.Upgrade(w, r, nil); err == nil { - conns <- conn - } else { - errors.LogError(context.Background(), "Browser dialer http upgrade unexpected error") - } - } - } else { - w.Header().Set("Access-Control-Allow-Origin", "*"); - w.Write(webpage) - } - }), - } - go server.ListenAndServe() + dialer := newDialerInstance(addr) + conns = dialer.conns + server = dialer.server } } @@ -88,14 +61,92 @@ func HasBrowserDialer() bool { return conns != nil } +func HasBrowserDialerWithAddress(addr string) bool { + return connsByAddress(addr) != nil +} + type webSocketExtra struct { Protocol string `json:"protocol,omitempty"` } +type dialerInstance struct { + conns chan *websocket.Conn + server *http.Server +} + +func getEnvAddress() string { + return platform.NewEnvFlag(platform.BrowserDialerAddress).GetValue(func() string { return "" }) +} + +func newDialerInstance(addr string) *dialerInstance { + token := uuid.New() + csrfToken := token.String() + page := bytes.ReplaceAll(webpage, []byte("csrfToken"), []byte(csrfToken)) + dialer := &dialerInstance{ + conns: make(chan *websocket.Conn, 256), + } + dialer.server = &http.Server{ + Addr: addr, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/websocket" { + if r.URL.Query().Get("token") == csrfToken { + if conn, err := upgrader.Upgrade(w, r, nil); err == nil { + dialer.conns <- conn + } else { + errors.LogError(context.Background(), "Browser dialer http upgrade unexpected error") + } + } + } else { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Write(page) + } + }), + } + go dialer.server.ListenAndServe() + return dialer +} + +func closeDialerInstance(d *dialerInstance) { + if d == nil { + return + } + if d.server != nil { + d.server.Close() + } + for len(d.conns) > 0 { + select { + case c := <-d.conns: + c.Close() + default: + } + } +} + +func getDialerByAddress(addr string) *dialerInstance { + if addr == "" { + return nil + } + mu.Lock() + defer mu.Unlock() + if sockoptDialers == nil { + sockoptDialers = make(map[string]*dialerInstance) + } + if dialer, found := sockoptDialers[addr]; found { + return dialer + } + dialer := newDialerInstance(addr) + sockoptDialers[addr] = dialer + return dialer +} + func DialWS(uri string, ed []byte) (*websocket.Conn, error) { + return DialWSWithAddress("", uri, ed) +} + +func DialWSWithAddress(addr string, uri string, ed []byte) (*websocket.Conn, error) { task := task{ - Method: "WS", - URL: uri, + Method: "WS", + URL: uri, StreamResponse: true, } @@ -105,7 +156,7 @@ func DialWS(uri string, ed []byte) (*websocket.Conn, error) { } } - return dialTask(task) + return dialTaskWithAddress(addr, task) } type httpExtra struct { @@ -143,29 +194,37 @@ func httpExtraFromHeadersAndCookies(headers http.Header, cookies []*http.Cookie) } func DialGet(uri string, headers http.Header, cookies []*http.Cookie) (*websocket.Conn, error) { + return DialGetWithAddress("", uri, headers, cookies) +} + +func DialGetWithAddress(addr string, uri string, headers http.Header, cookies []*http.Cookie) (*websocket.Conn, error) { task := task{ - Method: "GET", - URL: uri, - Extra: httpExtraFromHeadersAndCookies(headers, cookies), + Method: "GET", + URL: uri, + Extra: httpExtraFromHeadersAndCookies(headers, cookies), StreamResponse: true, } - return dialTask(task) + return dialTaskWithAddress(addr, task) } func DialPacket(method string, uri string, headers http.Header, cookies []*http.Cookie, payload []byte) error { - return dialWithBody(method, uri, headers, cookies, payload) + return DialPacketWithAddress("", method, uri, headers, cookies, payload) +} + +func DialPacketWithAddress(addr string, method string, uri string, headers http.Header, cookies []*http.Cookie, payload []byte) error { + return dialWithBody(addr, method, uri, headers, cookies, payload) } -func dialWithBody(method string, uri string, headers http.Header, cookies []*http.Cookie, payload []byte) error { +func dialWithBody(addr string, method string, uri string, headers http.Header, cookies []*http.Cookie, payload []byte) error { task := task{ - Method: method, - URL: uri, - Extra: httpExtraFromHeadersAndCookies(headers, cookies), + Method: method, + URL: uri, + Extra: httpExtraFromHeadersAndCookies(headers, cookies), StreamResponse: false, } - conn, err := dialTask(task) + conn, err := dialTaskWithAddress(addr, task) if err != nil { return err } @@ -185,11 +244,20 @@ func dialWithBody(method string, uri string, headers http.Header, cookies []*htt } func dialTask(task task) (*websocket.Conn, error) { + return dialTaskWithAddress("", task) +} + +func dialTaskWithAddress(addr string, task task) (*websocket.Conn, error) { data, err := json.Marshal(task) if err != nil { return nil, err } + conns := connsByAddress(addr) + if conns == nil { + return nil, errors.New("browser dialer is not configured") + } + var conn *websocket.Conn for { conn = <-conns @@ -219,7 +287,20 @@ func CheckOK(conn *websocket.Conn) error { return nil } +func connsByAddress(addr string) chan *websocket.Conn { + if addr != "" { + dialer := getDialerByAddress(addr) + if dialer == nil { + return nil + } + return dialer.conns + } + if HasBrowserDialer() { + return conns + } + return nil +} + func init() { Reload() } - diff --git a/transport/internet/config.pb.go b/transport/internet/config.pb.go index e2339fe8da7b..2fea7a48a39d 100644 --- a/transport/internet/config.pb.go +++ b/transport/internet/config.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v6.33.5 +// protoc v3.21.12 // source: transport/internet/config.proto package internet @@ -749,6 +749,7 @@ type SocketConfig struct { AddressPortStrategy AddressPortStrategy `protobuf:"varint,21,opt,name=address_port_strategy,json=addressPortStrategy,proto3,enum=xray.transport.internet.AddressPortStrategy" json:"address_port_strategy,omitempty"` HappyEyeballs *HappyEyeballsConfig `protobuf:"bytes,22,opt,name=happy_eyeballs,json=happyEyeballs,proto3" json:"happy_eyeballs,omitempty"` TrustedXForwardedFor []string `protobuf:"bytes,23,rep,name=trusted_x_forwarded_for,json=trustedXForwardedFor,proto3" json:"trusted_x_forwarded_for,omitempty"` + BrowserDialer string `protobuf:"bytes,24,opt,name=browser_dialer,json=browserDialer,proto3" json:"browser_dialer,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -944,6 +945,13 @@ func (x *SocketConfig) GetTrustedXForwardedFor() []string { return nil } +func (x *SocketConfig) GetBrowserDialer() string { + if x != nil { + return x.BrowserDialer + } + return "" +} + type HappyEyeballsConfig struct { state protoimpl.MessageState `protogen:"open.v1"` PrioritizeIpv6 bool `protobuf:"varint,1,opt,name=prioritize_ipv6,json=prioritizeIpv6,proto3" json:"prioritize_ipv6,omitempty"` @@ -1066,7 +1074,7 @@ const file_transport_internet_config_proto_rawDesc = "" + "\x05level\x18\x03 \x01(\tR\x05level\x12\x10\n" + "\x03opt\x18\x04 \x01(\tR\x03opt\x12\x14\n" + "\x05value\x18\x05 \x01(\tR\x05value\x12\x12\n" + - "\x04type\x18\x06 \x01(\tR\x04type\"\x89\t\n" + + "\x04type\x18\x06 \x01(\tR\x04type\"\xb0\t\n" + "\fSocketConfig\x12\x12\n" + "\x04mark\x18\x01 \x01(\x05R\x04mark\x12\x10\n" + "\x03tfo\x18\x02 \x01(\x05R\x03tfo\x12H\n" + @@ -1091,7 +1099,8 @@ const file_transport_internet_config_proto_rawDesc = "" + "\rcustomSockopt\x18\x14 \x03(\v2&.xray.transport.internet.CustomSockoptR\rcustomSockopt\x12`\n" + "\x15address_port_strategy\x18\x15 \x01(\x0e2,.xray.transport.internet.AddressPortStrategyR\x13addressPortStrategy\x12S\n" + "\x0ehappy_eyeballs\x18\x16 \x01(\v2,.xray.transport.internet.HappyEyeballsConfigR\rhappyEyeballs\x125\n" + - "\x17trusted_x_forwarded_for\x18\x17 \x03(\tR\x14trustedXForwardedFor\"/\n" + + "\x17trusted_x_forwarded_for\x18\x17 \x03(\tR\x14trustedXForwardedFor\x12%\n" + + "\x0ebrowser_dialer\x18\x18 \x01(\tR\rbrowserDialer\"/\n" + "\n" + "TProxyMode\x12\a\n" + "\x03Off\x10\x00\x12\n" + diff --git a/transport/internet/config.proto b/transport/internet/config.proto index ad23f047eaf2..79c22bf6957c 100644 --- a/transport/internet/config.proto +++ b/transport/internet/config.proto @@ -161,6 +161,8 @@ message SocketConfig { HappyEyeballsConfig happy_eyeballs = 22; repeated string trusted_x_forwarded_for = 23; + + string browser_dialer = 24; } message HappyEyeballsConfig { diff --git a/transport/internet/splithttp/browser_client.go b/transport/internet/splithttp/browser_client.go index a70447f2d48e..7334d8755a71 100644 --- a/transport/internet/splithttp/browser_client.go +++ b/transport/internet/splithttp/browser_client.go @@ -15,6 +15,7 @@ import ( // BrowserDialerClient implements splithttp.DialerClient in terms of browser dialer type BrowserDialerClient struct { transportConfig *Config + browserDialer string } func (c *BrowserDialerClient) IsClosed() bool { @@ -33,7 +34,7 @@ func (c *BrowserDialerClient) OpenStream(ctx context.Context, url string, sessio c.transportConfig.FillStreamRequest(request, sessionId, "") - conn, err := browser_dialer.DialGet(request.URL.String(), request.Header, request.Cookies()) + conn, err := browser_dialer.DialGetWithAddress(c.browserDialer, request.URL.String(), request.Header, request.Cookies()) dummyAddr := &net.IPAddr{} if err != nil { return nil, dummyAddr, dummyAddr, err @@ -62,7 +63,7 @@ func (c *BrowserDialerClient) PostPacket(ctx context.Context, url string, sessio } } - err = browser_dialer.DialPacket(method, request.URL.String(), request.Header, request.Cookies(), bytes) + err = browser_dialer.DialPacketWithAddress(c.browserDialer, method, request.URL.String(), request.Header, request.Cookies(), bytes) if err != nil { return err } diff --git a/transport/internet/splithttp/dialer.go b/transport/internet/splithttp/dialer.go index 6f4ec1d83fa2..cda9cadb4f28 100644 --- a/transport/internet/splithttp/dialer.go +++ b/transport/internet/splithttp/dialer.go @@ -47,9 +47,16 @@ var ( func getHTTPClient(ctx context.Context, dest net.Destination, streamSettings *internet.MemoryStreamConfig) (DialerClient, *XmuxClient) { realityConfig := reality.ConfigFromStreamSettings(streamSettings) + browserDialer := "" + if streamSettings.SocketSettings != nil { + browserDialer = streamSettings.SocketSettings.BrowserDialer + } - if browser_dialer.HasBrowserDialer() && realityConfig == nil { - return &BrowserDialerClient{transportConfig: streamSettings.ProtocolSettings.(*Config)}, nil + if browser_dialer.HasBrowserDialerWithAddress(browserDialer) && realityConfig == nil { + return &BrowserDialerClient{ + transportConfig: streamSettings.ProtocolSettings.(*Config), + browserDialer: browserDialer, + }, nil } globalDialerAccess.Lock() diff --git a/transport/internet/websocket/dialer.go b/transport/internet/websocket/dialer.go index e5354908d77c..99fbc877d708 100644 --- a/transport/internet/websocket/dialer.go +++ b/transport/internet/websocket/dialer.go @@ -117,8 +117,12 @@ func dialWebSocket(ctx context.Context, dest net.Destination, streamSettings *in } uri := protocol + "://" + host + wsSettings.GetNormalizedPath() - if browser_dialer.HasBrowserDialer() { - conn, err := browser_dialer.DialWS(uri, ed) + browserDialer := "" + if streamSettings.SocketSettings != nil { + browserDialer = streamSettings.SocketSettings.BrowserDialer + } + if browser_dialer.HasBrowserDialerWithAddress(browserDialer) { + conn, err := browser_dialer.DialWSWithAddress(browserDialer, uri, ed) if err != nil { return nil, err } From 2fdfa727b432f1547bc5b25affdfc8342ff50a77 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 07:26:34 +0000 Subject: [PATCH 02/44] refactor: improve browser dialer instance handling and diagnostics Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/56665ec5-84ea-4bc3-a812-2e699e0e880d Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 32 +++++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index d7cfb8107b5a..f7088d52f344 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -6,6 +6,7 @@ import ( _ "embed" "encoding/base64" "encoding/json" + stderrors "errors" "net/http" "sync" "time" @@ -44,17 +45,20 @@ var upgrader = &websocket.Upgrader{ func Reload() { addr := getEnvAddress() mu.Lock() - defer mu.Unlock() closeDialerInstance(&dialerInstance{conns: conns, server: server}) conns = nil server = nil + var dialer *dialerInstance if addr != "" { - dialer := newDialerInstance(addr) + dialer = newDialerInstance(addr) conns = dialer.conns server = dialer.server } + mu.Unlock() + + startDialerInstance(dialer) } func HasBrowserDialer() bool { @@ -102,10 +106,20 @@ func newDialerInstance(addr string) *dialerInstance { } }), } - go dialer.server.ListenAndServe() return dialer } +func startDialerInstance(dialer *dialerInstance) { + if dialer == nil || dialer.server == nil { + return + } + go func() { + if err := dialer.server.ListenAndServe(); err != nil && !stderrors.Is(err, http.ErrServerClosed) { + errors.LogError(context.Background(), "Browser dialer http server unexpected error on ", dialer.server.Addr, ": ", err) + } + }() +} + func closeDialerInstance(d *dialerInstance) { if d == nil { return @@ -113,11 +127,12 @@ func closeDialerInstance(d *dialerInstance) { if d.server != nil { d.server.Close() } - for len(d.conns) > 0 { + for { select { case c := <-d.conns: c.Close() default: + return } } } @@ -127,15 +142,17 @@ func getDialerByAddress(addr string) *dialerInstance { return nil } mu.Lock() - defer mu.Unlock() if sockoptDialers == nil { sockoptDialers = make(map[string]*dialerInstance) } if dialer, found := sockoptDialers[addr]; found { + mu.Unlock() return dialer } dialer := newDialerInstance(addr) sockoptDialers[addr] = dialer + mu.Unlock() + startDialerInstance(dialer) return dialer } @@ -255,7 +272,10 @@ func dialTaskWithAddress(addr string, task task) (*websocket.Conn, error) { conns := connsByAddress(addr) if conns == nil { - return nil, errors.New("browser dialer is not configured") + if addr != "" { + return nil, errors.New("browser dialer is not configured for sockopt.browserDialer: ", addr) + } + return nil, errors.New("browser dialer is not configured; set sockopt.browserDialer or env ", platform.BrowserDialerAddress) } var conn *websocket.Conn From 27b8b29ef83be33ff4c047079ee17a7fbb46fbaa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 07:44:58 +0000 Subject: [PATCH 03/44] refactor: remove browser dialer env reload path and refresh proto metadata Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/e3502f01-e171-4e6f-9ae7-dc29b6682951 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 59 +++++---------------- transport/internet/config.pb.go | 2 +- 2 files changed, 14 insertions(+), 47 deletions(-) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index f7088d52f344..0028f6f90e31 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -7,13 +7,13 @@ import ( "encoding/base64" "encoding/json" stderrors "errors" + "net" "net/http" "sync" "time" "github.com/gorilla/websocket" "github.com/xtls/xray-core/common/errors" - "github.com/xtls/xray-core/common/platform" "github.com/xtls/xray-core/common/uuid" ) @@ -27,8 +27,6 @@ type task struct { StreamResponse bool `json:"streamResponse"` } -var conns chan *websocket.Conn -var server *http.Server var sockoptDialers map[string]*dialerInstance var mu sync.Mutex @@ -41,32 +39,12 @@ var upgrader = &websocket.Upgrader{ }, } -// Used by external projects when using xray as a go module -func Reload() { - addr := getEnvAddress() - mu.Lock() - - closeDialerInstance(&dialerInstance{conns: conns, server: server}) - conns = nil - server = nil - - var dialer *dialerInstance - if addr != "" { - dialer = newDialerInstance(addr) - conns = dialer.conns - server = dialer.server - } - mu.Unlock() - - startDialerInstance(dialer) -} - -func HasBrowserDialer() bool { - return conns != nil -} - func HasBrowserDialerWithAddress(addr string) bool { - return connsByAddress(addr) != nil + if addr == "" { + return false + } + _, _, err := net.SplitHostPort(addr) + return err == nil } type webSocketExtra struct { @@ -78,10 +56,6 @@ type dialerInstance struct { server *http.Server } -func getEnvAddress() string { - return platform.NewEnvFlag(platform.BrowserDialerAddress).GetValue(func() string { return "" }) -} - func newDialerInstance(addr string) *dialerInstance { token := uuid.New() csrfToken := token.String() @@ -275,7 +249,7 @@ func dialTaskWithAddress(addr string, task task) (*websocket.Conn, error) { if addr != "" { return nil, errors.New("browser dialer is not configured for sockopt.browserDialer: ", addr) } - return nil, errors.New("browser dialer is not configured; set sockopt.browserDialer or env ", platform.BrowserDialerAddress) + return nil, errors.New("browser dialer is not configured; set sockopt.browserDialer") } var conn *websocket.Conn @@ -308,19 +282,12 @@ func CheckOK(conn *websocket.Conn) error { } func connsByAddress(addr string) chan *websocket.Conn { - if addr != "" { - dialer := getDialerByAddress(addr) - if dialer == nil { - return nil - } - return dialer.conns + if addr == "" { + return nil } - if HasBrowserDialer() { - return conns + dialer := getDialerByAddress(addr) + if dialer == nil { + return nil } - return nil -} - -func init() { - Reload() + return dialer.conns } diff --git a/transport/internet/config.pb.go b/transport/internet/config.pb.go index 2fea7a48a39d..3995a83dcd8b 100644 --- a/transport/internet/config.pb.go +++ b/transport/internet/config.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v3.21.12 +// protoc v6.33.5 // source: transport/internet/config.proto package internet From 84d04d0eab8dca9c2c90f34d901ecfee82bd1319 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 07:55:48 +0000 Subject: [PATCH 04/44] chore: warn legacy browser dialer env has been removed Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/d30a26b2-60aa-4f86-b2ed-3c6997652bfb Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 0028f6f90e31..68345a5ef432 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -14,6 +14,7 @@ import ( "github.com/gorilla/websocket" "github.com/xtls/xray-core/common/errors" + "github.com/xtls/xray-core/common/platform" "github.com/xtls/xray-core/common/uuid" ) @@ -291,3 +292,15 @@ func connsByAddress(addr string) chan *websocket.Conn { } return dialer.conns } + +func notifyRemovedEnv() { + envAddress := platform.NewEnvFlag(platform.BrowserDialerAddress).GetValue(func() string { return "" }) + if envAddress == "" { + return + } + errors.LogWarning(context.Background(), errors.PrintRemovedFeatureError("env "+platform.BrowserDialerAddress, "sockopt.browserDialer")) +} + +func init() { + notifyRemovedEnv() +} From 2691a1aa0ed336a775390060d9132c5e2163c965 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:01:48 +0000 Subject: [PATCH 05/44] feat: use path-based browser dialer csrf endpoint Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/b6b47cc0-5a64-49d5-9447-22c9c202c95d Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 21 ++++++++++--------- transport/internet/browser_dialer/dialer.html | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 68345a5ef432..45e13d5556e3 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -61,23 +61,24 @@ func newDialerInstance(addr string) *dialerInstance { token := uuid.New() csrfToken := token.String() page := bytes.ReplaceAll(webpage, []byte("csrfToken"), []byte(csrfToken)) + wsPath := "/websocket/" + csrfToken dialer := &dialerInstance{ conns: make(chan *websocket.Conn, 256), } dialer.server = &http.Server{ Addr: addr, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/websocket" { - if r.URL.Query().Get("token") == csrfToken { - if conn, err := upgrader.Upgrade(w, r, nil); err == nil { - dialer.conns <- conn - } else { - errors.LogError(context.Background(), "Browser dialer http upgrade unexpected error") - } + if r.URL.Path == wsPath { + if conn, err := upgrader.Upgrade(w, r, nil); err == nil { + dialer.conns <- conn + } else { + errors.LogError(context.Background(), "Browser dialer http upgrade unexpected error: ", err) } - } else { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Write(page) + return + } + w.Header().Set("Access-Control-Allow-Origin", "*") + if _, err := w.Write(page); err != nil { + errors.LogError(context.Background(), "Browser dialer http page write unexpected error: ", err) } }), } diff --git a/transport/internet/browser_dialer/dialer.html b/transport/internet/browser_dialer/dialer.html index 5a0df4892b81..255f9ed4d1d4 100644 --- a/transport/internet/browser_dialer/dialer.html +++ b/transport/internet/browser_dialer/dialer.html @@ -10,7 +10,7 @@ // Enable a much more aggressive JIT for performance gains // Copyright (c) 2021 XRAY. Mozilla Public License 2.0. - let url = "ws://" + window.location.host + "/websocket?token=csrfToken"; + let url = "ws://" + window.location.host + "/websocket/csrfToken"; let clientIdleCount = 0; let upstreamGetCount = 0; let upstreamWsCount = 0; From c48c4752567c38f0608240631fadfe04577b4a95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:20:37 +0000 Subject: [PATCH 06/44] feat: require browserDialer path and multiplex by path Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/d3d25e7b-e62f-49e0-90be-0ca7f974e115 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 156 +++++++++++++++--- transport/internet/browser_dialer/dialer.html | 2 +- 2 files changed, 130 insertions(+), 28 deletions(-) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 45e13d5556e3..f094c94db2cf 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -9,6 +9,9 @@ import ( stderrors "errors" "net" "net/http" + "net/url" + pathlib "path" + "strings" "sync" "time" @@ -29,7 +32,8 @@ type task struct { } var sockoptDialers map[string]*dialerInstance -var mu sync.Mutex +var dialerServers map[string]*dialerServer +var mu sync.RWMutex var upgrader = &websocket.Upgrader{ ReadBufferSize: 0, @@ -41,11 +45,8 @@ var upgrader = &websocket.Upgrader{ } func HasBrowserDialerWithAddress(addr string) bool { - if addr == "" { - return false - } - _, _, err := net.SplitHostPort(addr) - return err == nil + _, ok := parseBrowserDialerAddress(addr) + return ok } type webSocketExtra struct { @@ -53,39 +54,121 @@ type webSocketExtra struct { } type dialerInstance struct { - conns chan *websocket.Conn - server *http.Server + conns chan *websocket.Conn + pagePath string + wsPath string + page []byte +} + +type dialerServer struct { + server *http.Server + pageRoutes map[string]*dialerInstance + wsRoutes map[string]*dialerInstance +} + +type browserDialerAddress struct { + listenAddr string + path string } -func newDialerInstance(addr string) *dialerInstance { +func parseBrowserDialerAddress(addr string) (*browserDialerAddress, bool) { + if addr == "" { + return nil, false + } + + index := strings.Index(addr, "/") + if index <= 0 { + return nil, false + } + + listenAddr := addr[:index] + path := strings.TrimSuffix(addr[index:], "/") + if path == "" { + return nil, false + } + if _, _, err := net.SplitHostPort(listenAddr); err != nil { + return nil, false + } + parsedPath, err := url.ParseRequestURI(path) + if err != nil || parsedPath.RawQuery != "" || parsedPath.Fragment != "" { + return nil, false + } + cleanPath := pathlib.Clean(path) + if cleanPath == "." || cleanPath == "/" || cleanPath != path { + return nil, false + } + + return &browserDialerAddress{ + listenAddr: listenAddr, + path: cleanPath, + }, true +} + +func newDialerInstance(path string) *dialerInstance { token := uuid.New() csrfToken := token.String() - page := bytes.ReplaceAll(webpage, []byte("csrfToken"), []byte(csrfToken)) - wsPath := "/websocket/" + csrfToken + escapedCsrfToken := url.PathEscape(csrfToken) + wsPath := path + "/" + escapedCsrfToken + page := bytes.ReplaceAll(webpage, []byte("dialerPath"), []byte(strings.TrimPrefix(path, "/"))) + page = bytes.ReplaceAll(page, []byte("csrfToken"), []byte(escapedCsrfToken)) dialer := &dialerInstance{ - conns: make(chan *websocket.Conn, 256), + conns: make(chan *websocket.Conn, 256), + pagePath: path, + wsPath: wsPath, + page: page, + } + return dialer +} + +func newDialerServer(listenAddr string) *dialerServer { + dialer := &dialerServer{ + pageRoutes: make(map[string]*dialerInstance), + wsRoutes: make(map[string]*dialerInstance), } dialer.server = &http.Server{ - Addr: addr, + Addr: listenAddr, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == wsPath { + mu.RLock() + wsDialer := dialer.wsRoutes[r.URL.Path] + pageDialer := dialer.pageRoutes[r.URL.Path] + mu.RUnlock() + + if wsDialer != nil { if conn, err := upgrader.Upgrade(w, r, nil); err == nil { - dialer.conns <- conn + wsDialer.conns <- conn } else { errors.LogError(context.Background(), "Browser dialer http upgrade unexpected error: ", err) } return } - w.Header().Set("Access-Control-Allow-Origin", "*") - if _, err := w.Write(page); err != nil { - errors.LogError(context.Background(), "Browser dialer http page write unexpected error: ", err) + + if pageDialer != nil { + w.Header().Set("Access-Control-Allow-Origin", "*") + if _, err := w.Write(pageDialer.page); err != nil { + errors.LogError(context.Background(), "Browser dialer http page write unexpected error: ", err) + } + return } + + closeConnection(w) }), } return dialer } -func startDialerInstance(dialer *dialerInstance) { +func closeConnection(w http.ResponseWriter) { + hijacker, ok := w.(http.Hijacker) + if !ok { + return + } + conn, _, err := hijacker.Hijack() + if err != nil { + return + } + conn.Close() +} + +func startDialerServer(dialer *dialerServer) { if dialer == nil || dialer.server == nil { return } @@ -100,9 +183,6 @@ func closeDialerInstance(d *dialerInstance) { if d == nil { return } - if d.server != nil { - d.server.Close() - } for { select { case c := <-d.conns: @@ -114,21 +194,43 @@ func closeDialerInstance(d *dialerInstance) { } func getDialerByAddress(addr string) *dialerInstance { - if addr == "" { + parsed, ok := parseBrowserDialerAddress(addr) + if !ok { return nil } + + key := parsed.listenAddr + parsed.path + startServer := false + mu.Lock() if sockoptDialers == nil { sockoptDialers = make(map[string]*dialerInstance) } - if dialer, found := sockoptDialers[addr]; found { + if dialerServers == nil { + dialerServers = make(map[string]*dialerServer) + } + if dialer, found := sockoptDialers[key]; found { mu.Unlock() return dialer } - dialer := newDialerInstance(addr) - sockoptDialers[addr] = dialer + + server, found := dialerServers[parsed.listenAddr] + if !found { + server = newDialerServer(parsed.listenAddr) + dialerServers[parsed.listenAddr] = server + startServer = true + } + + dialer := newDialerInstance(parsed.path) + sockoptDialers[key] = dialer + server.pageRoutes[dialer.pagePath] = dialer + server.wsRoutes[dialer.wsPath] = dialer mu.Unlock() - startDialerInstance(dialer) + + if startServer { + startDialerServer(server) + } + return dialer } diff --git a/transport/internet/browser_dialer/dialer.html b/transport/internet/browser_dialer/dialer.html index 255f9ed4d1d4..379dc52bdf76 100644 --- a/transport/internet/browser_dialer/dialer.html +++ b/transport/internet/browser_dialer/dialer.html @@ -10,7 +10,7 @@ // Enable a much more aggressive JIT for performance gains // Copyright (c) 2021 XRAY. Mozilla Public License 2.0. - let url = "ws://" + window.location.host + "/websocket/csrfToken"; + let url = "ws://" + window.location.host + "/dialerPath/csrfToken"; let clientIdleCount = 0; let upstreamGetCount = 0; let upstreamWsCount = 0; From 57253b736d4f11ed08a5711d9eb8aabb900de922 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:43:35 +0000 Subject: [PATCH 07/44] refactor: use path plus header for browser dialer upgrade Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/33541da0-fdc4-4415-b7c4-7183f01b762e Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 31 ++++++++++--------- transport/internet/browser_dialer/dialer.html | 4 +-- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index f094c94db2cf..1784855f3505 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -18,7 +18,6 @@ import ( "github.com/gorilla/websocket" "github.com/xtls/xray-core/common/errors" "github.com/xtls/xray-core/common/platform" - "github.com/xtls/xray-core/common/uuid" ) //go:embed dialer.html @@ -35,6 +34,8 @@ var sockoptDialers map[string]*dialerInstance var dialerServers map[string]*dialerServer var mu sync.RWMutex +const browserDialerSubprotocol = "browser-dialer" + var upgrader = &websocket.Upgrader{ ReadBufferSize: 0, WriteBufferSize: 0, @@ -56,14 +57,12 @@ type webSocketExtra struct { type dialerInstance struct { conns chan *websocket.Conn pagePath string - wsPath string page []byte } type dialerServer struct { server *http.Server pageRoutes map[string]*dialerInstance - wsRoutes map[string]*dialerInstance } type browserDialerAddress struct { @@ -105,16 +104,10 @@ func parseBrowserDialerAddress(addr string) (*browserDialerAddress, bool) { } func newDialerInstance(path string) *dialerInstance { - token := uuid.New() - csrfToken := token.String() - escapedCsrfToken := url.PathEscape(csrfToken) - wsPath := path + "/" + escapedCsrfToken page := bytes.ReplaceAll(webpage, []byte("dialerPath"), []byte(strings.TrimPrefix(path, "/"))) - page = bytes.ReplaceAll(page, []byte("csrfToken"), []byte(escapedCsrfToken)) dialer := &dialerInstance{ conns: make(chan *websocket.Conn, 256), pagePath: path, - wsPath: wsPath, page: page, } return dialer @@ -123,19 +116,28 @@ func newDialerInstance(path string) *dialerInstance { func newDialerServer(listenAddr string) *dialerServer { dialer := &dialerServer{ pageRoutes: make(map[string]*dialerInstance), - wsRoutes: make(map[string]*dialerInstance), } dialer.server = &http.Server{ Addr: listenAddr, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.RLock() - wsDialer := dialer.wsRoutes[r.URL.Path] pageDialer := dialer.pageRoutes[r.URL.Path] mu.RUnlock() - if wsDialer != nil { - if conn, err := upgrader.Upgrade(w, r, nil); err == nil { - wsDialer.conns <- conn + if pageDialer != nil && websocket.IsWebSocketUpgrade(r) { + ok := false + for _, protocol := range websocket.Subprotocols(r) { + if protocol == browserDialerSubprotocol { + ok = true + break + } + } + if !ok { + closeConnection(w) + return + } + if conn, err := upgrader.Upgrade(w, r, http.Header{"Sec-WebSocket-Protocol": []string{browserDialerSubprotocol}}); err == nil { + pageDialer.conns <- conn } else { errors.LogError(context.Background(), "Browser dialer http upgrade unexpected error: ", err) } @@ -224,7 +226,6 @@ func getDialerByAddress(addr string) *dialerInstance { dialer := newDialerInstance(parsed.path) sockoptDialers[key] = dialer server.pageRoutes[dialer.pagePath] = dialer - server.wsRoutes[dialer.wsPath] = dialer mu.Unlock() if startServer { diff --git a/transport/internet/browser_dialer/dialer.html b/transport/internet/browser_dialer/dialer.html index 379dc52bdf76..acef530ba540 100644 --- a/transport/internet/browser_dialer/dialer.html +++ b/transport/internet/browser_dialer/dialer.html @@ -10,7 +10,7 @@ // Enable a much more aggressive JIT for performance gains // Copyright (c) 2021 XRAY. Mozilla Public License 2.0. - let url = "ws://" + window.location.host + "/dialerPath/csrfToken"; + let url = "ws://" + window.location.host + "/dialerPath"; let clientIdleCount = 0; let upstreamGetCount = 0; let upstreamWsCount = 0; @@ -67,7 +67,7 @@ } clientIdleCount += 1; console.log("Prepare", url); - let ws = new WebSocket(url); + let ws = new WebSocket(url, "browser-dialer"); // arraybuffer is significantly faster in chrome than default // blob, tested with chrome 123 ws.binaryType = "arraybuffer"; From 8fca7745fb19e53a0a9599ace77441773bcd7ad9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:49:47 +0000 Subject: [PATCH 08/44] feat: require UUID path for sockopt browser dialer Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/f1db6008-e292-4b43-a2e0-0bc80567ebba Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 28 +++++++++++++++++++ .../internet/browser_dialer/dialer_test.go | 22 +++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 transport/internet/browser_dialer/dialer_test.go diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 1784855f3505..960c9470cb0f 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -35,6 +35,7 @@ var dialerServers map[string]*dialerServer var mu sync.RWMutex const browserDialerSubprotocol = "browser-dialer" +const uuidPathLength = 37 var upgrader = &websocket.Upgrader{ ReadBufferSize: 0, @@ -96,6 +97,9 @@ func parseBrowserDialerAddress(addr string) (*browserDialerAddress, bool) { if cleanPath == "." || cleanPath == "/" || cleanPath != path { return nil, false } + if !isUUIDPath(cleanPath) { + return nil, false + } return &browserDialerAddress{ listenAddr: listenAddr, @@ -103,6 +107,30 @@ func parseBrowserDialerAddress(addr string) (*browserDialerAddress, bool) { }, true } +func isUUIDPath(path string) bool { + if len(path) != uuidPathLength || path[0] != '/' || strings.Count(path, "/") != 1 { + return false + } + + u := path[1:] + for i := 0; i < len(u); i++ { + c := u[i] + switch i { + case 8, 13, 18, 23: + if c != '-' { + return false + } + default: + isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') + if !isHex { + return false + } + } + } + + return true +} + func newDialerInstance(path string) *dialerInstance { page := bytes.ReplaceAll(webpage, []byte("dialerPath"), []byte(strings.TrimPrefix(path, "/"))) dialer := &dialerInstance{ diff --git a/transport/internet/browser_dialer/dialer_test.go b/transport/internet/browser_dialer/dialer_test.go new file mode 100644 index 000000000000..506ee2a2a77b --- /dev/null +++ b/transport/internet/browser_dialer/dialer_test.go @@ -0,0 +1,22 @@ +package browser_dialer + +import "testing" + +func TestParseBrowserDialerAddressRequireUUIDPath(t *testing.T) { + valid := "127.0.0.1:8080/123e4567-e89b-12d3-a456-426614174000" + if _, ok := parseBrowserDialerAddress(valid); !ok { + t.Fatalf("expected valid browser dialer address: %s", valid) + } + + invalid := []string{ + "127.0.0.1:8080/example", + "127.0.0.1:8080/short", + "127.0.0.1:8080/123e4567e89b12d3a456426614174000", + "127.0.0.1:8080/123e4567-e89b-12d3-a456-426614174000/extra", + } + for _, addr := range invalid { + if _, ok := parseBrowserDialerAddress(addr); ok { + t.Fatalf("expected invalid browser dialer address: %s", addr) + } + } +} From 9ad099774a989f0cf4a2b19494346b91f9c92a5c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:54:00 +0000 Subject: [PATCH 09/44] refactor: simplify browser dialer UUID path validation Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/9288cc3c-d788-49d9-8bdc-f03281c238bf Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 30 +++------------------ 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 960c9470cb0f..5dd530f2f030 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" pathlib "path" + "regexp" "strings" "sync" "time" @@ -35,7 +36,8 @@ var dialerServers map[string]*dialerServer var mu sync.RWMutex const browserDialerSubprotocol = "browser-dialer" -const uuidPathLength = 37 + +var uuidPathPattern = regexp.MustCompile(`^/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) var upgrader = &websocket.Upgrader{ ReadBufferSize: 0, @@ -97,7 +99,7 @@ func parseBrowserDialerAddress(addr string) (*browserDialerAddress, bool) { if cleanPath == "." || cleanPath == "/" || cleanPath != path { return nil, false } - if !isUUIDPath(cleanPath) { + if !uuidPathPattern.MatchString(cleanPath) { return nil, false } @@ -107,30 +109,6 @@ func parseBrowserDialerAddress(addr string) (*browserDialerAddress, bool) { }, true } -func isUUIDPath(path string) bool { - if len(path) != uuidPathLength || path[0] != '/' || strings.Count(path, "/") != 1 { - return false - } - - u := path[1:] - for i := 0; i < len(u); i++ { - c := u[i] - switch i { - case 8, 13, 18, 23: - if c != '-' { - return false - } - default: - isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') - if !isHex { - return false - } - } - } - - return true -} - func newDialerInstance(path string) *dialerInstance { page := bytes.ReplaceAll(webpage, []byte("dialerPath"), []byte(strings.TrimPrefix(path, "/"))) dialer := &dialerInstance{ From aeb689284c392f6d14c9d9dfe6ad3f4833964f91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:03:45 +0000 Subject: [PATCH 10/44] feat: validate and initialize browser dialer at config build Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/d0035ff5-3633-402f-890e-e68c267a65c1 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- infra/conf/transport_browser_dialer_test.go | 63 +++++++++++++++++++ infra/conf/transport_internet.go | 12 ++++ transport/internet/browser_dialer/dialer.go | 70 +++++++++++++++------ 3 files changed, 127 insertions(+), 18 deletions(-) create mode 100644 infra/conf/transport_browser_dialer_test.go diff --git a/infra/conf/transport_browser_dialer_test.go b/infra/conf/transport_browser_dialer_test.go new file mode 100644 index 000000000000..aae0b36aeb9e --- /dev/null +++ b/infra/conf/transport_browser_dialer_test.go @@ -0,0 +1,63 @@ +package conf_test + +import ( + "net" + "strings" + "testing" + + . "github.com/xtls/xray-core/infra/conf" +) + +const testBrowserDialerPath = "/123e4567-e89b-12d3-a456-426614174000" + +func TestStreamConfigBuildRejectsBrowserDialerUnsupportedProtocol(t *testing.T) { + network := TransportProtocol("tcp") + config := &StreamConfig{ + Network: &network, + SocketSettings: &SocketConfig{ + BrowserDialer: "127.0.0.1:18080" + testBrowserDialerPath, + }, + } + + _, err := config.Build() + if err == nil || !strings.Contains(err.Error(), "sockopt.browserDialer only supports WS or XHTTP") { + t.Fatalf("expected unsupported protocol error, got: %v", err) + } +} + +func TestStreamConfigBuildRejectsBrowserDialerWithREALITY(t *testing.T) { + network := TransportProtocol("splithttp") + config := &StreamConfig{ + Network: &network, + Security: "reality", + SocketSettings: &SocketConfig{ + BrowserDialer: "127.0.0.1:18081" + testBrowserDialerPath, + }, + } + + _, err := config.Build() + if err == nil || !strings.Contains(err.Error(), "sockopt.browserDialer does not support REALITY") { + t.Fatalf("expected REALITY rejection, got: %v", err) + } +} + +func TestStreamConfigBuildFailsOnBrowserDialerAddressConflict(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to prepare occupied listener: %v", err) + } + defer listener.Close() + + network := TransportProtocol("websocket") + config := &StreamConfig{ + Network: &network, + SocketSettings: &SocketConfig{ + BrowserDialer: listener.Addr().String() + testBrowserDialerPath, + }, + } + + _, err = config.Build() + if err == nil || !strings.Contains(err.Error(), "Failed to start Browser Dialer listener") { + t.Fatalf("expected address conflict error, got: %v", err) + } +} diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index b06a8894ded3..785571221581 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -21,6 +21,7 @@ import ( "github.com/xtls/xray-core/common/platform/filesystem" "github.com/xtls/xray-core/common/serial" "github.com/xtls/xray-core/transport/internet" + "github.com/xtls/xray-core/transport/internet/browser_dialer" "github.com/xtls/xray-core/transport/internet/finalmask/fragment" "github.com/xtls/xray-core/transport/internet/finalmask/header/custom" "github.com/xtls/xray-core/transport/internet/finalmask/header/dns" @@ -1972,6 +1973,14 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { } config.ProtocolName = protocol } + if c.SocketSettings != nil && c.SocketSettings.BrowserDialer != "" { + if config.ProtocolName != "websocket" && config.ProtocolName != "splithttp" { + return nil, errors.New("sockopt.browserDialer only supports WS or XHTTP") + } + if strings.EqualFold(c.Security, "reality") { + return nil, errors.New("sockopt.browserDialer does not support REALITY") + } + } switch strings.ToLower(c.Security) { case "", "none": @@ -2088,6 +2097,9 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { if err != nil { return nil, errors.New("Failed to build sockopt.").Base(err) } + if err := browser_dialer.EnsureDialerWithAddress(ss.BrowserDialer); err != nil { + return nil, errors.New("Failed to start Browser Dialer listener.").Base(err) + } config.SocketSettings = ss } diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 5dd530f2f030..627407d004a8 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -11,7 +11,6 @@ import ( "net/http" "net/url" pathlib "path" - "regexp" "strings" "sync" "time" @@ -19,6 +18,7 @@ import ( "github.com/gorilla/websocket" "github.com/xtls/xray-core/common/errors" "github.com/xtls/xray-core/common/platform" + "github.com/xtls/xray-core/common/uuid" ) //go:embed dialer.html @@ -37,8 +37,6 @@ var mu sync.RWMutex const browserDialerSubprotocol = "browser-dialer" -var uuidPathPattern = regexp.MustCompile(`^/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) - var upgrader = &websocket.Upgrader{ ReadBufferSize: 0, WriteBufferSize: 0, @@ -66,6 +64,7 @@ type dialerInstance struct { type dialerServer struct { server *http.Server pageRoutes map[string]*dialerInstance + started bool } type browserDialerAddress struct { @@ -99,7 +98,15 @@ func parseBrowserDialerAddress(addr string) (*browserDialerAddress, bool) { if cleanPath == "." || cleanPath == "/" || cleanPath != path { return nil, false } - if !uuidPathPattern.MatchString(cleanPath) { + if strings.Count(cleanPath, "/") != 1 { + return nil, false + } + id := strings.TrimPrefix(cleanPath, "/") + if len(id) != 36 { + return nil, false + } + parsedUUID, err := uuid.ParseString(id) + if err != nil || !strings.EqualFold(parsedUUID.String(), id) { return nil, false } @@ -176,15 +183,20 @@ func closeConnection(w http.ResponseWriter) { conn.Close() } -func startDialerServer(dialer *dialerServer) { +func startDialerServer(dialer *dialerServer) error { if dialer == nil || dialer.server == nil { - return + return nil + } + listener, err := net.Listen("tcp", dialer.server.Addr) + if err != nil { + return err } go func() { - if err := dialer.server.ListenAndServe(); err != nil && !stderrors.Is(err, http.ErrServerClosed) { + if err := dialer.server.Serve(listener); err != nil && !stderrors.Is(err, http.ErrServerClosed) { errors.LogError(context.Background(), "Browser dialer http server unexpected error on ", dialer.server.Addr, ": ", err) } }() + return nil } func closeDialerInstance(d *dialerInstance) { @@ -201,14 +213,15 @@ func closeDialerInstance(d *dialerInstance) { } } -func getDialerByAddress(addr string) *dialerInstance { +func getDialerByAddress(addr string) (*dialerInstance, error) { parsed, ok := parseBrowserDialerAddress(addr) if !ok { - return nil + return nil, errors.New("invalid sockopt.browserDialer: ", addr) } key := parsed.listenAddr + parsed.path - startServer := false + var server *dialerServer + var dialer *dialerInstance mu.Lock() if sockoptDialers == nil { @@ -219,26 +232,47 @@ func getDialerByAddress(addr string) *dialerInstance { } if dialer, found := sockoptDialers[key]; found { mu.Unlock() - return dialer + return dialer, nil } - server, found := dialerServers[parsed.listenAddr] + found := false + server, found = dialerServers[parsed.listenAddr] if !found { server = newDialerServer(parsed.listenAddr) dialerServers[parsed.listenAddr] = server - startServer = true } - dialer := newDialerInstance(parsed.path) + dialer = newDialerInstance(parsed.path) sockoptDialers[key] = dialer server.pageRoutes[dialer.pagePath] = dialer + startServer := !server.started + server.started = true mu.Unlock() if startServer { - startDialerServer(server) + if err := startDialerServer(server); err != nil { + mu.Lock() + delete(sockoptDialers, key) + delete(server.pageRoutes, dialer.pagePath) + if len(server.pageRoutes) == 0 { + delete(dialerServers, parsed.listenAddr) + server.started = false + } + mu.Unlock() + closeDialerInstance(dialer) + return nil, err + } } - return dialer + return dialer, nil +} + +func EnsureDialerWithAddress(addr string) error { + if addr == "" { + return nil + } + _, err := getDialerByAddress(addr) + return err } func DialWS(uri string, ed []byte) (*websocket.Conn, error) { @@ -396,8 +430,8 @@ func connsByAddress(addr string) chan *websocket.Conn { if addr == "" { return nil } - dialer := getDialerByAddress(addr) - if dialer == nil { + dialer, err := getDialerByAddress(addr) + if err != nil || dialer == nil { return nil } return dialer.conns From e06c536ce692cfe8aac5dba4a10be5a31ad4a380 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:05:57 +0000 Subject: [PATCH 11/44] refactor: tighten browser dialer UUID and config error handling Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/d0035ff5-3633-402f-890e-e68c267a65c1 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- infra/conf/transport_browser_dialer_test.go | 2 +- infra/conf/transport_internet.go | 2 +- transport/internet/browser_dialer/dialer.go | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/infra/conf/transport_browser_dialer_test.go b/infra/conf/transport_browser_dialer_test.go index aae0b36aeb9e..c5e3a38300e0 100644 --- a/infra/conf/transport_browser_dialer_test.go +++ b/infra/conf/transport_browser_dialer_test.go @@ -20,7 +20,7 @@ func TestStreamConfigBuildRejectsBrowserDialerUnsupportedProtocol(t *testing.T) } _, err := config.Build() - if err == nil || !strings.Contains(err.Error(), "sockopt.browserDialer only supports WS or XHTTP") { + if err == nil || !strings.Contains(err.Error(), "sockopt.browserDialer only supports websocket or splithttp") { t.Fatalf("expected unsupported protocol error, got: %v", err) } } diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index 785571221581..071a91706f69 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -1975,7 +1975,7 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { } if c.SocketSettings != nil && c.SocketSettings.BrowserDialer != "" { if config.ProtocolName != "websocket" && config.ProtocolName != "splithttp" { - return nil, errors.New("sockopt.browserDialer only supports WS or XHTTP") + return nil, errors.New("sockopt.browserDialer only supports websocket or splithttp") } if strings.EqualFold(c.Security, "reality") { return nil, errors.New("sockopt.browserDialer does not support REALITY") diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 627407d004a8..27ada3f1a712 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -106,9 +106,11 @@ func parseBrowserDialerAddress(addr string) (*browserDialerAddress, bool) { return nil, false } parsedUUID, err := uuid.ParseString(id) - if err != nil || !strings.EqualFold(parsedUUID.String(), id) { + id = strings.ToLower(id) + if err != nil || parsedUUID.String() != id { return nil, false } + cleanPath = "/" + id return &browserDialerAddress{ listenAddr: listenAddr, @@ -256,7 +258,6 @@ func getDialerByAddress(addr string) (*dialerInstance, error) { delete(server.pageRoutes, dialer.pagePath) if len(server.pageRoutes) == 0 { delete(dialerServers, parsed.listenAddr) - server.started = false } mu.Unlock() closeDialerInstance(dialer) From 1d4250e6f0da25fdefbf9a9a746570b335b00875 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:07:23 +0000 Subject: [PATCH 12/44] fix: normalize browser dialer UUID before parsing Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/d0035ff5-3633-402f-890e-e68c267a65c1 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 27ada3f1a712..7d855114635f 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -105,8 +105,8 @@ func parseBrowserDialerAddress(addr string) (*browserDialerAddress, bool) { if len(id) != 36 { return nil, false } - parsedUUID, err := uuid.ParseString(id) id = strings.ToLower(id) + parsedUUID, err := uuid.ParseString(id) if err != nil || parsedUUID.String() != id { return nil, false } From 5afd664c8b7e8f3dec05b06cee49046f4fae9436 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:23:37 +0000 Subject: [PATCH 13/44] refactor: simplify browser dialer and remove added conf tests Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/3aee4c73-7847-433c-905a-2eafe5b1bfe8 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- infra/conf/transport_browser_dialer_test.go | 63 ------ transport/internet/browser_dialer/dialer.go | 181 +++++------------- .../internet/browser_dialer/dialer_test.go | 4 +- 3 files changed, 50 insertions(+), 198 deletions(-) delete mode 100644 infra/conf/transport_browser_dialer_test.go diff --git a/infra/conf/transport_browser_dialer_test.go b/infra/conf/transport_browser_dialer_test.go deleted file mode 100644 index c5e3a38300e0..000000000000 --- a/infra/conf/transport_browser_dialer_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package conf_test - -import ( - "net" - "strings" - "testing" - - . "github.com/xtls/xray-core/infra/conf" -) - -const testBrowserDialerPath = "/123e4567-e89b-12d3-a456-426614174000" - -func TestStreamConfigBuildRejectsBrowserDialerUnsupportedProtocol(t *testing.T) { - network := TransportProtocol("tcp") - config := &StreamConfig{ - Network: &network, - SocketSettings: &SocketConfig{ - BrowserDialer: "127.0.0.1:18080" + testBrowserDialerPath, - }, - } - - _, err := config.Build() - if err == nil || !strings.Contains(err.Error(), "sockopt.browserDialer only supports websocket or splithttp") { - t.Fatalf("expected unsupported protocol error, got: %v", err) - } -} - -func TestStreamConfigBuildRejectsBrowserDialerWithREALITY(t *testing.T) { - network := TransportProtocol("splithttp") - config := &StreamConfig{ - Network: &network, - Security: "reality", - SocketSettings: &SocketConfig{ - BrowserDialer: "127.0.0.1:18081" + testBrowserDialerPath, - }, - } - - _, err := config.Build() - if err == nil || !strings.Contains(err.Error(), "sockopt.browserDialer does not support REALITY") { - t.Fatalf("expected REALITY rejection, got: %v", err) - } -} - -func TestStreamConfigBuildFailsOnBrowserDialerAddressConflict(t *testing.T) { - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("failed to prepare occupied listener: %v", err) - } - defer listener.Close() - - network := TransportProtocol("websocket") - config := &StreamConfig{ - Network: &network, - SocketSettings: &SocketConfig{ - BrowserDialer: listener.Addr().String() + testBrowserDialerPath, - }, - } - - _, err = config.Build() - if err == nil || !strings.Contains(err.Error(), "Failed to start Browser Dialer listener") { - t.Fatalf("expected address conflict error, got: %v", err) - } -} diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 7d855114635f..d39281f986ce 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -47,7 +47,7 @@ var upgrader = &websocket.Upgrader{ } func HasBrowserDialerWithAddress(addr string) bool { - _, ok := parseBrowserDialerAddress(addr) + _, _, ok := parseBrowserDialerAddress(addr) return ok } @@ -56,79 +56,56 @@ type webSocketExtra struct { } type dialerInstance struct { - conns chan *websocket.Conn - pagePath string - page []byte + conns chan *websocket.Conn + page []byte } type dialerServer struct { server *http.Server pageRoutes map[string]*dialerInstance - started bool } -type browserDialerAddress struct { - listenAddr string - path string -} - -func parseBrowserDialerAddress(addr string) (*browserDialerAddress, bool) { +func parseBrowserDialerAddress(addr string) (string, string, bool) { if addr == "" { - return nil, false + return "", "", false } - index := strings.Index(addr, "/") - if index <= 0 { - return nil, false + listenAddr, pathRaw, ok := strings.Cut(addr, "/") + if !ok || listenAddr == "" { + return "", "", false } - listenAddr := addr[:index] - path := strings.TrimSuffix(addr[index:], "/") + path := "/" + strings.TrimSuffix(pathRaw, "/") if path == "" { - return nil, false + return "", "", false } if _, _, err := net.SplitHostPort(listenAddr); err != nil { - return nil, false + return "", "", false } parsedPath, err := url.ParseRequestURI(path) if err != nil || parsedPath.RawQuery != "" || parsedPath.Fragment != "" { - return nil, false + return "", "", false } cleanPath := pathlib.Clean(path) if cleanPath == "." || cleanPath == "/" || cleanPath != path { - return nil, false + return "", "", false } if strings.Count(cleanPath, "/") != 1 { - return nil, false + return "", "", false } id := strings.TrimPrefix(cleanPath, "/") if len(id) != 36 { - return nil, false + return "", "", false } id = strings.ToLower(id) parsedUUID, err := uuid.ParseString(id) if err != nil || parsedUUID.String() != id { - return nil, false + return "", "", false } - cleanPath = "/" + id - - return &browserDialerAddress{ - listenAddr: listenAddr, - path: cleanPath, - }, true + return listenAddr, "/" + id, true } -func newDialerInstance(path string) *dialerInstance { - page := bytes.ReplaceAll(webpage, []byte("dialerPath"), []byte(strings.TrimPrefix(path, "/"))) - dialer := &dialerInstance{ - conns: make(chan *websocket.Conn, 256), - pagePath: path, - page: page, - } - return dialer -} - -func newDialerServer(listenAddr string) *dialerServer { +func newDialerServer(listenAddr string) (*dialerServer, error) { dialer := &dialerServer{ pageRoutes: make(map[string]*dialerInstance), } @@ -170,62 +147,41 @@ func newDialerServer(listenAddr string) *dialerServer { closeConnection(w) }), } - return dialer -} - -func closeConnection(w http.ResponseWriter) { - hijacker, ok := w.(http.Hijacker) - if !ok { - return - } - conn, _, err := hijacker.Hijack() - if err != nil { - return - } - conn.Close() -} - -func startDialerServer(dialer *dialerServer) error { - if dialer == nil || dialer.server == nil { - return nil - } - listener, err := net.Listen("tcp", dialer.server.Addr) + listener, err := net.Listen("tcp", listenAddr) if err != nil { - return err + return nil, err } go func() { if err := dialer.server.Serve(listener); err != nil && !stderrors.Is(err, http.ErrServerClosed) { errors.LogError(context.Background(), "Browser dialer http server unexpected error on ", dialer.server.Addr, ": ", err) } }() - return nil + return dialer, nil } -func closeDialerInstance(d *dialerInstance) { - if d == nil { +func closeConnection(w http.ResponseWriter) { + hijacker, ok := w.(http.Hijacker) + if !ok { return } - for { - select { - case c := <-d.conns: - c.Close() - default: - return - } + conn, _, err := hijacker.Hijack() + if err != nil { + return } + conn.Close() } func getDialerByAddress(addr string) (*dialerInstance, error) { - parsed, ok := parseBrowserDialerAddress(addr) + listenAddr, path, ok := parseBrowserDialerAddress(addr) if !ok { return nil, errors.New("invalid sockopt.browserDialer: ", addr) } - key := parsed.listenAddr + parsed.path - var server *dialerServer - var dialer *dialerInstance + key := listenAddr + path mu.Lock() + defer mu.Unlock() + if sockoptDialers == nil { sockoptDialers = make(map[string]*dialerInstance) } @@ -233,38 +189,24 @@ func getDialerByAddress(addr string) (*dialerInstance, error) { dialerServers = make(map[string]*dialerServer) } if dialer, found := sockoptDialers[key]; found { - mu.Unlock() return dialer, nil } - found := false - server, found = dialerServers[parsed.listenAddr] + server, found := dialerServers[listenAddr] if !found { - server = newDialerServer(parsed.listenAddr) - dialerServers[parsed.listenAddr] = server - } - - dialer = newDialerInstance(parsed.path) - sockoptDialers[key] = dialer - server.pageRoutes[dialer.pagePath] = dialer - startServer := !server.started - server.started = true - mu.Unlock() - - if startServer { - if err := startDialerServer(server); err != nil { - mu.Lock() - delete(sockoptDialers, key) - delete(server.pageRoutes, dialer.pagePath) - if len(server.pageRoutes) == 0 { - delete(dialerServers, parsed.listenAddr) - } - mu.Unlock() - closeDialerInstance(dialer) + server, err := newDialerServer(listenAddr) + if err != nil { return nil, err } + dialerServers[listenAddr] = server } + dialer := &dialerInstance{ + conns: make(chan *websocket.Conn, 256), + page: bytes.ReplaceAll(webpage, []byte("dialerPath"), []byte(strings.TrimPrefix(path, "/"))), + } + sockoptDialers[key] = dialer + server.pageRoutes[path] = dialer return dialer, nil } @@ -276,10 +218,6 @@ func EnsureDialerWithAddress(addr string) error { return err } -func DialWS(uri string, ed []byte) (*websocket.Conn, error) { - return DialWSWithAddress("", uri, ed) -} - func DialWSWithAddress(addr string, uri string, ed []byte) (*websocket.Conn, error) { task := task{ Method: "WS", @@ -330,10 +268,6 @@ func httpExtraFromHeadersAndCookies(headers http.Header, cookies []*http.Cookie) return &extra } -func DialGet(uri string, headers http.Header, cookies []*http.Cookie) (*websocket.Conn, error) { - return DialGetWithAddress("", uri, headers, cookies) -} - func DialGetWithAddress(addr string, uri string, headers http.Header, cookies []*http.Cookie) (*websocket.Conn, error) { task := task{ Method: "GET", @@ -345,15 +279,7 @@ func DialGetWithAddress(addr string, uri string, headers http.Header, cookies [] return dialTaskWithAddress(addr, task) } -func DialPacket(method string, uri string, headers http.Header, cookies []*http.Cookie, payload []byte) error { - return DialPacketWithAddress("", method, uri, headers, cookies, payload) -} - func DialPacketWithAddress(addr string, method string, uri string, headers http.Header, cookies []*http.Cookie, payload []byte) error { - return dialWithBody(addr, method, uri, headers, cookies, payload) -} - -func dialWithBody(addr string, method string, uri string, headers http.Header, cookies []*http.Cookie, payload []byte) error { task := task{ Method: method, URL: uri, @@ -380,23 +306,23 @@ func dialWithBody(addr string, method string, uri string, headers http.Header, c return nil } -func dialTask(task task) (*websocket.Conn, error) { - return dialTaskWithAddress("", task) -} - func dialTaskWithAddress(addr string, task task) (*websocket.Conn, error) { data, err := json.Marshal(task) if err != nil { return nil, err } - conns := connsByAddress(addr) - if conns == nil { + if addr == "" { + return nil, errors.New("browser dialer is not configured; set sockopt.browserDialer") + } + dialer, err := getDialerByAddress(addr) + if err != nil || dialer == nil { if addr != "" { return nil, errors.New("browser dialer is not configured for sockopt.browserDialer: ", addr) } return nil, errors.New("browser dialer is not configured; set sockopt.browserDialer") } + conns := dialer.conns var conn *websocket.Conn for { @@ -427,17 +353,6 @@ func CheckOK(conn *websocket.Conn) error { return nil } -func connsByAddress(addr string) chan *websocket.Conn { - if addr == "" { - return nil - } - dialer, err := getDialerByAddress(addr) - if err != nil || dialer == nil { - return nil - } - return dialer.conns -} - func notifyRemovedEnv() { envAddress := platform.NewEnvFlag(platform.BrowserDialerAddress).GetValue(func() string { return "" }) if envAddress == "" { diff --git a/transport/internet/browser_dialer/dialer_test.go b/transport/internet/browser_dialer/dialer_test.go index 506ee2a2a77b..9384306f704d 100644 --- a/transport/internet/browser_dialer/dialer_test.go +++ b/transport/internet/browser_dialer/dialer_test.go @@ -4,7 +4,7 @@ import "testing" func TestParseBrowserDialerAddressRequireUUIDPath(t *testing.T) { valid := "127.0.0.1:8080/123e4567-e89b-12d3-a456-426614174000" - if _, ok := parseBrowserDialerAddress(valid); !ok { + if _, _, ok := parseBrowserDialerAddress(valid); !ok { t.Fatalf("expected valid browser dialer address: %s", valid) } @@ -15,7 +15,7 @@ func TestParseBrowserDialerAddressRequireUUIDPath(t *testing.T) { "127.0.0.1:8080/123e4567-e89b-12d3-a456-426614174000/extra", } for _, addr := range invalid { - if _, ok := parseBrowserDialerAddress(addr); ok { + if _, _, ok := parseBrowserDialerAddress(addr); ok { t.Fatalf("expected invalid browser dialer address: %s", addr) } } From 4636ca26753c279accd3d6dac15b1b78047c6d75 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:25:25 +0000 Subject: [PATCH 14/44] fix: clean up dialer refactor review issues Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/3aee4c73-7847-433c-905a-2eafe5b1bfe8 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index d39281f986ce..2c9adee20d52 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -194,10 +194,11 @@ func getDialerByAddress(addr string) (*dialerInstance, error) { server, found := dialerServers[listenAddr] if !found { - server, err := newDialerServer(listenAddr) - if err != nil { - return nil, err + newServer, serverErr := newDialerServer(listenAddr) + if serverErr != nil { + return nil, serverErr } + server = newServer dialerServers[listenAddr] = server } @@ -317,10 +318,7 @@ func dialTaskWithAddress(addr string, task task) (*websocket.Conn, error) { } dialer, err := getDialerByAddress(addr) if err != nil || dialer == nil { - if addr != "" { - return nil, errors.New("browser dialer is not configured for sockopt.browserDialer: ", addr) - } - return nil, errors.New("browser dialer is not configured; set sockopt.browserDialer") + return nil, errors.New("browser dialer is not configured for sockopt.browserDialer: ", addr) } conns := dialer.conns From 12ecf47fcc1a97a2e76da989a73bbc3027442467 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:27:13 +0000 Subject: [PATCH 15/44] fix: clean browser dialer path parsing guard Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/3aee4c73-7847-433c-905a-2eafe5b1bfe8 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 2c9adee20d52..7ab7b037ebb8 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -71,14 +71,11 @@ func parseBrowserDialerAddress(addr string) (string, string, bool) { } listenAddr, pathRaw, ok := strings.Cut(addr, "/") - if !ok || listenAddr == "" { + if !ok || listenAddr == "" || pathRaw == "" { return "", "", false } path := "/" + strings.TrimSuffix(pathRaw, "/") - if path == "" { - return "", "", false - } if _, _, err := net.SplitHostPort(listenAddr); err != nil { return "", "", false } From 64f783f2b0ce5086c7d355e3ef3dc31d86f73c52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:39:27 +0000 Subject: [PATCH 16/44] fix: allow same-address browser dialer port reuse across outbounds Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/b21c3fc4-8476-4107-975a-9d921d55ffea Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 10 +++++ .../internet/browser_dialer/dialer_test.go | 42 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 7ab7b037ebb8..9885edbb9557 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -173,6 +173,10 @@ func getDialerByAddress(addr string) (*dialerInstance, error) { if !ok { return nil, errors.New("invalid sockopt.browserDialer: ", addr) } + _, port, err := net.SplitHostPort(listenAddr) + if err != nil { + return nil, errors.New("invalid sockopt.browserDialer: ", addr) + } key := listenAddr + path @@ -191,6 +195,12 @@ func getDialerByAddress(addr string) (*dialerInstance, error) { server, found := dialerServers[listenAddr] if !found { + for existingAddr := range dialerServers { + _, existingPort, splitErr := net.SplitHostPort(existingAddr) + if splitErr == nil && existingPort == port { + return nil, errors.New("sockopt.browserDialer cannot use the same port with a different listen address: ", existingAddr, " and ", listenAddr) + } + } newServer, serverErr := newDialerServer(listenAddr) if serverErr != nil { return nil, serverErr diff --git a/transport/internet/browser_dialer/dialer_test.go b/transport/internet/browser_dialer/dialer_test.go index 9384306f704d..e2bdb66d98db 100644 --- a/transport/internet/browser_dialer/dialer_test.go +++ b/transport/internet/browser_dialer/dialer_test.go @@ -1,6 +1,10 @@ package browser_dialer -import "testing" +import ( + "net" + "strconv" + "testing" +) func TestParseBrowserDialerAddressRequireUUIDPath(t *testing.T) { valid := "127.0.0.1:8080/123e4567-e89b-12d3-a456-426614174000" @@ -20,3 +24,39 @@ func TestParseBrowserDialerAddressRequireUUIDPath(t *testing.T) { } } } + +func TestEnsureDialerWithAddressReusesSameListenAddress(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := listener.Addr().(*net.TCPAddr).Port + listener.Close() + + addr1 := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + "/123e4567-e89b-12d3-a456-426614174000" + addr2 := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + "/123e4567-e89b-12d3-a456-426614174001" + if err := EnsureDialerWithAddress(addr1); err != nil { + t.Fatalf("failed to ensure first browser dialer: %v", err) + } + if err := EnsureDialerWithAddress(addr2); err != nil { + t.Fatalf("failed to reuse browser dialer listener on same address: %v", err) + } +} + +func TestEnsureDialerWithAddressRejectsSamePortDifferentAddress(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := listener.Addr().(*net.TCPAddr).Port + listener.Close() + + addr1 := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + "/123e4567-e89b-12d3-a456-426614174010" + addr2 := net.JoinHostPort("127.0.0.2", strconv.Itoa(port)) + "/123e4567-e89b-12d3-a456-426614174011" + if err := EnsureDialerWithAddress(addr1); err != nil { + t.Fatalf("failed to ensure first browser dialer: %v", err) + } + if err := EnsureDialerWithAddress(addr2); err == nil { + t.Fatal("expected error for same port with different listen address") + } +} From a54c54a55974eb8c4e5293c0d7e4e3b559de8d1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:42:56 +0000 Subject: [PATCH 17/44] fix: enforce same-port different-address browser dialer rejection Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/b21c3fc4-8476-4107-975a-9d921d55ffea Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 2 +- .../internet/browser_dialer/dialer_test.go | 74 ++++++++++++------- 2 files changed, 49 insertions(+), 27 deletions(-) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 9885edbb9557..b127528075f5 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -175,7 +175,7 @@ func getDialerByAddress(addr string) (*dialerInstance, error) { } _, port, err := net.SplitHostPort(listenAddr) if err != nil { - return nil, errors.New("invalid sockopt.browserDialer: ", addr) + return nil, errors.New("invalid sockopt.browserDialer listen address: ", listenAddr) } key := listenAddr + path diff --git a/transport/internet/browser_dialer/dialer_test.go b/transport/internet/browser_dialer/dialer_test.go index e2bdb66d98db..73efa58665c7 100644 --- a/transport/internet/browser_dialer/dialer_test.go +++ b/transport/internet/browser_dialer/dialer_test.go @@ -1,8 +1,8 @@ package browser_dialer import ( - "net" - "strconv" + "net/http" + "strings" "testing" ) @@ -25,38 +25,60 @@ func TestParseBrowserDialerAddressRequireUUIDPath(t *testing.T) { } } -func TestEnsureDialerWithAddressReusesSameListenAddress(t *testing.T) { - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) +func TestGetDialerByAddressReusesExistingServerForSameListenAddress(t *testing.T) { + listenAddr := "127.0.0.1:39000" + server := &dialerServer{ + server: &http.Server{Addr: listenAddr}, + pageRoutes: make(map[string]*dialerInstance), } - port := listener.Addr().(*net.TCPAddr).Port - listener.Close() - addr1 := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + "/123e4567-e89b-12d3-a456-426614174000" - addr2 := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + "/123e4567-e89b-12d3-a456-426614174001" - if err := EnsureDialerWithAddress(addr1); err != nil { - t.Fatalf("failed to ensure first browser dialer: %v", err) + mu.Lock() + oldDialers, oldServers := sockoptDialers, dialerServers + sockoptDialers = make(map[string]*dialerInstance) + dialerServers = map[string]*dialerServer{listenAddr: server} + mu.Unlock() + t.Cleanup(func() { + mu.Lock() + sockoptDialers = oldDialers + dialerServers = oldServers + mu.Unlock() + }) + + if _, err := getDialerByAddress(listenAddr + "/123e4567-e89b-12d3-a456-426614174000"); err != nil { + t.Fatalf("failed to create first dialer: %v", err) + } + if _, err := getDialerByAddress(listenAddr + "/123e4567-e89b-12d3-a456-426614174001"); err != nil { + t.Fatalf("failed to create second dialer on same listener: %v", err) } - if err := EnsureDialerWithAddress(addr2); err != nil { - t.Fatalf("failed to reuse browser dialer listener on same address: %v", err) + if len(dialerServers) != 1 { + t.Fatalf("expected one shared listener, got %d", len(dialerServers)) } } -func TestEnsureDialerWithAddressRejectsSamePortDifferentAddress(t *testing.T) { - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) +func TestGetDialerByAddressRejectsSamePortDifferentAddress(t *testing.T) { + listenAddr := "127.0.0.1:39001" + server := &dialerServer{ + server: &http.Server{Addr: listenAddr}, + pageRoutes: make(map[string]*dialerInstance), } - port := listener.Addr().(*net.TCPAddr).Port - listener.Close() - addr1 := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + "/123e4567-e89b-12d3-a456-426614174010" - addr2 := net.JoinHostPort("127.0.0.2", strconv.Itoa(port)) + "/123e4567-e89b-12d3-a456-426614174011" - if err := EnsureDialerWithAddress(addr1); err != nil { - t.Fatalf("failed to ensure first browser dialer: %v", err) - } - if err := EnsureDialerWithAddress(addr2); err == nil { + mu.Lock() + oldDialers, oldServers := sockoptDialers, dialerServers + sockoptDialers = make(map[string]*dialerInstance) + dialerServers = map[string]*dialerServer{listenAddr: server} + mu.Unlock() + t.Cleanup(func() { + mu.Lock() + sockoptDialers = oldDialers + dialerServers = oldServers + mu.Unlock() + }) + + _, err := getDialerByAddress("127.0.0.2:39001/123e4567-e89b-12d3-a456-426614174011") + if err == nil { t.Fatal("expected error for same port with different listen address") } + if !strings.Contains(err.Error(), "cannot use the same port with a different listen address") { + t.Fatalf("unexpected error: %v", err) + } } From 5906445c68064712ca9d080920cf476ed29c9ef2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:16:42 +0000 Subject: [PATCH 18/44] fix: restrict xhttp browser dialer mode and remove added tests Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/071df77b-69fb-4b1f-a14d-9dab447e1efa Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- infra/conf/transport_internet.go | 16 +++++ .../internet/browser_dialer/dialer_test.go | 64 +------------------ 2 files changed, 17 insertions(+), 63 deletions(-) diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index 071a91706f69..ee05dd33d26b 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -1980,6 +1980,22 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { if strings.EqualFold(c.Security, "reality") { return nil, errors.New("sockopt.browserDialer does not support REALITY") } + if config.ProtocolName == "splithttp" { + splitHTTPSettings := c.SplitHTTPSettings + if c.XHTTPSettings != nil { + splitHTTPSettings = c.XHTTPSettings + } + if splitHTTPSettings != nil { + splitHTTPSettingsCopy := *splitHTTPSettings + hs, err := splitHTTPSettingsCopy.Build() + if err != nil { + return nil, errors.New("Failed to build XHTTP config.").Base(err) + } + if splitHTTPConfig, ok := hs.(*splithttp.Config); ok && splitHTTPConfig.Mode != "auto" && splitHTTPConfig.Mode != "packet-up" { + return nil, errors.New("sockopt.browserDialer only supports XHTTP mode auto or packet-up") + } + } + } } switch strings.ToLower(c.Security) { diff --git a/transport/internet/browser_dialer/dialer_test.go b/transport/internet/browser_dialer/dialer_test.go index 73efa58665c7..9384306f704d 100644 --- a/transport/internet/browser_dialer/dialer_test.go +++ b/transport/internet/browser_dialer/dialer_test.go @@ -1,10 +1,6 @@ package browser_dialer -import ( - "net/http" - "strings" - "testing" -) +import "testing" func TestParseBrowserDialerAddressRequireUUIDPath(t *testing.T) { valid := "127.0.0.1:8080/123e4567-e89b-12d3-a456-426614174000" @@ -24,61 +20,3 @@ func TestParseBrowserDialerAddressRequireUUIDPath(t *testing.T) { } } } - -func TestGetDialerByAddressReusesExistingServerForSameListenAddress(t *testing.T) { - listenAddr := "127.0.0.1:39000" - server := &dialerServer{ - server: &http.Server{Addr: listenAddr}, - pageRoutes: make(map[string]*dialerInstance), - } - - mu.Lock() - oldDialers, oldServers := sockoptDialers, dialerServers - sockoptDialers = make(map[string]*dialerInstance) - dialerServers = map[string]*dialerServer{listenAddr: server} - mu.Unlock() - t.Cleanup(func() { - mu.Lock() - sockoptDialers = oldDialers - dialerServers = oldServers - mu.Unlock() - }) - - if _, err := getDialerByAddress(listenAddr + "/123e4567-e89b-12d3-a456-426614174000"); err != nil { - t.Fatalf("failed to create first dialer: %v", err) - } - if _, err := getDialerByAddress(listenAddr + "/123e4567-e89b-12d3-a456-426614174001"); err != nil { - t.Fatalf("failed to create second dialer on same listener: %v", err) - } - if len(dialerServers) != 1 { - t.Fatalf("expected one shared listener, got %d", len(dialerServers)) - } -} - -func TestGetDialerByAddressRejectsSamePortDifferentAddress(t *testing.T) { - listenAddr := "127.0.0.1:39001" - server := &dialerServer{ - server: &http.Server{Addr: listenAddr}, - pageRoutes: make(map[string]*dialerInstance), - } - - mu.Lock() - oldDialers, oldServers := sockoptDialers, dialerServers - sockoptDialers = make(map[string]*dialerInstance) - dialerServers = map[string]*dialerServer{listenAddr: server} - mu.Unlock() - t.Cleanup(func() { - mu.Lock() - sockoptDialers = oldDialers - dialerServers = oldServers - mu.Unlock() - }) - - _, err := getDialerByAddress("127.0.0.2:39001/123e4567-e89b-12d3-a456-426614174011") - if err == nil { - t.Fatal("expected error for same port with different listen address") - } - if !strings.Contains(err.Error(), "cannot use the same port with a different listen address") { - t.Fatalf("unexpected error: %v", err) - } -} From 181193581bfb41bad2544cf89b5ed8010304fa8d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:18:37 +0000 Subject: [PATCH 19/44] chore: improve xhttp browser dialer validation errors Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/071df77b-69fb-4b1f-a14d-9dab447e1efa Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- infra/conf/transport_internet.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index ee05dd33d26b..6410ec53f6f0 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -1989,10 +1989,10 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { splitHTTPSettingsCopy := *splitHTTPSettings hs, err := splitHTTPSettingsCopy.Build() if err != nil { - return nil, errors.New("Failed to build XHTTP config.").Base(err) + return nil, errors.New("Failed to build XHTTP config for browserDialer validation.").Base(err) } if splitHTTPConfig, ok := hs.(*splithttp.Config); ok && splitHTTPConfig.Mode != "auto" && splitHTTPConfig.Mode != "packet-up" { - return nil, errors.New("sockopt.browserDialer only supports XHTTP mode auto or packet-up") + return nil, errors.New("sockopt.browserDialer only supports XHTTP mode auto or packet-up, got: ", splitHTTPConfig.Mode) } } } From 97ad6cef43e88dc53a343161a24233a842add386 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:20:23 +0000 Subject: [PATCH 20/44] chore: polish xhttp browser dialer mode validation messages Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/071df77b-69fb-4b1f-a14d-9dab447e1efa Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- infra/conf/transport_internet.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index 6410ec53f6f0..e1387de8851d 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -1989,10 +1989,10 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { splitHTTPSettingsCopy := *splitHTTPSettings hs, err := splitHTTPSettingsCopy.Build() if err != nil { - return nil, errors.New("Failed to build XHTTP config for browserDialer validation.").Base(err) + return nil, errors.New("failed to build XHTTP config for browserDialer validation.").Base(err) } if splitHTTPConfig, ok := hs.(*splithttp.Config); ok && splitHTTPConfig.Mode != "auto" && splitHTTPConfig.Mode != "packet-up" { - return nil, errors.New("sockopt.browserDialer only supports XHTTP mode auto or packet-up, got: ", splitHTTPConfig.Mode) + return nil, errors.New("sockopt.browserDialer only supports XHTTP modes \"auto\" or \"packet-up\", got: \"", splitHTTPConfig.Mode, "\"") } } } From ca3cd5fb88cff5059ea37afd6ed874e797c840c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:54:10 +0000 Subject: [PATCH 21/44] feat: add root browserDialers tags for dialerProxy integration Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/808be5b1-7ed2-4309-87f1-18a808d6aba4 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- infra/conf/xray.go | 24 ++++++++++++++++ transport/internet/browser_dialer/dialer.go | 32 +++++++++++++++++++++ transport/internet/dialer.go | 4 +++ transport/internet/splithttp/dialer.go | 27 ++++++++++++++++- transport/internet/websocket/dialer.go | 5 ++++ 5 files changed, 91 insertions(+), 1 deletion(-) diff --git a/infra/conf/xray.go b/infra/conf/xray.go index d01dac2bf683..4d8443e11ac5 100644 --- a/infra/conf/xray.go +++ b/infra/conf/xray.go @@ -15,6 +15,7 @@ import ( "github.com/xtls/xray-core/common/serial" core "github.com/xtls/xray-core/core" "github.com/xtls/xray-core/transport/internet" + "github.com/xtls/xray-core/transport/internet/browser_dialer" ) var ( @@ -362,6 +363,12 @@ type Config struct { BurstObservatory *BurstObservatoryConfig `json:"burstObservatory"` Version *VersionConfig `json:"version"` Geodata *GeodataConfig `json:"geodata"` + BrowserDialers []BrowserDialerConfig `json:"browserDialers"` +} + +type BrowserDialerConfig struct { + Tag string `json:"tag"` + URL string `json:"url"` } func (c *Config) findInboundTag(tag string) int { @@ -437,6 +444,9 @@ func (c *Config) Override(o *Config, fn string) { if o.Geodata != nil { c.Geodata = o.Geodata } + if o.BrowserDialers != nil { + c.BrowserDialers = o.BrowserDialers + } // update the Inbound in slice if the only one in override config has same tag if len(o.InboundConfigs) > 0 { @@ -605,6 +615,20 @@ func (c *Config) Build() (*core.Config, error) { return nil, errors.PrintRemovedFeatureError("Global transport config", "streamSettings in inbounds and outbounds") } + browserDialerTags := make(map[string]string, len(c.BrowserDialers)) + for _, browserDialer := range c.BrowserDialers { + if browserDialer.Tag == "" { + return nil, errors.New("browserDialers tag cannot be empty") + } + if _, found := browserDialerTags[browserDialer.Tag]; found { + return nil, errors.New("duplicate browserDialers tag: ", browserDialer.Tag) + } + browserDialerTags[browserDialer.Tag] = browserDialer.URL + } + if err := browser_dialer.ConfigureDialerTags(browserDialerTags); err != nil { + return nil, errors.New("failed to configure browserDialers").Base(err) + } + for _, rawInboundConfig := range inbounds { ic, err := rawInboundConfig.Build() if err != nil { diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index b127528075f5..0882d6b2cb24 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -33,6 +33,7 @@ type task struct { var sockoptDialers map[string]*dialerInstance var dialerServers map[string]*dialerServer +var dialerTags map[string]string var mu sync.RWMutex const browserDialerSubprotocol = "browser-dialer" @@ -51,6 +52,37 @@ func HasBrowserDialerWithAddress(addr string) bool { return ok } +func GetAddressByTag(tag string) (string, bool) { + if tag == "" { + return "", false + } + mu.RLock() + defer mu.RUnlock() + addr, ok := dialerTags[tag] + return addr, ok +} + +func ConfigureDialerTags(tags map[string]string) error { + next := make(map[string]string, len(tags)) + for tag, addr := range tags { + if tag == "" { + return errors.New("browserDialers tag cannot be empty") + } + if addr == "" { + return errors.New("browserDialers url cannot be empty for tag: ", tag) + } + if err := EnsureDialerWithAddress(addr); err != nil { + return errors.New("invalid browserDialers entry for tag ", tag).Base(err) + } + next[tag] = addr + } + + mu.Lock() + dialerTags = next + mu.Unlock() + return nil +} + type webSocketExtra struct { Protocol string `json:"protocol,omitempty"` } diff --git a/transport/internet/dialer.go b/transport/internet/dialer.go index 9342f26f5ff7..6c4921f067b6 100644 --- a/transport/internet/dialer.go +++ b/transport/internet/dialer.go @@ -14,6 +14,7 @@ import ( "github.com/xtls/xray-core/features/dns" "github.com/xtls/xray-core/features/outbound" "github.com/xtls/xray-core/transport" + "github.com/xtls/xray-core/transport/internet/browser_dialer" "github.com/xtls/xray-core/transport/internet/stat" "github.com/xtls/xray-core/transport/pipe" ) @@ -269,6 +270,9 @@ func DialSystem(ctx context.Context, dest net.Destination, sockopt *SocketConfig } if len(sockopt.DialerProxy) > 0 { + if _, ok := browser_dialer.GetAddressByTag(sockopt.DialerProxy); ok { + return nil, errors.New("dialerProxy tag ", sockopt.DialerProxy, " maps to browserDialers and only supports websocket or splithttp").AtError() + } if obm == nil { return nil, errors.New("there is no outbound manager for dialerProxy").AtError() } diff --git a/transport/internet/splithttp/dialer.go b/transport/internet/splithttp/dialer.go index cda9cadb4f28..96a572590a3c 100644 --- a/transport/internet/splithttp/dialer.go +++ b/transport/internet/splithttp/dialer.go @@ -40,6 +40,20 @@ type dialerConf struct { *internet.MemoryStreamConfig } +type errorDialerClient struct { + err error +} + +func (c *errorDialerClient) IsClosed() bool { return false } + +func (c *errorDialerClient) OpenStream(context.Context, string, string, io.Reader, bool) (io.ReadCloser, net.Addr, net.Addr, error) { + return nil, nil, nil, c.err +} + +func (c *errorDialerClient) PostPacket(context.Context, string, string, string, buf.MultiBuffer) error { + return c.err +} + var ( globalDialerMap map[dialerConf]*XmuxManager globalDialerAccess sync.Mutex @@ -50,11 +64,22 @@ func getHTTPClient(ctx context.Context, dest net.Destination, streamSettings *in browserDialer := "" if streamSettings.SocketSettings != nil { browserDialer = streamSettings.SocketSettings.BrowserDialer + if browserDialer == "" { + if taggedDialer, ok := browser_dialer.GetAddressByTag(streamSettings.SocketSettings.DialerProxy); ok { + browserDialer = taggedDialer + } + } } if browser_dialer.HasBrowserDialerWithAddress(browserDialer) && realityConfig == nil { + transportConfig := streamSettings.ProtocolSettings.(*Config) + if transportConfig.Mode != "auto" && transportConfig.Mode != "packet-up" { + return &errorDialerClient{ + err: errors.New("dialerProxy/browserDialer with XHTTP only supports modes \"auto\" or \"packet-up\", got: \"", transportConfig.Mode, "\""), + }, nil + } return &BrowserDialerClient{ - transportConfig: streamSettings.ProtocolSettings.(*Config), + transportConfig: transportConfig, browserDialer: browserDialer, }, nil } diff --git a/transport/internet/websocket/dialer.go b/transport/internet/websocket/dialer.go index 99fbc877d708..6c7019cf2cea 100644 --- a/transport/internet/websocket/dialer.go +++ b/transport/internet/websocket/dialer.go @@ -120,6 +120,11 @@ func dialWebSocket(ctx context.Context, dest net.Destination, streamSettings *in browserDialer := "" if streamSettings.SocketSettings != nil { browserDialer = streamSettings.SocketSettings.BrowserDialer + if browserDialer == "" { + if taggedDialer, ok := browser_dialer.GetAddressByTag(streamSettings.SocketSettings.DialerProxy); ok { + browserDialer = taggedDialer + } + } } if browser_dialer.HasBrowserDialerWithAddress(browserDialer) { conn, err := browser_dialer.DialWSWithAddress(browserDialer, uri, ed) From 1d137004811839ea3010f205e54f19c310d85edf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:56:03 +0000 Subject: [PATCH 22/44] chore: polish browserDialers xhttp runtime error client behavior Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/808be5b1-7ed2-4309-87f1-18a808d6aba4 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/splithttp/dialer.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/transport/internet/splithttp/dialer.go b/transport/internet/splithttp/dialer.go index 96a572590a3c..513f182c6d62 100644 --- a/transport/internet/splithttp/dialer.go +++ b/transport/internet/splithttp/dialer.go @@ -44,7 +44,7 @@ type errorDialerClient struct { err error } -func (c *errorDialerClient) IsClosed() bool { return false } +func (c *errorDialerClient) IsClosed() bool { return true } func (c *errorDialerClient) OpenStream(context.Context, string, string, io.Reader, bool) (io.ReadCloser, net.Addr, net.Addr, error) { return nil, nil, nil, c.err @@ -75,7 +75,7 @@ func getHTTPClient(ctx context.Context, dest net.Destination, streamSettings *in transportConfig := streamSettings.ProtocolSettings.(*Config) if transportConfig.Mode != "auto" && transportConfig.Mode != "packet-up" { return &errorDialerClient{ - err: errors.New("dialerProxy/browserDialer with XHTTP only supports modes \"auto\" or \"packet-up\", got: \"", transportConfig.Mode, "\""), + err: errors.New("browserDialer with XHTTP only supports modes \"auto\" or \"packet-up\", got: \"", transportConfig.Mode, "\""), }, nil } return &BrowserDialerClient{ From 61c39a2834399fd2b27ab7830c62ecb9e4195357 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:57:52 +0000 Subject: [PATCH 23/44] chore: align browserDialers runtime error wording Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/808be5b1-7ed2-4309-87f1-18a808d6aba4 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/dialer.go | 2 +- transport/internet/splithttp/dialer.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/transport/internet/dialer.go b/transport/internet/dialer.go index 6c4921f067b6..993d736c8e33 100644 --- a/transport/internet/dialer.go +++ b/transport/internet/dialer.go @@ -271,7 +271,7 @@ func DialSystem(ctx context.Context, dest net.Destination, sockopt *SocketConfig if len(sockopt.DialerProxy) > 0 { if _, ok := browser_dialer.GetAddressByTag(sockopt.DialerProxy); ok { - return nil, errors.New("dialerProxy tag ", sockopt.DialerProxy, " maps to browserDialers and only supports websocket or splithttp").AtError() + return nil, errors.New("dialerProxy tag ", sockopt.DialerProxy, " maps to browserDialers and only supports WebSocket or splithttp").AtError() } if obm == nil { return nil, errors.New("there is no outbound manager for dialerProxy").AtError() diff --git a/transport/internet/splithttp/dialer.go b/transport/internet/splithttp/dialer.go index 513f182c6d62..abbcce31261a 100644 --- a/transport/internet/splithttp/dialer.go +++ b/transport/internet/splithttp/dialer.go @@ -75,7 +75,7 @@ func getHTTPClient(ctx context.Context, dest net.Destination, streamSettings *in transportConfig := streamSettings.ProtocolSettings.(*Config) if transportConfig.Mode != "auto" && transportConfig.Mode != "packet-up" { return &errorDialerClient{ - err: errors.New("browserDialer with XHTTP only supports modes \"auto\" or \"packet-up\", got: \"", transportConfig.Mode, "\""), + err: errors.New("browserDialer with splithttp only supports modes \"auto\" or \"packet-up\", got: \"", transportConfig.Mode, "\""), }, nil } return &BrowserDialerClient{ From 1cc7349529779596fabf6bbe0cc6887cade50c31 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:23:29 +0000 Subject: [PATCH 24/44] refactor: enforce browserDialers-only usage via dialerProxy tags Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/92209153-115a-4303-8c4e-5825c971881b Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- infra/conf/transport_internet.go | 47 ++++++++------- infra/conf/xray.go | 3 + transport/internet/browser_dialer/dialer.go | 63 ++++++++++++++------- transport/internet/splithttp/dialer.go | 7 +-- transport/internet/websocket/dialer.go | 7 +-- 5 files changed, 77 insertions(+), 50 deletions(-) diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index e1387de8851d..b56a0aa9d351 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -1080,6 +1080,10 @@ type SocketConfig struct { // Build implements Buildable. func (c *SocketConfig) Build() (*internet.SocketConfig, error) { + if c.BrowserDialer != "" { + return nil, errors.PrintRemovedFeatureError("sockopt.browserDialer", "root browserDialers + sockopt.dialerProxy") + } + tfo := int32(0) // don't invoke setsockopt() for TFO if c.TFO != nil { switch v := c.TFO.(type) { @@ -1196,7 +1200,6 @@ func (c *SocketConfig) Build() (*internet.SocketConfig, error) { AddressPortStrategy: addressPortStrategy, HappyEyeballs: happyEyeballs, TrustedXForwardedFor: c.TrustedXForwardedFor, - BrowserDialer: c.BrowserDialer, }, nil } @@ -1974,25 +1977,30 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { config.ProtocolName = protocol } if c.SocketSettings != nil && c.SocketSettings.BrowserDialer != "" { - if config.ProtocolName != "websocket" && config.ProtocolName != "splithttp" { - return nil, errors.New("sockopt.browserDialer only supports websocket or splithttp") - } - if strings.EqualFold(c.Security, "reality") { - return nil, errors.New("sockopt.browserDialer does not support REALITY") - } - if config.ProtocolName == "splithttp" { - splitHTTPSettings := c.SplitHTTPSettings - if c.XHTTPSettings != nil { - splitHTTPSettings = c.XHTTPSettings + return nil, errors.PrintRemovedFeatureError("sockopt.browserDialer", "root browserDialers + sockopt.dialerProxy") + } + if c.SocketSettings != nil && c.SocketSettings.DialerProxy != "" { + if _, ok := browser_dialer.GetAddressByTag(c.SocketSettings.DialerProxy); ok { + if config.ProtocolName != "websocket" && config.ProtocolName != "splithttp" { + return nil, errors.New("dialerProxy tag ", c.SocketSettings.DialerProxy, " maps to browserDialers and only supports websocket or splithttp") + } + if strings.EqualFold(c.Security, "reality") { + return nil, errors.New("dialerProxy tag ", c.SocketSettings.DialerProxy, " maps to browserDialers and does not support REALITY") } - if splitHTTPSettings != nil { - splitHTTPSettingsCopy := *splitHTTPSettings - hs, err := splitHTTPSettingsCopy.Build() - if err != nil { - return nil, errors.New("failed to build XHTTP config for browserDialer validation.").Base(err) + if config.ProtocolName == "splithttp" { + splitHTTPSettings := c.SplitHTTPSettings + if c.XHTTPSettings != nil { + splitHTTPSettings = c.XHTTPSettings } - if splitHTTPConfig, ok := hs.(*splithttp.Config); ok && splitHTTPConfig.Mode != "auto" && splitHTTPConfig.Mode != "packet-up" { - return nil, errors.New("sockopt.browserDialer only supports XHTTP modes \"auto\" or \"packet-up\", got: \"", splitHTTPConfig.Mode, "\"") + if splitHTTPSettings != nil { + splitHTTPSettingsCopy := *splitHTTPSettings + hs, err := splitHTTPSettingsCopy.Build() + if err != nil { + return nil, errors.New("failed to build XHTTP config for browserDialers validation.").Base(err) + } + if splitHTTPConfig, ok := hs.(*splithttp.Config); ok && splitHTTPConfig.Mode != "auto" && splitHTTPConfig.Mode != "packet-up" { + return nil, errors.New("dialerProxy tag ", c.SocketSettings.DialerProxy, " maps to browserDialers and only supports XHTTP modes \"auto\" or \"packet-up\", got: \"", splitHTTPConfig.Mode, "\"") + } } } } @@ -2113,9 +2121,6 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { if err != nil { return nil, errors.New("Failed to build sockopt.").Base(err) } - if err := browser_dialer.EnsureDialerWithAddress(ss.BrowserDialer); err != nil { - return nil, errors.New("Failed to start Browser Dialer listener.").Base(err) - } config.SocketSettings = ss } diff --git a/infra/conf/xray.go b/infra/conf/xray.go index 4d8443e11ac5..938bdd76a42b 100644 --- a/infra/conf/xray.go +++ b/infra/conf/xray.go @@ -614,6 +614,9 @@ func (c *Config) Build() (*core.Config, error) { if len(c.Transport) > 0 { return nil, errors.PrintRemovedFeatureError("Global transport config", "streamSettings in inbounds and outbounds") } + if err := browser_dialer.CheckLegacyEnv(); err != nil { + return nil, err + } browserDialerTags := make(map[string]string, len(c.BrowserDialers)) for _, browserDialer := range c.BrowserDialers { diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 0882d6b2cb24..644d0c4db752 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -62,8 +62,20 @@ func GetAddressByTag(tag string) (string, bool) { return addr, ok } +func CheckLegacyEnv() error { + envAddress := platform.NewEnvFlag(platform.BrowserDialerAddress).GetValue(func() string { return "" }) + if envAddress == "" { + return nil + } + return errors.PrintRemovedFeatureError("env "+platform.BrowserDialerAddress, "root browserDialers + sockopt.dialerProxy") +} + func ConfigureDialerTags(tags map[string]string) error { + if err := CheckLegacyEnv(); err != nil { + return err + } next := make(map[string]string, len(tags)) + listenAddrByPort := make(map[string]string, len(tags)) for tag, addr := range tags { if tag == "" { return errors.New("browserDialers tag cannot be empty") @@ -71,11 +83,36 @@ func ConfigureDialerTags(tags map[string]string) error { if addr == "" { return errors.New("browserDialers url cannot be empty for tag: ", tag) } - if err := EnsureDialerWithAddress(addr); err != nil { - return errors.New("invalid browserDialers entry for tag ", tag).Base(err) + listenAddr, _, ok := parseBrowserDialerAddress(addr) + if !ok { + return errors.New("invalid browserDialers entry for tag ", tag, ": ", addr) + } + _, port, err := net.SplitHostPort(listenAddr) + if err != nil { + return errors.New("invalid browserDialers listen address for tag ", tag, ": ", listenAddr) + } + if existingAddr, found := listenAddrByPort[port]; found && existingAddr != listenAddr { + return errors.New("browserDialers cannot use the same port with a different listen address: ", existingAddr, " and ", listenAddr) } + listenAddrByPort[port] = listenAddr next[tag] = addr } + mu.RLock() + defer mu.RUnlock() + for existingAddr := range dialerServers { + _, existingPort, splitErr := net.SplitHostPort(existingAddr) + if splitErr != nil { + continue + } + if newAddr, found := listenAddrByPort[existingPort]; found && newAddr != existingAddr { + return errors.New("browserDialers cannot use the same port with a different listen address: ", existingAddr, " and ", newAddr) + } + } + for tag, addr := range next { + if err := EnsureDialerWithAddress(addr); err != nil { + return errors.New("failed to initialize browserDialers listener for tag ", tag).Base(err) + } + } mu.Lock() dialerTags = next @@ -203,11 +240,11 @@ func closeConnection(w http.ResponseWriter) { func getDialerByAddress(addr string) (*dialerInstance, error) { listenAddr, path, ok := parseBrowserDialerAddress(addr) if !ok { - return nil, errors.New("invalid sockopt.browserDialer: ", addr) + return nil, errors.New("invalid browserDialers url: ", addr) } _, port, err := net.SplitHostPort(listenAddr) if err != nil { - return nil, errors.New("invalid sockopt.browserDialer listen address: ", listenAddr) + return nil, errors.New("invalid browserDialers listen address: ", listenAddr) } key := listenAddr + path @@ -230,7 +267,7 @@ func getDialerByAddress(addr string) (*dialerInstance, error) { for existingAddr := range dialerServers { _, existingPort, splitErr := net.SplitHostPort(existingAddr) if splitErr == nil && existingPort == port { - return nil, errors.New("sockopt.browserDialer cannot use the same port with a different listen address: ", existingAddr, " and ", listenAddr) + return nil, errors.New("browserDialers cannot use the same port with a different listen address: ", existingAddr, " and ", listenAddr) } } newServer, serverErr := newDialerServer(listenAddr) @@ -353,11 +390,11 @@ func dialTaskWithAddress(addr string, task task) (*websocket.Conn, error) { } if addr == "" { - return nil, errors.New("browser dialer is not configured; set sockopt.browserDialer") + return nil, errors.New("browser dialer is not configured; set root browserDialers and use sockopt.dialerProxy tag") } dialer, err := getDialerByAddress(addr) if err != nil || dialer == nil { - return nil, errors.New("browser dialer is not configured for sockopt.browserDialer: ", addr) + return nil, errors.New("browser dialer is not configured for browserDialers url: ", addr) } conns := dialer.conns @@ -389,15 +426,3 @@ func CheckOK(conn *websocket.Conn) error { return nil } - -func notifyRemovedEnv() { - envAddress := platform.NewEnvFlag(platform.BrowserDialerAddress).GetValue(func() string { return "" }) - if envAddress == "" { - return - } - errors.LogWarning(context.Background(), errors.PrintRemovedFeatureError("env "+platform.BrowserDialerAddress, "sockopt.browserDialer")) -} - -func init() { - notifyRemovedEnv() -} diff --git a/transport/internet/splithttp/dialer.go b/transport/internet/splithttp/dialer.go index abbcce31261a..425ebbabab74 100644 --- a/transport/internet/splithttp/dialer.go +++ b/transport/internet/splithttp/dialer.go @@ -63,11 +63,8 @@ func getHTTPClient(ctx context.Context, dest net.Destination, streamSettings *in realityConfig := reality.ConfigFromStreamSettings(streamSettings) browserDialer := "" if streamSettings.SocketSettings != nil { - browserDialer = streamSettings.SocketSettings.BrowserDialer - if browserDialer == "" { - if taggedDialer, ok := browser_dialer.GetAddressByTag(streamSettings.SocketSettings.DialerProxy); ok { - browserDialer = taggedDialer - } + if taggedDialer, ok := browser_dialer.GetAddressByTag(streamSettings.SocketSettings.DialerProxy); ok { + browserDialer = taggedDialer } } diff --git a/transport/internet/websocket/dialer.go b/transport/internet/websocket/dialer.go index 6c7019cf2cea..f7bc496822c0 100644 --- a/transport/internet/websocket/dialer.go +++ b/transport/internet/websocket/dialer.go @@ -119,11 +119,8 @@ func dialWebSocket(ctx context.Context, dest net.Destination, streamSettings *in browserDialer := "" if streamSettings.SocketSettings != nil { - browserDialer = streamSettings.SocketSettings.BrowserDialer - if browserDialer == "" { - if taggedDialer, ok := browser_dialer.GetAddressByTag(streamSettings.SocketSettings.DialerProxy); ok { - browserDialer = taggedDialer - } + if taggedDialer, ok := browser_dialer.GetAddressByTag(streamSettings.SocketSettings.DialerProxy); ok { + browserDialer = taggedDialer } } if browser_dialer.HasBrowserDialerWithAddress(browserDialer) { From 266ae17654a636f4cc5b093f56905316b95d69da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:38:07 +0000 Subject: [PATCH 25/44] refactor: simplify browser dialer static state and remove sockopt browserDialer parsing Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/4875f50c-9a90-4d34-afbe-2e629296faa0 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- infra/conf/transport_internet.go | 8 -- transport/internet/browser_dialer/dialer.go | 102 +++++++++++--------- transport/internet/splithttp/dialer.go | 2 +- transport/internet/websocket/dialer.go | 2 +- 4 files changed, 61 insertions(+), 53 deletions(-) diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index b56a0aa9d351..f2e548080962 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -1075,15 +1075,10 @@ type SocketConfig struct { AddressPortStrategy string `json:"addressPortStrategy"` HappyEyeballsSettings *HappyEyeballsConfig `json:"happyEyeballs"` TrustedXForwardedFor []string `json:"trustedXForwardedFor"` - BrowserDialer string `json:"browserDialer"` } // Build implements Buildable. func (c *SocketConfig) Build() (*internet.SocketConfig, error) { - if c.BrowserDialer != "" { - return nil, errors.PrintRemovedFeatureError("sockopt.browserDialer", "root browserDialers + sockopt.dialerProxy") - } - tfo := int32(0) // don't invoke setsockopt() for TFO if c.TFO != nil { switch v := c.TFO.(type) { @@ -1976,9 +1971,6 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { } config.ProtocolName = protocol } - if c.SocketSettings != nil && c.SocketSettings.BrowserDialer != "" { - return nil, errors.PrintRemovedFeatureError("sockopt.browserDialer", "root browserDialers + sockopt.dialerProxy") - } if c.SocketSettings != nil && c.SocketSettings.DialerProxy != "" { if _, ok := browser_dialer.GetAddressByTag(c.SocketSettings.DialerProxy); ok { if config.ProtocolName != "websocket" && config.ProtocolName != "splithttp" { diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 644d0c4db752..ac3a9887401d 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -13,6 +13,7 @@ import ( pathlib "path" "strings" "sync" + "sync/atomic" "time" "github.com/gorilla/websocket" @@ -31,10 +32,11 @@ type task struct { StreamResponse bool `json:"streamResponse"` } -var sockoptDialers map[string]*dialerInstance -var dialerServers map[string]*dialerServer -var dialerTags map[string]string -var mu sync.RWMutex +var dialersByAddress = map[string]*dialerInstance{} +var serversByListenAddr = map[string]*dialerServer{} +var addressByTag atomic.Value +var initMu sync.Mutex +var initialized bool const browserDialerSubprotocol = "browser-dialer" @@ -47,18 +49,12 @@ var upgrader = &websocket.Upgrader{ }, } -func HasBrowserDialerWithAddress(addr string) bool { - _, _, ok := parseBrowserDialerAddress(addr) - return ok -} - func GetAddressByTag(tag string) (string, bool) { if tag == "" { return "", false } - mu.RLock() - defer mu.RUnlock() - addr, ok := dialerTags[tag] + tags, _ := addressByTag.Load().(map[string]string) + addr, ok := tags[tag] return addr, ok } @@ -71,6 +67,13 @@ func CheckLegacyEnv() error { } func ConfigureDialerTags(tags map[string]string) error { + initMu.Lock() + defer initMu.Unlock() + + if initialized { + return errors.New("browserDialers does not support dynamic add/remove; restart is required after changing configuration") + } + if err := CheckLegacyEnv(); err != nil { return err } @@ -97,9 +100,7 @@ func ConfigureDialerTags(tags map[string]string) error { listenAddrByPort[port] = listenAddr next[tag] = addr } - mu.RLock() - defer mu.RUnlock() - for existingAddr := range dialerServers { + for existingAddr := range serversByListenAddr { _, existingPort, splitErr := net.SplitHostPort(existingAddr) if splitErr != nil { continue @@ -113,10 +114,13 @@ func ConfigureDialerTags(tags map[string]string) error { return errors.New("failed to initialize browserDialers listener for tag ", tag).Base(err) } } - - mu.Lock() - dialerTags = next - mu.Unlock() + for listenAddr, server := range serversByListenAddr { + if err := server.start(); err != nil { + return errors.New("failed to start browserDialers listener on ", listenAddr).Base(err) + } + } + addressByTag.Store(next) + initialized = true return nil } @@ -132,6 +136,7 @@ type dialerInstance struct { type dialerServer struct { server *http.Server pageRoutes map[string]*dialerInstance + started bool } func parseBrowserDialerAddress(addr string) (string, string, bool) { @@ -178,9 +183,7 @@ func newDialerServer(listenAddr string) (*dialerServer, error) { dialer.server = &http.Server{ Addr: listenAddr, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mu.RLock() pageDialer := dialer.pageRoutes[r.URL.Path] - mu.RUnlock() if pageDialer != nil && websocket.IsWebSocketUpgrade(r) { ok := false @@ -213,16 +216,24 @@ func newDialerServer(listenAddr string) (*dialerServer, error) { closeConnection(w) }), } - listener, err := net.Listen("tcp", listenAddr) + return dialer, nil +} + +func (d *dialerServer) start() error { + if d.started { + return nil + } + listener, err := net.Listen("tcp", d.server.Addr) if err != nil { - return nil, err + return err } + d.started = true go func() { - if err := dialer.server.Serve(listener); err != nil && !stderrors.Is(err, http.ErrServerClosed) { - errors.LogError(context.Background(), "Browser dialer http server unexpected error on ", dialer.server.Addr, ": ", err) + if err := d.server.Serve(listener); err != nil && !stderrors.Is(err, http.ErrServerClosed) { + errors.LogError(context.Background(), "Browser dialer http server unexpected error on ", d.server.Addr, ": ", err) } }() - return dialer, nil + return nil } func closeConnection(w http.ResponseWriter) { @@ -238,6 +249,18 @@ func closeConnection(w http.ResponseWriter) { } func getDialerByAddress(addr string) (*dialerInstance, error) { + listenAddr, path, ok := parseBrowserDialerAddress(addr) + if !ok { + return nil, errors.New("invalid browserDialers url: ", addr) + } + key := listenAddr + path + if dialer, found := dialersByAddress[key]; found { + return dialer, nil + } + return nil, errors.New("browser dialer is not configured for browserDialers url: ", addr) +} + +func ensureDialerWithAddress(addr string) (*dialerInstance, error) { listenAddr, path, ok := parseBrowserDialerAddress(addr) if !ok { return nil, errors.New("invalid browserDialers url: ", addr) @@ -248,23 +271,13 @@ func getDialerByAddress(addr string) (*dialerInstance, error) { } key := listenAddr + path - - mu.Lock() - defer mu.Unlock() - - if sockoptDialers == nil { - sockoptDialers = make(map[string]*dialerInstance) - } - if dialerServers == nil { - dialerServers = make(map[string]*dialerServer) - } - if dialer, found := sockoptDialers[key]; found { + if dialer, found := dialersByAddress[key]; found { return dialer, nil } - server, found := dialerServers[listenAddr] + server, found := serversByListenAddr[listenAddr] if !found { - for existingAddr := range dialerServers { + for existingAddr := range serversByListenAddr { _, existingPort, splitErr := net.SplitHostPort(existingAddr) if splitErr == nil && existingPort == port { return nil, errors.New("browserDialers cannot use the same port with a different listen address: ", existingAddr, " and ", listenAddr) @@ -275,14 +288,14 @@ func getDialerByAddress(addr string) (*dialerInstance, error) { return nil, serverErr } server = newServer - dialerServers[listenAddr] = server + serversByListenAddr[listenAddr] = server } dialer := &dialerInstance{ conns: make(chan *websocket.Conn, 256), page: bytes.ReplaceAll(webpage, []byte("dialerPath"), []byte(strings.TrimPrefix(path, "/"))), } - sockoptDialers[key] = dialer + dialersByAddress[key] = dialer server.pageRoutes[path] = dialer return dialer, nil } @@ -291,7 +304,7 @@ func EnsureDialerWithAddress(addr string) error { if addr == "" { return nil } - _, err := getDialerByAddress(addr) + _, err := ensureDialerWithAddress(addr) return err } @@ -393,7 +406,10 @@ func dialTaskWithAddress(addr string, task task) (*websocket.Conn, error) { return nil, errors.New("browser dialer is not configured; set root browserDialers and use sockopt.dialerProxy tag") } dialer, err := getDialerByAddress(addr) - if err != nil || dialer == nil { + if err != nil { + return nil, err + } + if dialer == nil { return nil, errors.New("browser dialer is not configured for browserDialers url: ", addr) } conns := dialer.conns diff --git a/transport/internet/splithttp/dialer.go b/transport/internet/splithttp/dialer.go index 425ebbabab74..171ce0f5d2fc 100644 --- a/transport/internet/splithttp/dialer.go +++ b/transport/internet/splithttp/dialer.go @@ -68,7 +68,7 @@ func getHTTPClient(ctx context.Context, dest net.Destination, streamSettings *in } } - if browser_dialer.HasBrowserDialerWithAddress(browserDialer) && realityConfig == nil { + if browserDialer != "" && realityConfig == nil { transportConfig := streamSettings.ProtocolSettings.(*Config) if transportConfig.Mode != "auto" && transportConfig.Mode != "packet-up" { return &errorDialerClient{ diff --git a/transport/internet/websocket/dialer.go b/transport/internet/websocket/dialer.go index f7bc496822c0..8a491c4ad1fe 100644 --- a/transport/internet/websocket/dialer.go +++ b/transport/internet/websocket/dialer.go @@ -123,7 +123,7 @@ func dialWebSocket(ctx context.Context, dest net.Destination, streamSettings *in browserDialer = taggedDialer } } - if browser_dialer.HasBrowserDialerWithAddress(browserDialer) { + if browserDialer != "" { conn, err := browser_dialer.DialWSWithAddress(browserDialer, uri, ed) if err != nil { return nil, err From 7a9c592221f07cf22ef156668478610554624eb5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:49:21 +0000 Subject: [PATCH 26/44] refactor: use browserDialers URL array and dialerProxy URL matching Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/6ed9ad08-e0b0-43d4-878b-7fe355be5325 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- infra/conf/transport_internet.go | 8 ++-- infra/conf/xray.go | 19 +-------- transport/internet/browser_dialer/dialer.go | 46 ++++++++++----------- transport/internet/dialer.go | 4 +- transport/internet/splithttp/dialer.go | 4 +- transport/internet/websocket/dialer.go | 4 +- 6 files changed, 34 insertions(+), 51 deletions(-) diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index f2e548080962..5057cdc02559 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -1972,12 +1972,12 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { config.ProtocolName = protocol } if c.SocketSettings != nil && c.SocketSettings.DialerProxy != "" { - if _, ok := browser_dialer.GetAddressByTag(c.SocketSettings.DialerProxy); ok { + if browser_dialer.HasConfiguredURL(c.SocketSettings.DialerProxy) { if config.ProtocolName != "websocket" && config.ProtocolName != "splithttp" { - return nil, errors.New("dialerProxy tag ", c.SocketSettings.DialerProxy, " maps to browserDialers and only supports websocket or splithttp") + return nil, errors.New("dialerProxy url ", c.SocketSettings.DialerProxy, " is in browserDialers and only supports websocket or splithttp") } if strings.EqualFold(c.Security, "reality") { - return nil, errors.New("dialerProxy tag ", c.SocketSettings.DialerProxy, " maps to browserDialers and does not support REALITY") + return nil, errors.New("dialerProxy url ", c.SocketSettings.DialerProxy, " is in browserDialers and does not support REALITY") } if config.ProtocolName == "splithttp" { splitHTTPSettings := c.SplitHTTPSettings @@ -1991,7 +1991,7 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { return nil, errors.New("failed to build XHTTP config for browserDialers validation.").Base(err) } if splitHTTPConfig, ok := hs.(*splithttp.Config); ok && splitHTTPConfig.Mode != "auto" && splitHTTPConfig.Mode != "packet-up" { - return nil, errors.New("dialerProxy tag ", c.SocketSettings.DialerProxy, " maps to browserDialers and only supports XHTTP modes \"auto\" or \"packet-up\", got: \"", splitHTTPConfig.Mode, "\"") + return nil, errors.New("dialerProxy url ", c.SocketSettings.DialerProxy, " is in browserDialers and only supports XHTTP modes \"auto\" or \"packet-up\", got: \"", splitHTTPConfig.Mode, "\"") } } } diff --git a/infra/conf/xray.go b/infra/conf/xray.go index 938bdd76a42b..70936a0a0273 100644 --- a/infra/conf/xray.go +++ b/infra/conf/xray.go @@ -363,12 +363,7 @@ type Config struct { BurstObservatory *BurstObservatoryConfig `json:"burstObservatory"` Version *VersionConfig `json:"version"` Geodata *GeodataConfig `json:"geodata"` - BrowserDialers []BrowserDialerConfig `json:"browserDialers"` -} - -type BrowserDialerConfig struct { - Tag string `json:"tag"` - URL string `json:"url"` + BrowserDialers []string `json:"browserDialers"` } func (c *Config) findInboundTag(tag string) int { @@ -618,17 +613,7 @@ func (c *Config) Build() (*core.Config, error) { return nil, err } - browserDialerTags := make(map[string]string, len(c.BrowserDialers)) - for _, browserDialer := range c.BrowserDialers { - if browserDialer.Tag == "" { - return nil, errors.New("browserDialers tag cannot be empty") - } - if _, found := browserDialerTags[browserDialer.Tag]; found { - return nil, errors.New("duplicate browserDialers tag: ", browserDialer.Tag) - } - browserDialerTags[browserDialer.Tag] = browserDialer.URL - } - if err := browser_dialer.ConfigureDialerTags(browserDialerTags); err != nil { + if err := browser_dialer.ConfigureDialers(c.BrowserDialers); err != nil { return nil, errors.New("failed to configure browserDialers").Base(err) } diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index ac3a9887401d..ae55b1e799c0 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -34,7 +34,7 @@ type task struct { var dialersByAddress = map[string]*dialerInstance{} var serversByListenAddr = map[string]*dialerServer{} -var addressByTag atomic.Value +var configuredURLs atomic.Value var initMu sync.Mutex var initialized bool @@ -49,13 +49,13 @@ var upgrader = &websocket.Upgrader{ }, } -func GetAddressByTag(tag string) (string, bool) { - if tag == "" { - return "", false +func HasConfiguredURL(url string) bool { + if url == "" { + return false } - tags, _ := addressByTag.Load().(map[string]string) - addr, ok := tags[tag] - return addr, ok + urls, _ := configuredURLs.Load().(map[string]struct{}) + _, ok := urls[url] + return ok } func CheckLegacyEnv() error { @@ -66,7 +66,7 @@ func CheckLegacyEnv() error { return errors.PrintRemovedFeatureError("env "+platform.BrowserDialerAddress, "root browserDialers + sockopt.dialerProxy") } -func ConfigureDialerTags(tags map[string]string) error { +func ConfigureDialers(urls []string) error { initMu.Lock() defer initMu.Unlock() @@ -77,28 +77,25 @@ func ConfigureDialerTags(tags map[string]string) error { if err := CheckLegacyEnv(); err != nil { return err } - next := make(map[string]string, len(tags)) - listenAddrByPort := make(map[string]string, len(tags)) - for tag, addr := range tags { - if tag == "" { - return errors.New("browserDialers tag cannot be empty") + next := make(map[string]struct{}, len(urls)) + listenAddrByPort := make(map[string]string, len(urls)) + for _, browserDialerURL := range urls { + if browserDialerURL == "" { + return errors.New("browserDialers url cannot be empty") } - if addr == "" { - return errors.New("browserDialers url cannot be empty for tag: ", tag) - } - listenAddr, _, ok := parseBrowserDialerAddress(addr) + listenAddr, _, ok := parseBrowserDialerAddress(browserDialerURL) if !ok { - return errors.New("invalid browserDialers entry for tag ", tag, ": ", addr) + return errors.New("invalid browserDialers url: ", browserDialerURL) } _, port, err := net.SplitHostPort(listenAddr) if err != nil { - return errors.New("invalid browserDialers listen address for tag ", tag, ": ", listenAddr) + return errors.New("invalid browserDialers listen address: ", listenAddr) } if existingAddr, found := listenAddrByPort[port]; found && existingAddr != listenAddr { return errors.New("browserDialers cannot use the same port with a different listen address: ", existingAddr, " and ", listenAddr) } listenAddrByPort[port] = listenAddr - next[tag] = addr + next[browserDialerURL] = struct{}{} } for existingAddr := range serversByListenAddr { _, existingPort, splitErr := net.SplitHostPort(existingAddr) @@ -109,9 +106,10 @@ func ConfigureDialerTags(tags map[string]string) error { return errors.New("browserDialers cannot use the same port with a different listen address: ", existingAddr, " and ", newAddr) } } - for tag, addr := range next { + for browserDialerURL := range next { + addr := browserDialerURL if err := EnsureDialerWithAddress(addr); err != nil { - return errors.New("failed to initialize browserDialers listener for tag ", tag).Base(err) + return errors.New("failed to initialize browserDialers listener for url ", browserDialerURL).Base(err) } } for listenAddr, server := range serversByListenAddr { @@ -119,7 +117,7 @@ func ConfigureDialerTags(tags map[string]string) error { return errors.New("failed to start browserDialers listener on ", listenAddr).Base(err) } } - addressByTag.Store(next) + configuredURLs.Store(next) initialized = true return nil } @@ -403,7 +401,7 @@ func dialTaskWithAddress(addr string, task task) (*websocket.Conn, error) { } if addr == "" { - return nil, errors.New("browser dialer is not configured; set root browserDialers and use sockopt.dialerProxy tag") + return nil, errors.New("browser dialer is not configured; set root browserDialers and use sockopt.dialerProxy url") } dialer, err := getDialerByAddress(addr) if err != nil { diff --git a/transport/internet/dialer.go b/transport/internet/dialer.go index 993d736c8e33..b0cf50d29024 100644 --- a/transport/internet/dialer.go +++ b/transport/internet/dialer.go @@ -270,8 +270,8 @@ func DialSystem(ctx context.Context, dest net.Destination, sockopt *SocketConfig } if len(sockopt.DialerProxy) > 0 { - if _, ok := browser_dialer.GetAddressByTag(sockopt.DialerProxy); ok { - return nil, errors.New("dialerProxy tag ", sockopt.DialerProxy, " maps to browserDialers and only supports WebSocket or splithttp").AtError() + if browser_dialer.HasConfiguredURL(sockopt.DialerProxy) { + return nil, errors.New("dialerProxy url ", sockopt.DialerProxy, " is in browserDialers and only supports WebSocket or splithttp").AtError() } if obm == nil { return nil, errors.New("there is no outbound manager for dialerProxy").AtError() diff --git a/transport/internet/splithttp/dialer.go b/transport/internet/splithttp/dialer.go index 171ce0f5d2fc..052ac20f2963 100644 --- a/transport/internet/splithttp/dialer.go +++ b/transport/internet/splithttp/dialer.go @@ -63,8 +63,8 @@ func getHTTPClient(ctx context.Context, dest net.Destination, streamSettings *in realityConfig := reality.ConfigFromStreamSettings(streamSettings) browserDialer := "" if streamSettings.SocketSettings != nil { - if taggedDialer, ok := browser_dialer.GetAddressByTag(streamSettings.SocketSettings.DialerProxy); ok { - browserDialer = taggedDialer + if browser_dialer.HasConfiguredURL(streamSettings.SocketSettings.DialerProxy) { + browserDialer = streamSettings.SocketSettings.DialerProxy } } diff --git a/transport/internet/websocket/dialer.go b/transport/internet/websocket/dialer.go index 8a491c4ad1fe..48ad45df865c 100644 --- a/transport/internet/websocket/dialer.go +++ b/transport/internet/websocket/dialer.go @@ -119,8 +119,8 @@ func dialWebSocket(ctx context.Context, dest net.Destination, streamSettings *in browserDialer := "" if streamSettings.SocketSettings != nil { - if taggedDialer, ok := browser_dialer.GetAddressByTag(streamSettings.SocketSettings.DialerProxy); ok { - browserDialer = taggedDialer + if browser_dialer.HasConfiguredURL(streamSettings.SocketSettings.DialerProxy) { + browserDialer = streamSettings.SocketSettings.DialerProxy } } if browserDialer != "" { From 9421ac0b13768e4931f23d66f568f1f8a09eeb7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:59:10 +0000 Subject: [PATCH 27/44] refactor: further simplify browser dialer URL parsing path Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/56a85a3a-5e08-44ce-97d9-d441379c11cc Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index ae55b1e799c0..a006ad4754cd 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -107,8 +107,7 @@ func ConfigureDialers(urls []string) error { } } for browserDialerURL := range next { - addr := browserDialerURL - if err := EnsureDialerWithAddress(addr); err != nil { + if err := EnsureDialerWithAddress(browserDialerURL); err != nil { return errors.New("failed to initialize browserDialers listener for url ", browserDialerURL).Base(err) } } @@ -142,19 +141,15 @@ func parseBrowserDialerAddress(addr string) (string, string, bool) { return "", "", false } - listenAddr, pathRaw, ok := strings.Cut(addr, "/") - if !ok || listenAddr == "" || pathRaw == "" { + parsedAddr, err := url.Parse("http://" + addr) + if err != nil || parsedAddr.Host == "" || parsedAddr.Path == "" || parsedAddr.RawQuery != "" || parsedAddr.Fragment != "" { return "", "", false } - - path := "/" + strings.TrimSuffix(pathRaw, "/") + listenAddr := parsedAddr.Host if _, _, err := net.SplitHostPort(listenAddr); err != nil { return "", "", false } - parsedPath, err := url.ParseRequestURI(path) - if err != nil || parsedPath.RawQuery != "" || parsedPath.Fragment != "" { - return "", "", false - } + path := strings.TrimSuffix(parsedAddr.Path, "/") cleanPath := pathlib.Clean(path) if cleanPath == "." || cleanPath == "/" || cleanPath != path { return "", "", false @@ -407,9 +402,6 @@ func dialTaskWithAddress(addr string, task task) (*websocket.Conn, error) { if err != nil { return nil, err } - if dialer == nil { - return nil, errors.New("browser dialer is not configured for browserDialers url: ", addr) - } conns := dialer.conns var conn *websocket.Conn From e79f3a4361d02c175d327208c69b46e24ee03759 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:00:58 +0000 Subject: [PATCH 28/44] fix: normalize parsed browser dialer path with explicit leading slash Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/56a85a3a-5e08-44ce-97d9-d441379c11cc Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index a006ad4754cd..b43f1f945b38 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -149,7 +149,7 @@ func parseBrowserDialerAddress(addr string) (string, string, bool) { if _, _, err := net.SplitHostPort(listenAddr); err != nil { return "", "", false } - path := strings.TrimSuffix(parsedAddr.Path, "/") + path := "/" + strings.Trim(strings.TrimSuffix(parsedAddr.Path, "/"), "/") cleanPath := pathlib.Clean(path) if cleanPath == "." || cleanPath == "/" || cleanPath != path { return "", "", false From 7416fd225e4c67643dfdc8f7da2b7fc0b574ea99 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:02:41 +0000 Subject: [PATCH 29/44] refactor: simplify browser dialer parsed path normalization Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/56a85a3a-5e08-44ce-97d9-d441379c11cc Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index b43f1f945b38..1fba097544ff 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -149,7 +149,10 @@ func parseBrowserDialerAddress(addr string) (string, string, bool) { if _, _, err := net.SplitHostPort(listenAddr); err != nil { return "", "", false } - path := "/" + strings.Trim(strings.TrimSuffix(parsedAddr.Path, "/"), "/") + path := strings.TrimSuffix(parsedAddr.Path, "/") + if !strings.HasPrefix(path, "/") { + path = "/" + path + } cleanPath := pathlib.Clean(path) if cleanPath == "." || cleanPath == "/" || cleanPath != path { return "", "", false From be9a229d688b19340792f686be3bedbd3adabe4f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:46:22 +0000 Subject: [PATCH 30/44] refactor: switch browser dialer to browser:// dialerProxy collection Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/84d72770-6ad0-447a-8d86-94d692972a05 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- infra/conf/transport_internet.go | 11 +-- infra/conf/xray.go | 14 +-- transport/internet/browser_dialer/dialer.go | 88 +++++++++++-------- .../internet/browser_dialer/dialer_test.go | 22 ----- transport/internet/dialer.go | 4 +- transport/internet/splithttp/dialer.go | 2 +- transport/internet/websocket/dialer.go | 2 +- 7 files changed, 65 insertions(+), 78 deletions(-) delete mode 100644 transport/internet/browser_dialer/dialer_test.go diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index 5057cdc02559..3c3e10a2741e 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -1972,12 +1972,12 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { config.ProtocolName = protocol } if c.SocketSettings != nil && c.SocketSettings.DialerProxy != "" { - if browser_dialer.HasConfiguredURL(c.SocketSettings.DialerProxy) { + if browser_dialer.IsBrowserDialerProxy(c.SocketSettings.DialerProxy) { if config.ProtocolName != "websocket" && config.ProtocolName != "splithttp" { - return nil, errors.New("dialerProxy url ", c.SocketSettings.DialerProxy, " is in browserDialers and only supports websocket or splithttp") + return nil, errors.New("dialerProxy ", c.SocketSettings.DialerProxy, " only supports websocket or splithttp") } if strings.EqualFold(c.Security, "reality") { - return nil, errors.New("dialerProxy url ", c.SocketSettings.DialerProxy, " is in browserDialers and does not support REALITY") + return nil, errors.New("dialerProxy ", c.SocketSettings.DialerProxy, " does not support REALITY") } if config.ProtocolName == "splithttp" { splitHTTPSettings := c.SplitHTTPSettings @@ -1988,13 +1988,14 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { splitHTTPSettingsCopy := *splitHTTPSettings hs, err := splitHTTPSettingsCopy.Build() if err != nil { - return nil, errors.New("failed to build XHTTP config for browserDialers validation.").Base(err) + return nil, errors.New("failed to build XHTTP config for browser dialer validation").Base(err) } if splitHTTPConfig, ok := hs.(*splithttp.Config); ok && splitHTTPConfig.Mode != "auto" && splitHTTPConfig.Mode != "packet-up" { - return nil, errors.New("dialerProxy url ", c.SocketSettings.DialerProxy, " is in browserDialers and only supports XHTTP modes \"auto\" or \"packet-up\", got: \"", splitHTTPConfig.Mode, "\"") + return nil, errors.New("dialerProxy ", c.SocketSettings.DialerProxy, " only supports XHTTP modes \"auto\" or \"packet-up\", got: \"", splitHTTPConfig.Mode, "\"") } } } + browser_dialer.RegisterDialerProxyURL(c.SocketSettings.DialerProxy) } } diff --git a/infra/conf/xray.go b/infra/conf/xray.go index 70936a0a0273..cb9367defe89 100644 --- a/infra/conf/xray.go +++ b/infra/conf/xray.go @@ -363,7 +363,6 @@ type Config struct { BurstObservatory *BurstObservatoryConfig `json:"burstObservatory"` Version *VersionConfig `json:"version"` Geodata *GeodataConfig `json:"geodata"` - BrowserDialers []string `json:"browserDialers"` } func (c *Config) findInboundTag(tag string) int { @@ -439,10 +438,6 @@ func (c *Config) Override(o *Config, fn string) { if o.Geodata != nil { c.Geodata = o.Geodata } - if o.BrowserDialers != nil { - c.BrowserDialers = o.BrowserDialers - } - // update the Inbound in slice if the only one in override config has same tag if len(o.InboundConfigs) > 0 { for i := range o.InboundConfigs { @@ -609,14 +604,10 @@ func (c *Config) Build() (*core.Config, error) { if len(c.Transport) > 0 { return nil, errors.PrintRemovedFeatureError("Global transport config", "streamSettings in inbounds and outbounds") } - if err := browser_dialer.CheckLegacyEnv(); err != nil { + if err := browser_dialer.BeginCollectingDialerProxyURLs(); err != nil { return nil, err } - if err := browser_dialer.ConfigureDialers(c.BrowserDialers); err != nil { - return nil, errors.New("failed to configure browserDialers").Base(err) - } - for _, rawInboundConfig := range inbounds { ic, err := rawInboundConfig.Build() if err != nil { @@ -638,6 +629,9 @@ func (c *Config) Build() (*core.Config, error) { } config.Outbound = append(config.Outbound, oc) } + if err := browser_dialer.ConfigureCollectedDialerProxyURLs(); err != nil { + return nil, errors.New("failed to configure browser dialer").Base(err) + } return config, nil } diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 1fba097544ff..27d5f86dfc07 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -13,7 +13,6 @@ import ( pathlib "path" "strings" "sync" - "sync/atomic" "time" "github.com/gorilla/websocket" @@ -34,9 +33,9 @@ type task struct { var dialersByAddress = map[string]*dialerInstance{} var serversByListenAddr = map[string]*dialerServer{} -var configuredURLs atomic.Value var initMu sync.Mutex var initialized bool +var pendingURLs map[string]struct{} const browserDialerSubprotocol = "browser-dialer" @@ -49,53 +48,69 @@ var upgrader = &websocket.Upgrader{ }, } -func HasConfiguredURL(url string) bool { - if url == "" { - return false - } - urls, _ := configuredURLs.Load().(map[string]struct{}) - _, ok := urls[url] - return ok -} - func CheckLegacyEnv() error { envAddress := platform.NewEnvFlag(platform.BrowserDialerAddress).GetValue(func() string { return "" }) if envAddress == "" { return nil } - return errors.PrintRemovedFeatureError("env "+platform.BrowserDialerAddress, "root browserDialers + sockopt.dialerProxy") + return errors.PrintRemovedFeatureError("env "+platform.BrowserDialerAddress, "sockopt.dialerProxy with browser://host:port/uuid") } -func ConfigureDialers(urls []string) error { +func IsBrowserDialerProxy(raw string) bool { + parsed, err := url.Parse(raw) + return err == nil && strings.EqualFold(parsed.Scheme, "browser") +} + +func BeginCollectingDialerProxyURLs() error { initMu.Lock() defer initMu.Unlock() if initialized { - return errors.New("browserDialers does not support dynamic add/remove; restart is required after changing configuration") + return errors.New("browser dialer does not support dynamic add/remove; restart is required after changing configuration") + } + if err := CheckLegacyEnv(); err != nil { + return err } + pendingURLs = map[string]struct{}{} + return nil +} +func RegisterDialerProxyURL(raw string) { + if !IsBrowserDialerProxy(raw) { + return + } + initMu.Lock() + defer initMu.Unlock() + if pendingURLs == nil { + pendingURLs = map[string]struct{}{} + } + pendingURLs[raw] = struct{}{} +} + +func ConfigureCollectedDialerProxyURLs() error { + initMu.Lock() + defer initMu.Unlock() + + if initialized { + return errors.New("browser dialer does not support dynamic add/remove; restart is required after changing configuration") + } if err := CheckLegacyEnv(); err != nil { return err } - next := make(map[string]struct{}, len(urls)) - listenAddrByPort := make(map[string]string, len(urls)) - for _, browserDialerURL := range urls { - if browserDialerURL == "" { - return errors.New("browserDialers url cannot be empty") - } + listenAddrByPort := make(map[string]string, len(pendingURLs)) + for browserDialerURL := range pendingURLs { listenAddr, _, ok := parseBrowserDialerAddress(browserDialerURL) if !ok { - return errors.New("invalid browserDialers url: ", browserDialerURL) + return errors.New("invalid browser dialer url: ", browserDialerURL) } _, port, err := net.SplitHostPort(listenAddr) if err != nil { - return errors.New("invalid browserDialers listen address: ", listenAddr) + return errors.New("invalid browser dialer listen address: ", listenAddr) } if existingAddr, found := listenAddrByPort[port]; found && existingAddr != listenAddr { - return errors.New("browserDialers cannot use the same port with a different listen address: ", existingAddr, " and ", listenAddr) + return errors.New("browser dialer cannot use the same port with a different listen address: ", existingAddr, " and ", listenAddr) } listenAddrByPort[port] = listenAddr - next[browserDialerURL] = struct{}{} } for existingAddr := range serversByListenAddr { _, existingPort, splitErr := net.SplitHostPort(existingAddr) @@ -103,20 +118,19 @@ func ConfigureDialers(urls []string) error { continue } if newAddr, found := listenAddrByPort[existingPort]; found && newAddr != existingAddr { - return errors.New("browserDialers cannot use the same port with a different listen address: ", existingAddr, " and ", newAddr) + return errors.New("browser dialer cannot use the same port with a different listen address: ", existingAddr, " and ", newAddr) } } - for browserDialerURL := range next { + for browserDialerURL := range pendingURLs { if err := EnsureDialerWithAddress(browserDialerURL); err != nil { - return errors.New("failed to initialize browserDialers listener for url ", browserDialerURL).Base(err) + return errors.New("failed to initialize browser dialer listener for url ", browserDialerURL).Base(err) } } for listenAddr, server := range serversByListenAddr { if err := server.start(); err != nil { - return errors.New("failed to start browserDialers listener on ", listenAddr).Base(err) + return errors.New("failed to start browser dialer listener on ", listenAddr).Base(err) } } - configuredURLs.Store(next) initialized = true return nil } @@ -141,8 +155,8 @@ func parseBrowserDialerAddress(addr string) (string, string, bool) { return "", "", false } - parsedAddr, err := url.Parse("http://" + addr) - if err != nil || parsedAddr.Host == "" || parsedAddr.Path == "" || parsedAddr.RawQuery != "" || parsedAddr.Fragment != "" { + parsedAddr, err := url.Parse(addr) + if err != nil || !strings.EqualFold(parsedAddr.Scheme, "browser") || parsedAddr.Host == "" || parsedAddr.Path == "" || parsedAddr.RawQuery != "" || parsedAddr.Fragment != "" { return "", "", false } listenAddr := parsedAddr.Host @@ -247,23 +261,23 @@ func closeConnection(w http.ResponseWriter) { func getDialerByAddress(addr string) (*dialerInstance, error) { listenAddr, path, ok := parseBrowserDialerAddress(addr) if !ok { - return nil, errors.New("invalid browserDialers url: ", addr) + return nil, errors.New("invalid browser dialer url: ", addr) } key := listenAddr + path if dialer, found := dialersByAddress[key]; found { return dialer, nil } - return nil, errors.New("browser dialer is not configured for browserDialers url: ", addr) + return nil, errors.New("browser dialer is not configured for url: ", addr) } func ensureDialerWithAddress(addr string) (*dialerInstance, error) { listenAddr, path, ok := parseBrowserDialerAddress(addr) if !ok { - return nil, errors.New("invalid browserDialers url: ", addr) + return nil, errors.New("invalid browser dialer url: ", addr) } _, port, err := net.SplitHostPort(listenAddr) if err != nil { - return nil, errors.New("invalid browserDialers listen address: ", listenAddr) + return nil, errors.New("invalid browser dialer listen address: ", listenAddr) } key := listenAddr + path @@ -276,7 +290,7 @@ func ensureDialerWithAddress(addr string) (*dialerInstance, error) { for existingAddr := range serversByListenAddr { _, existingPort, splitErr := net.SplitHostPort(existingAddr) if splitErr == nil && existingPort == port { - return nil, errors.New("browserDialers cannot use the same port with a different listen address: ", existingAddr, " and ", listenAddr) + return nil, errors.New("browser dialer cannot use the same port with a different listen address: ", existingAddr, " and ", listenAddr) } } newServer, serverErr := newDialerServer(listenAddr) @@ -399,7 +413,7 @@ func dialTaskWithAddress(addr string, task task) (*websocket.Conn, error) { } if addr == "" { - return nil, errors.New("browser dialer is not configured; set root browserDialers and use sockopt.dialerProxy url") + return nil, errors.New("browser dialer is not configured; set sockopt.dialerProxy to browser://host:port/uuid") } dialer, err := getDialerByAddress(addr) if err != nil { diff --git a/transport/internet/browser_dialer/dialer_test.go b/transport/internet/browser_dialer/dialer_test.go deleted file mode 100644 index 9384306f704d..000000000000 --- a/transport/internet/browser_dialer/dialer_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package browser_dialer - -import "testing" - -func TestParseBrowserDialerAddressRequireUUIDPath(t *testing.T) { - valid := "127.0.0.1:8080/123e4567-e89b-12d3-a456-426614174000" - if _, _, ok := parseBrowserDialerAddress(valid); !ok { - t.Fatalf("expected valid browser dialer address: %s", valid) - } - - invalid := []string{ - "127.0.0.1:8080/example", - "127.0.0.1:8080/short", - "127.0.0.1:8080/123e4567e89b12d3a456426614174000", - "127.0.0.1:8080/123e4567-e89b-12d3-a456-426614174000/extra", - } - for _, addr := range invalid { - if _, _, ok := parseBrowserDialerAddress(addr); ok { - t.Fatalf("expected invalid browser dialer address: %s", addr) - } - } -} diff --git a/transport/internet/dialer.go b/transport/internet/dialer.go index b0cf50d29024..1a4fe468e255 100644 --- a/transport/internet/dialer.go +++ b/transport/internet/dialer.go @@ -270,8 +270,8 @@ func DialSystem(ctx context.Context, dest net.Destination, sockopt *SocketConfig } if len(sockopt.DialerProxy) > 0 { - if browser_dialer.HasConfiguredURL(sockopt.DialerProxy) { - return nil, errors.New("dialerProxy url ", sockopt.DialerProxy, " is in browserDialers and only supports WebSocket or splithttp").AtError() + if browser_dialer.IsBrowserDialerProxy(sockopt.DialerProxy) { + return nil, errors.New("dialerProxy ", sockopt.DialerProxy, " only supports WebSocket or splithttp").AtError() } if obm == nil { return nil, errors.New("there is no outbound manager for dialerProxy").AtError() diff --git a/transport/internet/splithttp/dialer.go b/transport/internet/splithttp/dialer.go index 052ac20f2963..9b5562983cd1 100644 --- a/transport/internet/splithttp/dialer.go +++ b/transport/internet/splithttp/dialer.go @@ -63,7 +63,7 @@ func getHTTPClient(ctx context.Context, dest net.Destination, streamSettings *in realityConfig := reality.ConfigFromStreamSettings(streamSettings) browserDialer := "" if streamSettings.SocketSettings != nil { - if browser_dialer.HasConfiguredURL(streamSettings.SocketSettings.DialerProxy) { + if browser_dialer.IsBrowserDialerProxy(streamSettings.SocketSettings.DialerProxy) { browserDialer = streamSettings.SocketSettings.DialerProxy } } diff --git a/transport/internet/websocket/dialer.go b/transport/internet/websocket/dialer.go index 48ad45df865c..6641ed50bbe3 100644 --- a/transport/internet/websocket/dialer.go +++ b/transport/internet/websocket/dialer.go @@ -119,7 +119,7 @@ func dialWebSocket(ctx context.Context, dest net.Destination, streamSettings *in browserDialer := "" if streamSettings.SocketSettings != nil { - if browser_dialer.HasConfiguredURL(streamSettings.SocketSettings.DialerProxy) { + if browser_dialer.IsBrowserDialerProxy(streamSettings.SocketSettings.DialerProxy) { browserDialer = streamSettings.SocketSettings.DialerProxy } } From bb79f554dd7caf2c143f09d79e92e11af7d1bda0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:48:03 +0000 Subject: [PATCH 31/44] fix: tighten browser URL collection order and error handling Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/84d72770-6ad0-447a-8d86-94d692972a05 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- infra/conf/transport_internet.go | 4 +++- transport/internet/browser_dialer/dialer.go | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index 3c3e10a2741e..c7009f6b4fe7 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -1995,7 +1995,9 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { } } } - browser_dialer.RegisterDialerProxyURL(c.SocketSettings.DialerProxy) + if err := browser_dialer.RegisterDialerProxyURL(c.SocketSettings.DialerProxy); err != nil { + return nil, errors.New("failed to collect browser dialer url.").Base(err) + } } } diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 27d5f86dfc07..a8cef9ee371e 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -75,16 +75,17 @@ func BeginCollectingDialerProxyURLs() error { return nil } -func RegisterDialerProxyURL(raw string) { +func RegisterDialerProxyURL(raw string) error { if !IsBrowserDialerProxy(raw) { - return + return nil } initMu.Lock() defer initMu.Unlock() if pendingURLs == nil { - pendingURLs = map[string]struct{}{} + return errors.New("browser dialer url collection is not initialized") } pendingURLs[raw] = struct{}{} + return nil } func ConfigureCollectedDialerProxyURLs() error { From 9f8f5c2591b3095d2fd324d798339ea99219013e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:49:28 +0000 Subject: [PATCH 32/44] chore: polish browser dialer URL collection error message Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/84d72770-6ad0-447a-8d86-94d692972a05 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- infra/conf/transport_internet.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index c7009f6b4fe7..4e4bba3a0d48 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -1996,7 +1996,7 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { } } if err := browser_dialer.RegisterDialerProxyURL(c.SocketSettings.DialerProxy); err != nil { - return nil, errors.New("failed to collect browser dialer url.").Base(err) + return nil, errors.New("failed to collect browser dialer URL.").Base(err) } } } From 2ecfbcf1b66eb6e0e67af2c782dfbcf2a22c4db2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:50:38 +0000 Subject: [PATCH 33/44] chore: align browser dialer collection error style Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/84d72770-6ad0-447a-8d86-94d692972a05 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- infra/conf/transport_internet.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index 4e4bba3a0d48..cdca86426ee2 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -1996,7 +1996,7 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { } } if err := browser_dialer.RegisterDialerProxyURL(c.SocketSettings.DialerProxy); err != nil { - return nil, errors.New("failed to collect browser dialer URL.").Base(err) + return nil, errors.New("failed to collect browser dialer URL").Base(err) } } } From 3004e1e3998d328495e3560e67dadd2200818fe2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:54:10 +0000 Subject: [PATCH 34/44] refactor: use http:// dialerProxy scheme for browser dialer Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/de6da8b1-0d9b-4d56-a5a2-d0b06edc2965 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index a8cef9ee371e..c86923c31d1d 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -53,12 +53,12 @@ func CheckLegacyEnv() error { if envAddress == "" { return nil } - return errors.PrintRemovedFeatureError("env "+platform.BrowserDialerAddress, "sockopt.dialerProxy with browser://host:port/uuid") + return errors.PrintRemovedFeatureError("env "+platform.BrowserDialerAddress, "sockopt.dialerProxy with http://host:port/uuid") } func IsBrowserDialerProxy(raw string) bool { parsed, err := url.Parse(raw) - return err == nil && strings.EqualFold(parsed.Scheme, "browser") + return err == nil && strings.EqualFold(parsed.Scheme, "http") } func BeginCollectingDialerProxyURLs() error { @@ -157,7 +157,7 @@ func parseBrowserDialerAddress(addr string) (string, string, bool) { } parsedAddr, err := url.Parse(addr) - if err != nil || !strings.EqualFold(parsedAddr.Scheme, "browser") || parsedAddr.Host == "" || parsedAddr.Path == "" || parsedAddr.RawQuery != "" || parsedAddr.Fragment != "" { + if err != nil || !strings.EqualFold(parsedAddr.Scheme, "http") || parsedAddr.Host == "" || parsedAddr.Path == "" || parsedAddr.RawQuery != "" || parsedAddr.Fragment != "" { return "", "", false } listenAddr := parsedAddr.Host @@ -414,7 +414,7 @@ func dialTaskWithAddress(addr string, task task) (*websocket.Conn, error) { } if addr == "" { - return nil, errors.New("browser dialer is not configured; set sockopt.dialerProxy to browser://host:port/uuid") + return nil, errors.New("browser dialer is not configured; set sockopt.dialerProxy to http://host:port/uuid") } dialer, err := getDialerByAddress(addr) if err != nil { From f1f0d5970a3b87dc6991a6ae3b77413a250fc37e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:56:05 +0000 Subject: [PATCH 35/44] fix: require full valid browser dialer URL for http scheme Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/de6da8b1-0d9b-4d56-a5a2-d0b06edc2965 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index c86923c31d1d..901bda249006 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -57,8 +57,8 @@ func CheckLegacyEnv() error { } func IsBrowserDialerProxy(raw string) bool { - parsed, err := url.Parse(raw) - return err == nil && strings.EqualFold(parsed.Scheme, "http") + _, _, ok := parseBrowserDialerAddress(raw) + return ok } func BeginCollectingDialerProxyURLs() error { From 89396570162bd51d7e8ee5104f2e43a515d116f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:07:28 +0000 Subject: [PATCH 36/44] refactor: drop socket proto browser_dialer and remove dialer init locks Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/b6b773ed-3d59-4a25-b688-8032a496a8de Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 10 ---------- transport/internet/config.pb.go | 13 ++----------- transport/internet/config.proto | 2 -- 3 files changed, 2 insertions(+), 23 deletions(-) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 901bda249006..f902da0e8dfe 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -12,7 +12,6 @@ import ( "net/url" pathlib "path" "strings" - "sync" "time" "github.com/gorilla/websocket" @@ -33,7 +32,6 @@ type task struct { var dialersByAddress = map[string]*dialerInstance{} var serversByListenAddr = map[string]*dialerServer{} -var initMu sync.Mutex var initialized bool var pendingURLs map[string]struct{} @@ -62,9 +60,6 @@ func IsBrowserDialerProxy(raw string) bool { } func BeginCollectingDialerProxyURLs() error { - initMu.Lock() - defer initMu.Unlock() - if initialized { return errors.New("browser dialer does not support dynamic add/remove; restart is required after changing configuration") } @@ -79,8 +74,6 @@ func RegisterDialerProxyURL(raw string) error { if !IsBrowserDialerProxy(raw) { return nil } - initMu.Lock() - defer initMu.Unlock() if pendingURLs == nil { return errors.New("browser dialer url collection is not initialized") } @@ -89,9 +82,6 @@ func RegisterDialerProxyURL(raw string) error { } func ConfigureCollectedDialerProxyURLs() error { - initMu.Lock() - defer initMu.Unlock() - if initialized { return errors.New("browser dialer does not support dynamic add/remove; restart is required after changing configuration") } diff --git a/transport/internet/config.pb.go b/transport/internet/config.pb.go index 3995a83dcd8b..e2339fe8da7b 100644 --- a/transport/internet/config.pb.go +++ b/transport/internet/config.pb.go @@ -749,7 +749,6 @@ type SocketConfig struct { AddressPortStrategy AddressPortStrategy `protobuf:"varint,21,opt,name=address_port_strategy,json=addressPortStrategy,proto3,enum=xray.transport.internet.AddressPortStrategy" json:"address_port_strategy,omitempty"` HappyEyeballs *HappyEyeballsConfig `protobuf:"bytes,22,opt,name=happy_eyeballs,json=happyEyeballs,proto3" json:"happy_eyeballs,omitempty"` TrustedXForwardedFor []string `protobuf:"bytes,23,rep,name=trusted_x_forwarded_for,json=trustedXForwardedFor,proto3" json:"trusted_x_forwarded_for,omitempty"` - BrowserDialer string `protobuf:"bytes,24,opt,name=browser_dialer,json=browserDialer,proto3" json:"browser_dialer,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -945,13 +944,6 @@ func (x *SocketConfig) GetTrustedXForwardedFor() []string { return nil } -func (x *SocketConfig) GetBrowserDialer() string { - if x != nil { - return x.BrowserDialer - } - return "" -} - type HappyEyeballsConfig struct { state protoimpl.MessageState `protogen:"open.v1"` PrioritizeIpv6 bool `protobuf:"varint,1,opt,name=prioritize_ipv6,json=prioritizeIpv6,proto3" json:"prioritize_ipv6,omitempty"` @@ -1074,7 +1066,7 @@ const file_transport_internet_config_proto_rawDesc = "" + "\x05level\x18\x03 \x01(\tR\x05level\x12\x10\n" + "\x03opt\x18\x04 \x01(\tR\x03opt\x12\x14\n" + "\x05value\x18\x05 \x01(\tR\x05value\x12\x12\n" + - "\x04type\x18\x06 \x01(\tR\x04type\"\xb0\t\n" + + "\x04type\x18\x06 \x01(\tR\x04type\"\x89\t\n" + "\fSocketConfig\x12\x12\n" + "\x04mark\x18\x01 \x01(\x05R\x04mark\x12\x10\n" + "\x03tfo\x18\x02 \x01(\x05R\x03tfo\x12H\n" + @@ -1099,8 +1091,7 @@ const file_transport_internet_config_proto_rawDesc = "" + "\rcustomSockopt\x18\x14 \x03(\v2&.xray.transport.internet.CustomSockoptR\rcustomSockopt\x12`\n" + "\x15address_port_strategy\x18\x15 \x01(\x0e2,.xray.transport.internet.AddressPortStrategyR\x13addressPortStrategy\x12S\n" + "\x0ehappy_eyeballs\x18\x16 \x01(\v2,.xray.transport.internet.HappyEyeballsConfigR\rhappyEyeballs\x125\n" + - "\x17trusted_x_forwarded_for\x18\x17 \x03(\tR\x14trustedXForwardedFor\x12%\n" + - "\x0ebrowser_dialer\x18\x18 \x01(\tR\rbrowserDialer\"/\n" + + "\x17trusted_x_forwarded_for\x18\x17 \x03(\tR\x14trustedXForwardedFor\"/\n" + "\n" + "TProxyMode\x12\a\n" + "\x03Off\x10\x00\x12\n" + diff --git a/transport/internet/config.proto b/transport/internet/config.proto index 79c22bf6957c..ad23f047eaf2 100644 --- a/transport/internet/config.proto +++ b/transport/internet/config.proto @@ -161,8 +161,6 @@ message SocketConfig { HappyEyeballsConfig happy_eyeballs = 22; repeated string trusted_x_forwarded_for = 23; - - string browser_dialer = 24; } message HappyEyeballsConfig { From 9d4dd2c32f39a0f1342d98523dc85d59d5275ddc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:18:00 +0000 Subject: [PATCH 37/44] refactor: switch browser dialer scheme back to browser and trim wrappers Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/4df6450c-a247-4e14-951f-2859a7875fe3 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index f902da0e8dfe..2f858a5721a8 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -51,7 +51,7 @@ func CheckLegacyEnv() error { if envAddress == "" { return nil } - return errors.PrintRemovedFeatureError("env "+platform.BrowserDialerAddress, "sockopt.dialerProxy with http://host:port/uuid") + return errors.PrintRemovedFeatureError("env "+platform.BrowserDialerAddress, "sockopt.dialerProxy with browser://host:port/uuid") } func IsBrowserDialerProxy(raw string) bool { @@ -113,7 +113,7 @@ func ConfigureCollectedDialerProxyURLs() error { } } for browserDialerURL := range pendingURLs { - if err := EnsureDialerWithAddress(browserDialerURL); err != nil { + if _, err := ensureDialerWithAddress(browserDialerURL); err != nil { return errors.New("failed to initialize browser dialer listener for url ", browserDialerURL).Base(err) } } @@ -147,7 +147,7 @@ func parseBrowserDialerAddress(addr string) (string, string, bool) { } parsedAddr, err := url.Parse(addr) - if err != nil || !strings.EqualFold(parsedAddr.Scheme, "http") || parsedAddr.Host == "" || parsedAddr.Path == "" || parsedAddr.RawQuery != "" || parsedAddr.Fragment != "" { + if err != nil || !strings.EqualFold(parsedAddr.Scheme, "browser") || parsedAddr.Host == "" || parsedAddr.Path == "" || parsedAddr.RawQuery != "" || parsedAddr.Fragment != "" { return "", "", false } listenAddr := parsedAddr.Host @@ -166,9 +166,6 @@ func parseBrowserDialerAddress(addr string) (string, string, bool) { return "", "", false } id := strings.TrimPrefix(cleanPath, "/") - if len(id) != 36 { - return "", "", false - } id = strings.ToLower(id) parsedUUID, err := uuid.ParseString(id) if err != nil || parsedUUID.String() != id { @@ -301,14 +298,6 @@ func ensureDialerWithAddress(addr string) (*dialerInstance, error) { return dialer, nil } -func EnsureDialerWithAddress(addr string) error { - if addr == "" { - return nil - } - _, err := ensureDialerWithAddress(addr) - return err -} - func DialWSWithAddress(addr string, uri string, ed []byte) (*websocket.Conn, error) { task := task{ Method: "WS", @@ -404,7 +393,7 @@ func dialTaskWithAddress(addr string, task task) (*websocket.Conn, error) { } if addr == "" { - return nil, errors.New("browser dialer is not configured; set sockopt.dialerProxy to http://host:port/uuid") + return nil, errors.New("browser dialer is not configured; set sockopt.dialerProxy to browser://host:port/uuid") } dialer, err := getDialerByAddress(addr) if err != nil { From 286a702bfa65abfd9316aaf8485b77285145660f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:28:35 +0000 Subject: [PATCH 38/44] refactor: split browser dialer manager logic to keep dialer.go minimal Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/2f611863-296d-48df-b3c4-e02384132848 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/dialer.go | 274 ------------------ transport/internet/browser_dialer/manager.go | 282 +++++++++++++++++++ 2 files changed, 282 insertions(+), 274 deletions(-) create mode 100644 transport/internet/browser_dialer/manager.go diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 2f858a5721a8..a8da600b938f 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -1,23 +1,13 @@ package browser_dialer import ( - "bytes" - "context" _ "embed" "encoding/base64" "encoding/json" - stderrors "errors" - "net" "net/http" - "net/url" - pathlib "path" - "strings" - "time" "github.com/gorilla/websocket" "github.com/xtls/xray-core/common/errors" - "github.com/xtls/xray-core/common/platform" - "github.com/xtls/xray-core/common/uuid" ) //go:embed dialer.html @@ -30,274 +20,10 @@ type task struct { StreamResponse bool `json:"streamResponse"` } -var dialersByAddress = map[string]*dialerInstance{} -var serversByListenAddr = map[string]*dialerServer{} -var initialized bool -var pendingURLs map[string]struct{} - -const browserDialerSubprotocol = "browser-dialer" - -var upgrader = &websocket.Upgrader{ - ReadBufferSize: 0, - WriteBufferSize: 0, - HandshakeTimeout: time.Second * 4, - CheckOrigin: func(r *http.Request) bool { - return true - }, -} - -func CheckLegacyEnv() error { - envAddress := platform.NewEnvFlag(platform.BrowserDialerAddress).GetValue(func() string { return "" }) - if envAddress == "" { - return nil - } - return errors.PrintRemovedFeatureError("env "+platform.BrowserDialerAddress, "sockopt.dialerProxy with browser://host:port/uuid") -} - -func IsBrowserDialerProxy(raw string) bool { - _, _, ok := parseBrowserDialerAddress(raw) - return ok -} - -func BeginCollectingDialerProxyURLs() error { - if initialized { - return errors.New("browser dialer does not support dynamic add/remove; restart is required after changing configuration") - } - if err := CheckLegacyEnv(); err != nil { - return err - } - pendingURLs = map[string]struct{}{} - return nil -} - -func RegisterDialerProxyURL(raw string) error { - if !IsBrowserDialerProxy(raw) { - return nil - } - if pendingURLs == nil { - return errors.New("browser dialer url collection is not initialized") - } - pendingURLs[raw] = struct{}{} - return nil -} - -func ConfigureCollectedDialerProxyURLs() error { - if initialized { - return errors.New("browser dialer does not support dynamic add/remove; restart is required after changing configuration") - } - if err := CheckLegacyEnv(); err != nil { - return err - } - listenAddrByPort := make(map[string]string, len(pendingURLs)) - for browserDialerURL := range pendingURLs { - listenAddr, _, ok := parseBrowserDialerAddress(browserDialerURL) - if !ok { - return errors.New("invalid browser dialer url: ", browserDialerURL) - } - _, port, err := net.SplitHostPort(listenAddr) - if err != nil { - return errors.New("invalid browser dialer listen address: ", listenAddr) - } - if existingAddr, found := listenAddrByPort[port]; found && existingAddr != listenAddr { - return errors.New("browser dialer cannot use the same port with a different listen address: ", existingAddr, " and ", listenAddr) - } - listenAddrByPort[port] = listenAddr - } - for existingAddr := range serversByListenAddr { - _, existingPort, splitErr := net.SplitHostPort(existingAddr) - if splitErr != nil { - continue - } - if newAddr, found := listenAddrByPort[existingPort]; found && newAddr != existingAddr { - return errors.New("browser dialer cannot use the same port with a different listen address: ", existingAddr, " and ", newAddr) - } - } - for browserDialerURL := range pendingURLs { - if _, err := ensureDialerWithAddress(browserDialerURL); err != nil { - return errors.New("failed to initialize browser dialer listener for url ", browserDialerURL).Base(err) - } - } - for listenAddr, server := range serversByListenAddr { - if err := server.start(); err != nil { - return errors.New("failed to start browser dialer listener on ", listenAddr).Base(err) - } - } - initialized = true - return nil -} - type webSocketExtra struct { Protocol string `json:"protocol,omitempty"` } -type dialerInstance struct { - conns chan *websocket.Conn - page []byte -} - -type dialerServer struct { - server *http.Server - pageRoutes map[string]*dialerInstance - started bool -} - -func parseBrowserDialerAddress(addr string) (string, string, bool) { - if addr == "" { - return "", "", false - } - - parsedAddr, err := url.Parse(addr) - if err != nil || !strings.EqualFold(parsedAddr.Scheme, "browser") || parsedAddr.Host == "" || parsedAddr.Path == "" || parsedAddr.RawQuery != "" || parsedAddr.Fragment != "" { - return "", "", false - } - listenAddr := parsedAddr.Host - if _, _, err := net.SplitHostPort(listenAddr); err != nil { - return "", "", false - } - path := strings.TrimSuffix(parsedAddr.Path, "/") - if !strings.HasPrefix(path, "/") { - path = "/" + path - } - cleanPath := pathlib.Clean(path) - if cleanPath == "." || cleanPath == "/" || cleanPath != path { - return "", "", false - } - if strings.Count(cleanPath, "/") != 1 { - return "", "", false - } - id := strings.TrimPrefix(cleanPath, "/") - id = strings.ToLower(id) - parsedUUID, err := uuid.ParseString(id) - if err != nil || parsedUUID.String() != id { - return "", "", false - } - return listenAddr, "/" + id, true -} - -func newDialerServer(listenAddr string) (*dialerServer, error) { - dialer := &dialerServer{ - pageRoutes: make(map[string]*dialerInstance), - } - dialer.server = &http.Server{ - Addr: listenAddr, - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - pageDialer := dialer.pageRoutes[r.URL.Path] - - if pageDialer != nil && websocket.IsWebSocketUpgrade(r) { - ok := false - for _, protocol := range websocket.Subprotocols(r) { - if protocol == browserDialerSubprotocol { - ok = true - break - } - } - if !ok { - closeConnection(w) - return - } - if conn, err := upgrader.Upgrade(w, r, http.Header{"Sec-WebSocket-Protocol": []string{browserDialerSubprotocol}}); err == nil { - pageDialer.conns <- conn - } else { - errors.LogError(context.Background(), "Browser dialer http upgrade unexpected error: ", err) - } - return - } - - if pageDialer != nil { - w.Header().Set("Access-Control-Allow-Origin", "*") - if _, err := w.Write(pageDialer.page); err != nil { - errors.LogError(context.Background(), "Browser dialer http page write unexpected error: ", err) - } - return - } - - closeConnection(w) - }), - } - return dialer, nil -} - -func (d *dialerServer) start() error { - if d.started { - return nil - } - listener, err := net.Listen("tcp", d.server.Addr) - if err != nil { - return err - } - d.started = true - go func() { - if err := d.server.Serve(listener); err != nil && !stderrors.Is(err, http.ErrServerClosed) { - errors.LogError(context.Background(), "Browser dialer http server unexpected error on ", d.server.Addr, ": ", err) - } - }() - return nil -} - -func closeConnection(w http.ResponseWriter) { - hijacker, ok := w.(http.Hijacker) - if !ok { - return - } - conn, _, err := hijacker.Hijack() - if err != nil { - return - } - conn.Close() -} - -func getDialerByAddress(addr string) (*dialerInstance, error) { - listenAddr, path, ok := parseBrowserDialerAddress(addr) - if !ok { - return nil, errors.New("invalid browser dialer url: ", addr) - } - key := listenAddr + path - if dialer, found := dialersByAddress[key]; found { - return dialer, nil - } - return nil, errors.New("browser dialer is not configured for url: ", addr) -} - -func ensureDialerWithAddress(addr string) (*dialerInstance, error) { - listenAddr, path, ok := parseBrowserDialerAddress(addr) - if !ok { - return nil, errors.New("invalid browser dialer url: ", addr) - } - _, port, err := net.SplitHostPort(listenAddr) - if err != nil { - return nil, errors.New("invalid browser dialer listen address: ", listenAddr) - } - - key := listenAddr + path - if dialer, found := dialersByAddress[key]; found { - return dialer, nil - } - - server, found := serversByListenAddr[listenAddr] - if !found { - for existingAddr := range serversByListenAddr { - _, existingPort, splitErr := net.SplitHostPort(existingAddr) - if splitErr == nil && existingPort == port { - return nil, errors.New("browser dialer cannot use the same port with a different listen address: ", existingAddr, " and ", listenAddr) - } - } - newServer, serverErr := newDialerServer(listenAddr) - if serverErr != nil { - return nil, serverErr - } - server = newServer - serversByListenAddr[listenAddr] = server - } - - dialer := &dialerInstance{ - conns: make(chan *websocket.Conn, 256), - page: bytes.ReplaceAll(webpage, []byte("dialerPath"), []byte(strings.TrimPrefix(path, "/"))), - } - dialersByAddress[key] = dialer - server.pageRoutes[path] = dialer - return dialer, nil -} - func DialWSWithAddress(addr string, uri string, ed []byte) (*websocket.Conn, error) { task := task{ Method: "WS", diff --git a/transport/internet/browser_dialer/manager.go b/transport/internet/browser_dialer/manager.go new file mode 100644 index 000000000000..427c70b73f98 --- /dev/null +++ b/transport/internet/browser_dialer/manager.go @@ -0,0 +1,282 @@ +package browser_dialer + +import ( + "bytes" + "context" + stderrors "errors" + "net" + "net/http" + "net/url" + pathlib "path" + "strings" + "time" + + "github.com/gorilla/websocket" + "github.com/xtls/xray-core/common/errors" + "github.com/xtls/xray-core/common/platform" + "github.com/xtls/xray-core/common/uuid" +) + +var dialersByAddress = map[string]*dialerInstance{} +var serversByListenAddr = map[string]*dialerServer{} +var initialized bool +var pendingURLs map[string]struct{} + +const browserDialerSubprotocol = "browser-dialer" + +var upgrader = &websocket.Upgrader{ + ReadBufferSize: 0, + WriteBufferSize: 0, + HandshakeTimeout: time.Second * 4, + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +func CheckLegacyEnv() error { + envAddress := platform.NewEnvFlag(platform.BrowserDialerAddress).GetValue(func() string { return "" }) + if envAddress == "" { + return nil + } + return errors.PrintRemovedFeatureError("env "+platform.BrowserDialerAddress, "sockopt.dialerProxy with browser://host:port/uuid") +} + +func IsBrowserDialerProxy(raw string) bool { + _, _, ok := parseBrowserDialerAddress(raw) + return ok +} + +func BeginCollectingDialerProxyURLs() error { + if initialized { + return errors.New("browser dialer does not support dynamic add/remove; restart is required after changing configuration") + } + if err := CheckLegacyEnv(); err != nil { + return err + } + pendingURLs = map[string]struct{}{} + return nil +} + +func RegisterDialerProxyURL(raw string) error { + if !IsBrowserDialerProxy(raw) { + return nil + } + if pendingURLs == nil { + return errors.New("browser dialer url collection is not initialized") + } + pendingURLs[raw] = struct{}{} + return nil +} + +func ConfigureCollectedDialerProxyURLs() error { + if initialized { + return errors.New("browser dialer does not support dynamic add/remove; restart is required after changing configuration") + } + if err := CheckLegacyEnv(); err != nil { + return err + } + listenAddrByPort := make(map[string]string, len(pendingURLs)) + for browserDialerURL := range pendingURLs { + listenAddr, _, ok := parseBrowserDialerAddress(browserDialerURL) + if !ok { + return errors.New("invalid browser dialer url: ", browserDialerURL) + } + _, port, err := net.SplitHostPort(listenAddr) + if err != nil { + return errors.New("invalid browser dialer listen address: ", listenAddr) + } + if existingAddr, found := listenAddrByPort[port]; found && existingAddr != listenAddr { + return errors.New("browser dialer cannot use the same port with a different listen address: ", existingAddr, " and ", listenAddr) + } + listenAddrByPort[port] = listenAddr + } + for existingAddr := range serversByListenAddr { + _, existingPort, splitErr := net.SplitHostPort(existingAddr) + if splitErr != nil { + continue + } + if newAddr, found := listenAddrByPort[existingPort]; found && newAddr != existingAddr { + return errors.New("browser dialer cannot use the same port with a different listen address: ", existingAddr, " and ", newAddr) + } + } + for browserDialerURL := range pendingURLs { + if _, err := ensureDialerWithAddress(browserDialerURL); err != nil { + return errors.New("failed to initialize browser dialer listener for url ", browserDialerURL).Base(err) + } + } + for listenAddr, server := range serversByListenAddr { + if err := server.start(); err != nil { + return errors.New("failed to start browser dialer listener on ", listenAddr).Base(err) + } + } + initialized = true + return nil +} + +type dialerInstance struct { + conns chan *websocket.Conn + page []byte +} + +type dialerServer struct { + server *http.Server + pageRoutes map[string]*dialerInstance + started bool +} + +func parseBrowserDialerAddress(addr string) (string, string, bool) { + if addr == "" { + return "", "", false + } + + parsedAddr, err := url.Parse(addr) + if err != nil || !strings.EqualFold(parsedAddr.Scheme, "browser") || parsedAddr.Host == "" || parsedAddr.Path == "" || parsedAddr.RawQuery != "" || parsedAddr.Fragment != "" { + return "", "", false + } + listenAddr := parsedAddr.Host + if _, _, err := net.SplitHostPort(listenAddr); err != nil { + return "", "", false + } + path := strings.TrimSuffix(parsedAddr.Path, "/") + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + cleanPath := pathlib.Clean(path) + if cleanPath == "." || cleanPath == "/" || cleanPath != path { + return "", "", false + } + if strings.Count(cleanPath, "/") != 1 { + return "", "", false + } + id := strings.TrimPrefix(cleanPath, "/") + id = strings.ToLower(id) + parsedUUID, err := uuid.ParseString(id) + if err != nil || parsedUUID.String() != id { + return "", "", false + } + return listenAddr, "/" + id, true +} + +func newDialerServer(listenAddr string) (*dialerServer, error) { + dialer := &dialerServer{ + pageRoutes: make(map[string]*dialerInstance), + } + dialer.server = &http.Server{ + Addr: listenAddr, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pageDialer := dialer.pageRoutes[r.URL.Path] + + if pageDialer != nil && websocket.IsWebSocketUpgrade(r) { + ok := false + for _, protocol := range websocket.Subprotocols(r) { + if protocol == browserDialerSubprotocol { + ok = true + break + } + } + if !ok { + closeConnection(w) + return + } + if conn, err := upgrader.Upgrade(w, r, http.Header{"Sec-WebSocket-Protocol": []string{browserDialerSubprotocol}}); err == nil { + pageDialer.conns <- conn + } else { + errors.LogError(context.Background(), "Browser dialer http upgrade unexpected error: ", err) + } + return + } + + if pageDialer != nil { + w.Header().Set("Access-Control-Allow-Origin", "*") + if _, err := w.Write(pageDialer.page); err != nil { + errors.LogError(context.Background(), "Browser dialer http page write unexpected error: ", err) + } + return + } + + closeConnection(w) + }), + } + return dialer, nil +} + +func (d *dialerServer) start() error { + if d.started { + return nil + } + listener, err := net.Listen("tcp", d.server.Addr) + if err != nil { + return err + } + d.started = true + go func() { + if err := d.server.Serve(listener); err != nil && !stderrors.Is(err, http.ErrServerClosed) { + errors.LogError(context.Background(), "Browser dialer http server unexpected error on ", d.server.Addr, ": ", err) + } + }() + return nil +} + +func closeConnection(w http.ResponseWriter) { + hijacker, ok := w.(http.Hijacker) + if !ok { + return + } + conn, _, err := hijacker.Hijack() + if err != nil { + return + } + conn.Close() +} + +func getDialerByAddress(addr string) (*dialerInstance, error) { + listenAddr, path, ok := parseBrowserDialerAddress(addr) + if !ok { + return nil, errors.New("invalid browser dialer url: ", addr) + } + key := listenAddr + path + if dialer, found := dialersByAddress[key]; found { + return dialer, nil + } + return nil, errors.New("browser dialer is not configured for url: ", addr) +} + +func ensureDialerWithAddress(addr string) (*dialerInstance, error) { + listenAddr, path, ok := parseBrowserDialerAddress(addr) + if !ok { + return nil, errors.New("invalid browser dialer url: ", addr) + } + _, port, err := net.SplitHostPort(listenAddr) + if err != nil { + return nil, errors.New("invalid browser dialer listen address: ", listenAddr) + } + + key := listenAddr + path + if dialer, found := dialersByAddress[key]; found { + return dialer, nil + } + + server, found := serversByListenAddr[listenAddr] + if !found { + for existingAddr := range serversByListenAddr { + _, existingPort, splitErr := net.SplitHostPort(existingAddr) + if splitErr == nil && existingPort == port { + return nil, errors.New("browser dialer cannot use the same port with a different listen address: ", existingAddr, " and ", listenAddr) + } + } + newServer, serverErr := newDialerServer(listenAddr) + if serverErr != nil { + return nil, serverErr + } + server = newServer + serversByListenAddr[listenAddr] = server + } + + dialer := &dialerInstance{ + conns: make(chan *websocket.Conn, 256), + page: bytes.ReplaceAll(webpage, []byte("dialerPath"), []byte(strings.TrimPrefix(path, "/"))), + } + dialersByAddress[key] = dialer + server.pageRoutes[path] = dialer + return dialer, nil +} From c4233173058f07aebbc50c221a64a027dad75b79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:50:15 +0000 Subject: [PATCH 39/44] fix: split browser dialer configure and listener startup phases Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/bd037532-6fba-48d4-bf72-77fbb3bf8fc4 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- infra/conf/xray.go | 3 +++ transport/internet/browser_dialer/manager.go | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/infra/conf/xray.go b/infra/conf/xray.go index cb9367defe89..86ba0517a600 100644 --- a/infra/conf/xray.go +++ b/infra/conf/xray.go @@ -632,6 +632,9 @@ func (c *Config) Build() (*core.Config, error) { if err := browser_dialer.ConfigureCollectedDialerProxyURLs(); err != nil { return nil, errors.New("failed to configure browser dialer").Base(err) } + if err := browser_dialer.StartCollectedDialerProxyURLs(); err != nil { + return nil, errors.New("failed to start browser dialer listeners").Base(err) + } return config, nil } diff --git a/transport/internet/browser_dialer/manager.go b/transport/internet/browser_dialer/manager.go index 427c70b73f98..0519cabbd951 100644 --- a/transport/internet/browser_dialer/manager.go +++ b/transport/internet/browser_dialer/manager.go @@ -104,6 +104,13 @@ func ConfigureCollectedDialerProxyURLs() error { return errors.New("failed to initialize browser dialer listener for url ", browserDialerURL).Base(err) } } + return nil +} + +func StartCollectedDialerProxyURLs() error { + if initialized { + return errors.New("browser dialer does not support dynamic add/remove; restart is required after changing configuration") + } for listenAddr, server := range serversByListenAddr { if err := server.start(); err != nil { return errors.New("failed to start browser dialer listener on ", listenAddr).Base(err) From ccac265911ce7307d34585cc2eabe3c327398fb2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:52:48 +0000 Subject: [PATCH 40/44] chore: make browser dialer listener startup idempotent Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/bd037532-6fba-48d4-bf72-77fbb3bf8fc4 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/manager.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/transport/internet/browser_dialer/manager.go b/transport/internet/browser_dialer/manager.go index 0519cabbd951..02d148156c4d 100644 --- a/transport/internet/browser_dialer/manager.go +++ b/transport/internet/browser_dialer/manager.go @@ -107,9 +107,11 @@ func ConfigureCollectedDialerProxyURLs() error { return nil } +// StartCollectedDialerProxyURLs starts listeners for browser dialer URLs +// prepared by ConfigureCollectedDialerProxyURLs. func StartCollectedDialerProxyURLs() error { if initialized { - return errors.New("browser dialer does not support dynamic add/remove; restart is required after changing configuration") + return nil } for listenAddr, server := range serversByListenAddr { if err := server.start(); err != nil { From 3a826b7ecefe7cfc53e6cb6582a8442009ddc9d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:55:25 +0000 Subject: [PATCH 41/44] docs: clarify idempotent browser dialer listener startup Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/bd037532-6fba-48d4-bf72-77fbb3bf8fc4 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- transport/internet/browser_dialer/manager.go | 1 + 1 file changed, 1 insertion(+) diff --git a/transport/internet/browser_dialer/manager.go b/transport/internet/browser_dialer/manager.go index 02d148156c4d..1000d77a767a 100644 --- a/transport/internet/browser_dialer/manager.go +++ b/transport/internet/browser_dialer/manager.go @@ -109,6 +109,7 @@ func ConfigureCollectedDialerProxyURLs() error { // StartCollectedDialerProxyURLs starts listeners for browser dialer URLs // prepared by ConfigureCollectedDialerProxyURLs. +// Subsequent calls after successful initialization return nil. func StartCollectedDialerProxyURLs() error { if initialized { return nil From 8043924af5cd10b10e7fb5adbae1f064a4f8cd9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:32:47 +0000 Subject: [PATCH 42/44] Move browser dialer start stop to instance lifecycle Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/b8c640ab-f93c-4609-8e34-a8e14e2be9e7 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- core/xray.go | 9 ++++++++ infra/conf/xray.go | 3 --- transport/internet/browser_dialer/manager.go | 22 ++++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/core/xray.go b/core/xray.go index 58135c96b800..1fb3bbab5e3c 100644 --- a/core/xray.go +++ b/core/xray.go @@ -18,6 +18,7 @@ import ( "github.com/xtls/xray-core/features/routing" "github.com/xtls/xray-core/features/stats" "github.com/xtls/xray-core/transport/internet" + "github.com/xtls/xray-core/transport/internet/browser_dialer" ) // Server is an instance of Xray. At any time, there must be at most one Server instance running. @@ -262,6 +263,9 @@ func (s *Instance) Close() error { s.running = false var errs []interface{} + if err := browser_dialer.StopCollectedDialerProxyURLs(); err != nil { + errs = append(errs, err) + } for _, f := range s.features { if err := f.Close(); err != nil { errs = append(errs, err) @@ -385,9 +389,14 @@ func (s *Instance) Start() error { s.statusLock.Lock() defer s.statusLock.Unlock() + if err := browser_dialer.StartCollectedDialerProxyURLs(); err != nil { + return err + } s.running = true for _, f := range s.features { if err := f.Start(); err != nil { + s.running = false + _ = browser_dialer.StopCollectedDialerProxyURLs() return err } } diff --git a/infra/conf/xray.go b/infra/conf/xray.go index 86ba0517a600..cb9367defe89 100644 --- a/infra/conf/xray.go +++ b/infra/conf/xray.go @@ -632,9 +632,6 @@ func (c *Config) Build() (*core.Config, error) { if err := browser_dialer.ConfigureCollectedDialerProxyURLs(); err != nil { return nil, errors.New("failed to configure browser dialer").Base(err) } - if err := browser_dialer.StartCollectedDialerProxyURLs(); err != nil { - return nil, errors.New("failed to start browser dialer listeners").Base(err) - } return config, nil } diff --git a/transport/internet/browser_dialer/manager.go b/transport/internet/browser_dialer/manager.go index 1000d77a767a..9ecd89ae671b 100644 --- a/transport/internet/browser_dialer/manager.go +++ b/transport/internet/browser_dialer/manager.go @@ -123,6 +123,20 @@ func StartCollectedDialerProxyURLs() error { return nil } +func StopCollectedDialerProxyURLs() error { + var stopErr error + for listenAddr, server := range serversByListenAddr { + if err := server.stop(); err != nil && stopErr == nil { + stopErr = errors.New("failed to stop browser dialer listener on ", listenAddr).Base(err) + } + } + dialersByAddress = map[string]*dialerInstance{} + serversByListenAddr = map[string]*dialerServer{} + pendingURLs = nil + initialized = false + return stopErr +} + type dialerInstance struct { conns chan *websocket.Conn page []byte @@ -227,6 +241,14 @@ func (d *dialerServer) start() error { return nil } +func (d *dialerServer) stop() error { + if !d.started { + return nil + } + d.started = false + return d.server.Close() +} + func closeConnection(w http.ResponseWriter) { hijacker, ok := w.(http.Hijacker) if !ok { From 41bc47b1ecde0e0eb1db7f0d5afe21d37dcd9033 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:36:11 +0000 Subject: [PATCH 43/44] Handle browser dialer cleanup errors on instance lifecycle Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/b8c640ab-f93c-4609-8e34-a8e14e2be9e7 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- core/xray.go | 4 +++- transport/internet/browser_dialer/manager.go | 12 ++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/core/xray.go b/core/xray.go index 1fb3bbab5e3c..5a13ecdf798d 100644 --- a/core/xray.go +++ b/core/xray.go @@ -396,7 +396,9 @@ func (s *Instance) Start() error { for _, f := range s.features { if err := f.Start(); err != nil { s.running = false - _ = browser_dialer.StopCollectedDialerProxyURLs() + if stopErr := browser_dialer.StopCollectedDialerProxyURLs(); stopErr != nil { + return errors.New("failed to clean up browser dialer after startup failure").Base(errors.New(serial.Concat(err, "; ", stopErr))) + } return err } } diff --git a/transport/internet/browser_dialer/manager.go b/transport/internet/browser_dialer/manager.go index 9ecd89ae671b..1e47be3e4356 100644 --- a/transport/internet/browser_dialer/manager.go +++ b/transport/internet/browser_dialer/manager.go @@ -14,6 +14,7 @@ import ( "github.com/gorilla/websocket" "github.com/xtls/xray-core/common/errors" "github.com/xtls/xray-core/common/platform" + "github.com/xtls/xray-core/common/serial" "github.com/xtls/xray-core/common/uuid" ) @@ -124,17 +125,20 @@ func StartCollectedDialerProxyURLs() error { } func StopCollectedDialerProxyURLs() error { - var stopErr error + var stopErrs []string for listenAddr, server := range serversByListenAddr { - if err := server.stop(); err != nil && stopErr == nil { - stopErr = errors.New("failed to stop browser dialer listener on ", listenAddr).Base(err) + if err := server.stop(); err != nil { + stopErrs = append(stopErrs, serial.Concat("failed to stop browser dialer listener on ", listenAddr, ": ", err)) } } dialersByAddress = map[string]*dialerInstance{} serversByListenAddr = map[string]*dialerServer{} pendingURLs = nil initialized = false - return stopErr + if len(stopErrs) > 0 { + return errors.New(strings.Join(stopErrs, "; ")) + } + return nil } type dialerInstance struct { From e088cc4a25853c92fe4308b542204be9c6a0fccd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:38:42 +0000 Subject: [PATCH 44/44] Preserve startup error when browser dialer cleanup fails Agent-Logs-Url: https://github.com/XTLS/Xray-core/sessions/b8c640ab-f93c-4609-8e34-a8e14e2be9e7 Co-authored-by: RPRX <63339210+RPRX@users.noreply.github.com> --- core/xray.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/xray.go b/core/xray.go index 5a13ecdf798d..93b3fa3a36e4 100644 --- a/core/xray.go +++ b/core/xray.go @@ -397,7 +397,7 @@ func (s *Instance) Start() error { if err := f.Start(); err != nil { s.running = false if stopErr := browser_dialer.StopCollectedDialerProxyURLs(); stopErr != nil { - return errors.New("failed to clean up browser dialer after startup failure").Base(errors.New(serial.Concat(err, "; ", stopErr))) + return errors.New("browser dialer cleanup after startup failure also failed: ", stopErr).Base(err) } return err }