diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a9d8682ad6c4..9efeede77f32 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,6 +9,8 @@ body: options: - label: I confirm that I have read the documentation, understand the meaning of all the configuration items I wrote, and did not pile up seemingly useful options or default values. required: true + - label: I provided the complete config and logs, rather than just providing the truncated parts based on my own judgment. + required: true - label: I searched issues and did not find any similar issues. required: true - type: input @@ -40,7 +42,7 @@ body: Don't just paste a big exported config file here. Eliminate useless inbound/outbound, rules, options, this can help determine the problem, if you really want to get help. ### For logs - Please set the log level to debug first. + Please set the log level to debug and dnsLog to true first. Restart Xray-core, then operate according to the reproduction method, try to reduce the irrelevant part in the log. Remember to delete parts with personal information (such as UUID and IP). Provide the log of Xray-core, not the log output by the panel or other things. diff --git a/.github/ISSUE_TEMPLATE/bug_report_zh.yml b/.github/ISSUE_TEMPLATE/bug_report_zh.yml index 0e0862820e81..2fd33c566bad 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_zh.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_zh.yml @@ -9,6 +9,8 @@ body: options: - label: 我保证阅读了文档,了解所有我编写的配置文件项的含义,而不是大量堆砌看似有用的选项或默认值。 required: true + - label: 我提供了完整的配置文件和日志,而不是出于自己的判断只给出截取的部分。 + required: true - label: 我搜索了issues,没有发现已提出的类似问题。 required: true - type: input @@ -40,7 +42,7 @@ body: 不要直接在这里黏贴一大段导出的 config 文件。去掉无用的出入站、规则、选项,这可以帮助确定问题,如果你真的想得到帮助。 ### 对于日志 - 请先将日志等级设置为 debug. + 请先将日志等级设置为 debug, dnsLog 设置为true. 重启 Xray-core ,再按复现方式操作,尽量减少日志中的无关部分。 记得删除有关个人信息(如UUID与IP)的部分。 提供 Xray-core 的日志,而不是面板或者别的东西输出的日志。 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e577b6bb3679..793d27488410 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,6 +1,8 @@ name: Build docker image on: + release: + types: [published] push: branches: - main @@ -19,6 +21,7 @@ jobs: images: ghcr.io/${{ github.repository_owner }}/xray-core flavor: latest=true tags: | + type=sha type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} diff --git a/core/core.go b/core/core.go index 0cccfd77c4ff..53f58841e22c 100644 --- a/core/core.go +++ b/core/core.go @@ -21,7 +21,7 @@ import ( var ( Version_x byte = 1 Version_y byte = 8 - Version_z byte = 15 + Version_z byte = 16 ) var ( diff --git a/go.mod b/go.mod index 124d39b9cf4f..3715ec599d1b 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/miekg/dns v1.1.61 github.com/pelletier/go-toml v1.9.5 github.com/pires/go-proxyproto v0.7.0 - github.com/quic-go/quic-go v0.45.0 + github.com/quic-go/quic-go v0.45.1 github.com/refraction-networking/utls v1.6.6 github.com/sagernet/sing v0.4.1 github.com/sagernet/sing-shadowsocks v0.2.6 diff --git a/go.sum b/go.sum index 75e60ce17623..bc4dcd9c6598 100644 --- a/go.sum +++ b/go.sum @@ -110,8 +110,8 @@ github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXP github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/quic-go/quic-go v0.45.0 h1:OHmkQGM37luZITyTSu6ff03HP/2IrwDX1ZFiNEhSFUE= -github.com/quic-go/quic-go v0.45.0/go.mod h1:1dLehS7TIR64+vxGR70GDcatWTOtMX2PUtnKsjbTurI= +github.com/quic-go/quic-go v0.45.1 h1:tPfeYCk+uZHjmDRwHHQmvHRYL2t44ROTujLeFVBmjCA= +github.com/quic-go/quic-go v0.45.1/go.mod h1:1dLehS7TIR64+vxGR70GDcatWTOtMX2PUtnKsjbTurI= github.com/refraction-networking/utls v1.6.6 h1:igFsYBUJPYM8Rno9xUuDoM5GQrVEqY4llzEXOkL43Ig= github.com/refraction-networking/utls v1.6.6/go.mod h1:BC3O4vQzye5hqpmDTWUqi4P5DDhzJfkV1tdqtawQIH0= github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg= diff --git a/transport/internet/splithttp/dialer.go b/transport/internet/splithttp/dialer.go index b763981aaf5e..48a29c9c950e 100644 --- a/transport/internet/splithttp/dialer.go +++ b/transport/internet/splithttp/dialer.go @@ -16,6 +16,7 @@ import ( "github.com/xtls/xray-core/common/buf" "github.com/xtls/xray-core/common/net" "github.com/xtls/xray-core/common/session" + "github.com/xtls/xray-core/common/signal/done" "github.com/xtls/xray-core/common/signal/semaphore" "github.com/xtls/xray-core/common/uuid" "github.com/xtls/xray-core/transport/internet" @@ -44,18 +45,6 @@ var ( globalDialerAccess sync.Mutex ) -func destroyHTTPClient(ctx context.Context, dest net.Destination, streamSettings *internet.MemoryStreamConfig) { - globalDialerAccess.Lock() - defer globalDialerAccess.Unlock() - - if globalDialerMap == nil { - globalDialerMap = make(map[dialerConf]reusedClient) - } - - delete(globalDialerMap, dialerConf{dest, streamSettings}) - -} - func getHTTPClient(ctx context.Context, dest net.Destination, streamSettings *internet.MemoryStreamConfig) reusedClient { globalDialerAccess.Lock() defer globalDialerAccess.Unlock() @@ -69,6 +58,7 @@ func getHTTPClient(ctx context.Context, dest net.Destination, streamSettings *in } tlsConfig := tls.ConfigFromStreamSettings(streamSettings) + isH2 := tlsConfig != nil && !(len(tlsConfig.NextProtocol) == 1 && tlsConfig.NextProtocol[0] == "http/1.1") var gotlsConfig *gotls.Config @@ -77,7 +67,7 @@ func getHTTPClient(ctx context.Context, dest net.Destination, streamSettings *in } dialContext := func(ctxInner context.Context) (net.Conn, error) { - conn, err := internet.DialSystem(ctx, dest, streamSettings.SocketSettings) + conn, err := internet.DialSystem(ctxInner, dest, streamSettings.SocketSettings) if err != nil { return nil, err } @@ -85,7 +75,7 @@ func getHTTPClient(ctx context.Context, dest net.Destination, streamSettings *in if gotlsConfig != nil { if fingerprint := tls.GetFingerprint(tlsConfig.Fingerprint); fingerprint != nil { conn = tls.UClient(conn, gotlsConfig, fingerprint) - if err := conn.(*tls.UConn).HandshakeContext(ctx); err != nil { + if err := conn.(*tls.UConn).HandshakeContext(ctxInner); err != nil { return nil, err } } else { @@ -99,7 +89,7 @@ func getHTTPClient(ctx context.Context, dest net.Destination, streamSettings *in var uploadTransport http.RoundTripper var downloadTransport http.RoundTripper - if tlsConfig != nil { + if isH2 { downloadTransport = &http2.Transport{ DialTLSContext: func(ctxInner context.Context, network string, addr string, cfg *gotls.Config) (net.Conn, error) { return dialContext(ctxInner) @@ -132,7 +122,7 @@ func getHTTPClient(ctx context.Context, dest net.Destination, streamSettings *in upload: &http.Client{ Transport: uploadTransport, }, - isH2: tlsConfig != nil, + isH2: isH2, uploadRawPool: &sync.Pool{}, dialUploadConn: dialContext, } @@ -171,49 +161,73 @@ func Dial(ctx context.Context, dest net.Destination, streamSettings *internet.Me var remoteAddr gonet.Addr var localAddr gonet.Addr + // this is done when the TCP/UDP connection to the server was established, + // and we can unblock the Dial function and print correct net addresses in + // logs + gotConn := done.New() - trace := &httptrace.ClientTrace{ - GotConn: func(connInfo httptrace.GotConnInfo) { - remoteAddr = connInfo.Conn.RemoteAddr() - localAddr = connInfo.Conn.LocalAddr() - }, - } + var downResponse io.ReadCloser + gotDownResponse := done.New() sessionIdUuid := uuid.New() sessionId := sessionIdUuid.String() - req, err := http.NewRequestWithContext( - httptrace.WithClientTrace(ctx, trace), - "GET", - requestURL.String()+"?session="+sessionId, - nil, - ) - if err != nil { - return nil, err - } + go func() { + trace := &httptrace.ClientTrace{ + GotConn: func(connInfo httptrace.GotConnInfo) { + remoteAddr = connInfo.Conn.RemoteAddr() + localAddr = connInfo.Conn.LocalAddr() + gotConn.Close() + }, + } - req.Header = transportConfiguration.GetRequestHeader() - - downResponse, err := httpClient.download.Do(req) - if err != nil { - // workaround for various connection pool related issues, mostly around - // HTTP/1.1. if the http client ever fails to send a request, we simply - // delete it entirely. - // in HTTP/1.1, it was observed that pool connections would immediately - // fail with "context canceled" if the previous http response body was - // not explicitly BOTH drained and closed. at the same time, sometimes - // the draining itself takes forever and causes more problems. - // see also https://github.com/golang/go/issues/60240 - destroyHTTPClient(ctx, dest, streamSettings) - return nil, newError("failed to send download http request, destroying client").Base(err) - } + // in case we hit an error, we want to unblock this part + defer gotConn.Close() - if downResponse.StatusCode != 200 { - downResponse.Body.Close() - return nil, newError("invalid status code on download:", downResponse.Status) - } + req, err := http.NewRequestWithContext( + httptrace.WithClientTrace(context.WithoutCancel(ctx), trace), + "GET", + requestURL.String()+sessionId, + nil, + ) + if err != nil { + newError("failed to construct download http request").Base(err).WriteToLog() + gotDownResponse.Close() + return + } + + req.Header = transportConfiguration.GetRequestHeader() + + response, err := httpClient.download.Do(req) + gotConn.Close() + if err != nil { + newError("failed to send download http request").Base(err).WriteToLog() + gotDownResponse.Close() + return + } + + if response.StatusCode != 200 { + response.Body.Close() + newError("invalid status code on download:", response.Status).WriteToLog() + gotDownResponse.Close() + return + } + + // skip "ok" response + trashHeader := []byte{0, 0} + _, err = io.ReadFull(response.Body, trashHeader) + if err != nil { + response.Body.Close() + newError("failed to read initial response").Base(err).WriteToLog() + gotDownResponse.Close() + return + } - uploadUrl := requestURL.String() + "?session=" + sessionId + "&seq=" + downResponse = response.Body + gotDownResponse.Close() + }() + + uploadUrl := requestURL.String() + sessionId + "/" uploadPipeReader, uploadPipeWriter := pipe.New(pipe.WithSizeLimit(maxUploadSize)) @@ -263,10 +277,10 @@ func Dial(ctx context.Context, dest net.Destination, streamSettings *internet.Me } else { var err error var uploadConn any - for _ = range 5 { + for i := 0; i < 5; i++ { uploadConn = httpClient.uploadRawPool.Get() if uploadConn == nil { - uploadConn, err = httpClient.dialUploadConn(ctx) + uploadConn, err = httpClient.dialUploadConn(context.WithoutCancel(ctx)) if err != nil { newError("failed to connect upload").Base(err).WriteToLog() uploadPipeReader.Interrupt() @@ -293,21 +307,27 @@ func Dial(ctx context.Context, dest net.Destination, streamSettings *internet.Me } }() - // skip "ok" response - trashHeader := []byte{0, 0} - _, err = io.ReadFull(downResponse.Body, trashHeader) - if err != nil { - downResponse.Body.Close() - return nil, newError("failed to read initial response") - } + // we want to block Dial until we know the remote address of the server, + // for logging purposes + <-gotConn.Wait() // necessary in order to send larger chunks in upload bufferedUploadPipeWriter := buf.NewBufferedWriter(uploadPipeWriter) bufferedUploadPipeWriter.SetBuffered(false) + lazyDownload := &LazyReader{ + CreateReader: func() (io.ReadCloser, error) { + <-gotDownResponse.Wait() + if downResponse == nil { + return nil, newError("downResponse failed") + } + return downResponse, nil + }, + } + conn := splitConn{ writer: bufferedUploadPipeWriter, - reader: downResponse.Body, + reader: lazyDownload, remoteAddr: remoteAddr, localAddr: localAddr, } diff --git a/transport/internet/splithttp/hub.go b/transport/internet/splithttp/hub.go index 1883bf237b39..5181b19bcf73 100644 --- a/transport/internet/splithttp/hub.go +++ b/transport/internet/splithttp/hub.go @@ -7,6 +7,7 @@ import ( gonet "net" "net/http" "strconv" + "strings" "sync" "time" @@ -18,6 +19,8 @@ import ( "github.com/xtls/xray-core/transport/internet" "github.com/xtls/xray-core/transport/internet/stat" v2tls "github.com/xtls/xray-core/transport/internet/tls" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" ) type requestHandler struct { @@ -28,20 +31,65 @@ type requestHandler struct { localAddr gonet.TCPAddr } +type httpSession struct { + uploadQueue *UploadQueue + // for as long as the GET request is not opened by the client, this will be + // open ("undone"), and the session may be expired within a certain TTL. + // after the client connects, this becomes "done" and the session lives as + // long as the GET request. + isFullyConnected *done.Instance +} + +func (h *requestHandler) maybeReapSession(isFullyConnected *done.Instance, sessionId string) { + shouldReap := done.New() + go func() { + time.Sleep(30 * time.Second) + shouldReap.Close() + }() + + select { + case <-isFullyConnected.Wait(): + return + case <-shouldReap.Wait(): + h.sessions.Delete(sessionId) + } +} + +func (h *requestHandler) upsertSession(sessionId string) *httpSession { + currentSessionAny, ok := h.sessions.Load(sessionId) + if ok { + return currentSessionAny.(*httpSession) + } + + s := &httpSession{ + uploadQueue: NewUploadQueue(int(2 * h.ln.config.GetNormalizedMaxConcurrentUploads())), + isFullyConnected: done.New(), + } + + h.sessions.Store(sessionId, s) + go h.maybeReapSession(s.isFullyConnected, sessionId) + return s +} + func (h *requestHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { if len(h.host) > 0 && request.Host != h.host { newError("failed to validate host, request:", request.Host, ", config:", h.host).WriteToLog() writer.WriteHeader(http.StatusNotFound) return } - if request.URL.Path != h.path { + + if !strings.HasPrefix(request.URL.Path, h.path) { newError("failed to validate path, request:", request.URL.Path, ", config:", h.path).WriteToLog() writer.WriteHeader(http.StatusNotFound) return } - queryString := request.URL.Query() - sessionId := queryString.Get("session") + sessionId := "" + subpath := strings.Split(request.URL.Path[len(h.path):], "/") + if len(subpath) > 0 { + sessionId = subpath[0] + } + if sessionId == "" { newError("no sessionid on request:", request.URL.Path).WriteToLog() writer.WriteHeader(http.StatusBadRequest) @@ -60,15 +108,14 @@ func (h *requestHandler) ServeHTTP(writer http.ResponseWriter, request *http.Req } } + currentSession := h.upsertSession(sessionId) + if request.Method == "POST" { - uploadQueue, ok := h.sessions.Load(sessionId) - if !ok { - newError("sessionid does not exist").WriteToLog() - writer.WriteHeader(http.StatusBadRequest) - return + seq := "" + if len(subpath) > 1 { + seq = subpath[1] } - seq := queryString.Get("seq") if seq == "" { newError("no seq on request:", request.URL.Path).WriteToLog() writer.WriteHeader(http.StatusBadRequest) @@ -89,7 +136,7 @@ func (h *requestHandler) ServeHTTP(writer http.ResponseWriter, request *http.Req return } - err = uploadQueue.(*UploadQueue).Push(Packet{ + err = currentSession.uploadQueue.Push(Packet{ Payload: payload, Seq: seqInt, }) @@ -107,10 +154,9 @@ func (h *requestHandler) ServeHTTP(writer http.ResponseWriter, request *http.Req panic("expected http.ResponseWriter to be an http.Flusher") } - uploadQueue := NewUploadQueue(int(2 * h.ln.config.GetNormalizedMaxConcurrentUploads())) - - h.sessions.Store(sessionId, uploadQueue) - // the connection is finished, clean up map + // after GET is done, the connection is finished. disable automatic + // session reaping, and handle it in defer + currentSession.isFullyConnected.Close() defer h.sessions.Delete(sessionId) // magic header instructs nginx + apache to not buffer response body @@ -130,7 +176,7 @@ func (h *requestHandler) ServeHTTP(writer http.ResponseWriter, request *http.Req downloadDone: downloadDone, responseFlusher: responseFlusher, }, - reader: uploadQueue, + reader: currentSession.uploadQueue, remoteAddr: remoteAddr, } @@ -224,16 +270,21 @@ func ListenSH(ctx context.Context, address net.Address, port net.Port, streamSet } } + handler := &requestHandler{ + host: shSettings.Host, + path: shSettings.GetNormalizedPath(), + ln: l, + sessions: sync.Map{}, + localAddr: localAddr, + } + + // h2cHandler can handle both plaintext HTTP/1.1 and h2c + h2cHandler := h2c.NewHandler(handler, &http2.Server{}) + l.listener = listener l.server = http.Server{ - Handler: &requestHandler{ - host: shSettings.Host, - path: shSettings.GetNormalizedPath(), - ln: l, - sessions: sync.Map{}, - localAddr: localAddr, - }, + Handler: h2cHandler, ReadHeaderTimeout: time.Second * 4, MaxHeaderBytes: 8192, } diff --git a/transport/internet/splithttp/lazy_reader.go b/transport/internet/splithttp/lazy_reader.go new file mode 100644 index 000000000000..5ae4ed558dc4 --- /dev/null +++ b/transport/internet/splithttp/lazy_reader.go @@ -0,0 +1,57 @@ +package splithttp + +import ( + "io" + "sync" +) + +type LazyReader struct { + readerSync sync.Mutex + CreateReader func() (io.ReadCloser, error) + reader io.ReadCloser + readerError error +} + +func (r *LazyReader) getReader() (io.ReadCloser, error) { + r.readerSync.Lock() + defer r.readerSync.Unlock() + if r.reader != nil { + return r.reader, nil + } + + if r.readerError != nil { + return nil, r.readerError + } + + reader, err := r.CreateReader() + if err != nil { + r.readerError = err + return nil, err + } + + r.reader = reader + return reader, nil +} + +func (r *LazyReader) Read(b []byte) (int, error) { + reader, err := r.getReader() + if err != nil { + return 0, err + } + n, err := reader.Read(b) + return n, err +} + +func (r *LazyReader) Close() error { + r.readerSync.Lock() + defer r.readerSync.Unlock() + + var err error + if r.reader != nil { + err = r.reader.Close() + r.reader = nil + r.readerError = newError("closed reader") + } + + return err +} diff --git a/transport/internet/splithttp/splithttp_test.go b/transport/internet/splithttp/splithttp_test.go index 3d3d387c9541..52ced9a0a76e 100644 --- a/transport/internet/splithttp/splithttp_test.go +++ b/transport/internet/splithttp/splithttp_test.go @@ -2,7 +2,10 @@ package splithttp_test import ( "context" + gotls "crypto/tls" "fmt" + gonet "net" + "net/http" "runtime" "testing" "time" @@ -15,6 +18,7 @@ import ( . "github.com/xtls/xray-core/transport/internet/splithttp" "github.com/xtls/xray-core/transport/internet/stat" "github.com/xtls/xray-core/transport/internet/tls" + "golang.org/x/net/http2" ) func Test_listenSHAndDial(t *testing.T) { @@ -152,3 +156,50 @@ func Test_listenSHAndDial_TLS(t *testing.T) { t.Error("end: ", end, " start: ", start) } } + +func Test_listenSHAndDial_H2C(t *testing.T) { + if runtime.GOARCH == "arm64" { + return + } + + listenPort := tcp.PickPort() + + streamSettings := &internet.MemoryStreamConfig{ + ProtocolName: "splithttp", + ProtocolSettings: &Config{ + Path: "shs", + }, + } + listen, err := ListenSH(context.Background(), net.LocalHostIP, listenPort, streamSettings, func(conn stat.Connection) { + go func() { + _ = conn.Close() + }() + }) + common.Must(err) + defer listen.Close() + + client := http.Client{ + Transport: &http2.Transport{ + // So http2.Transport doesn't complain the URL scheme isn't 'https' + AllowHTTP: true, + // even with AllowHTTP, http2.Transport will attempt to establish + // the connection using DialTLSContext. Disable TLS with custom + // dial context. + DialTLSContext: func(ctx context.Context, network, addr string, cfg *gotls.Config) (gonet.Conn, error) { + var d gonet.Dialer + return d.DialContext(ctx, network, addr) + }, + }, + } + + resp, err := client.Get("http://" + net.LocalHostIP.String() + ":" + listenPort.String()) + common.Must(err) + + if resp.StatusCode != 404 { + t.Error("Expected 404 but got:", resp.StatusCode) + } + + if resp.ProtoMajor != 2 { + t.Error("Expected h2 but got:", resp.ProtoMajor) + } +} diff --git a/transport/internet/websocket/dialer.html b/transport/internet/websocket/dialer.html index 7831225c41b8..39b144411044 100644 --- a/transport/internet/websocket/dialer.html +++ b/transport/internet/websocket/dialer.html @@ -14,6 +14,9 @@ count += 1 console.log("Prepare", url) var ws = new WebSocket(url) + // arraybuffer is significantly faster in chrome than default + // blob, tested with chrome 123 + ws.binaryType = "arraybuffer"; var wss = undefined var first = true ws.onmessage = function (event) { @@ -23,6 +26,7 @@ var arr = event.data.split(" ") console.log("Dial", arr[0], arr[1]) wss = new WebSocket(arr[0], arr[1]) + wss.binaryType = "arraybuffer"; var opened = false wss.onopen = function (event) { opened = true