mirror of https://github.com/XTLS/Xray-core
Add SplitHTTP Browser Dialer support (#3484)
parent
308f0c64c3
commit
c8f6ba9ff0
@ -0,0 +1,121 @@
|
|||||||
|
package browser_dialer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"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
|
||||||
|
var webpage []byte
|
||||||
|
|
||||||
|
var conns chan *websocket.Conn
|
||||||
|
|
||||||
|
var upgrader = &websocket.Upgrader{
|
||||||
|
ReadBufferSize: 0,
|
||||||
|
WriteBufferSize: 0,
|
||||||
|
HandshakeTimeout: time.Second * 4,
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
addr := platform.NewEnvFlag(platform.BrowserDialerAddress).GetValue(func() string { return "" })
|
||||||
|
if addr != "" {
|
||||||
|
token := uuid.New()
|
||||||
|
csrfToken := token.String()
|
||||||
|
webpage = bytes.ReplaceAll(webpage, []byte("csrfToken"), []byte(csrfToken))
|
||||||
|
conns = make(chan *websocket.Conn, 256)
|
||||||
|
go http.ListenAndServe(addr, 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.Write(webpage)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HasBrowserDialer() bool {
|
||||||
|
return conns != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DialWS(uri string, ed []byte) (*websocket.Conn, error) {
|
||||||
|
data := []byte("WS " + uri)
|
||||||
|
if ed != nil {
|
||||||
|
data = append(data, " "+base64.RawURLEncoding.EncodeToString(ed)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dialRaw(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DialGet(uri string) (*websocket.Conn, error) {
|
||||||
|
data := []byte("GET " + uri)
|
||||||
|
return dialRaw(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DialPost(uri string, payload []byte) error {
|
||||||
|
data := []byte("POST " + uri)
|
||||||
|
conn, err := dialRaw(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = conn.WriteMessage(websocket.BinaryMessage, payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = CheckOK(conn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialRaw(data []byte) (*websocket.Conn, error) {
|
||||||
|
var conn *websocket.Conn
|
||||||
|
for {
|
||||||
|
conn = <-conns
|
||||||
|
if conn.WriteMessage(websocket.TextMessage, data) != nil {
|
||||||
|
conn.Close()
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := CheckOK(conn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckOK(conn *websocket.Conn) error {
|
||||||
|
if _, p, err := conn.ReadMessage(); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return err
|
||||||
|
} else if s := string(p); s != "ok" {
|
||||||
|
conn.Close()
|
||||||
|
return errors.New(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
@ -0,0 +1,136 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Browser Dialer</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
// Copyright (c) 2021 XRAY. Mozilla Public License 2.0.
|
||||||
|
var url = "ws://" + window.location.host + "/websocket?token=csrfToken";
|
||||||
|
var clientIdleCount = 0;
|
||||||
|
var upstreamGetCount = 0;
|
||||||
|
var upstreamWsCount = 0;
|
||||||
|
var upstreamPostCount = 0;
|
||||||
|
setInterval(check, 1000);
|
||||||
|
function check() {
|
||||||
|
if (clientIdleCount > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clientIdleCount += 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";
|
||||||
|
ws.onmessage = function (event) {
|
||||||
|
clientIdleCount -= 1;
|
||||||
|
let [method, url, protocol] = event.data.split(" ");
|
||||||
|
if (method == "WS") {
|
||||||
|
upstreamWsCount += 1;
|
||||||
|
console.log("Dial WS", url, protocol);
|
||||||
|
const wss = new WebSocket(url, protocol);
|
||||||
|
wss.binaryType = "arraybuffer";
|
||||||
|
var opened = false;
|
||||||
|
ws.onmessage = function (event) {
|
||||||
|
wss.send(event.data)
|
||||||
|
}
|
||||||
|
wss.onopen = function (event) {
|
||||||
|
opened = true;
|
||||||
|
ws.send("ok")
|
||||||
|
}
|
||||||
|
wss.onmessage = function (event) {
|
||||||
|
ws.send(event.data)
|
||||||
|
}
|
||||||
|
wss.onclose = function (event) {
|
||||||
|
upstreamWsCount -= 1;
|
||||||
|
console.log("Dial WS DONE, remaining: ", upstreamWsCount);
|
||||||
|
ws.close()
|
||||||
|
}
|
||||||
|
wss.onerror = function (event) {
|
||||||
|
!opened && ws.send("fail")
|
||||||
|
wss.close()
|
||||||
|
}
|
||||||
|
ws.onclose = function (event) {
|
||||||
|
wss.close()
|
||||||
|
}
|
||||||
|
} else if (method == "GET") {
|
||||||
|
(async () => {
|
||||||
|
console.log("Dial GET", url);
|
||||||
|
ws.send("ok");
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
/*
|
||||||
|
Aborting a streaming response in JavaScript
|
||||||
|
requires two levers to be pulled:
|
||||||
|
|
||||||
|
First, the streaming read itself has to be cancelled using
|
||||||
|
reader.cancel(), only then controller.abort() will actually work.
|
||||||
|
|
||||||
|
If controller.abort() alone is called while a
|
||||||
|
reader.read() is ongoing, it will block until the server closes the
|
||||||
|
response, the page is refreshed or the network connection is lost.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let reader = null;
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
try {
|
||||||
|
reader && reader.cancel();
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
controller.abort();
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
upstreamGetCount += 1;
|
||||||
|
const response = await fetch(url, {signal: controller.signal});
|
||||||
|
|
||||||
|
const body = await response.body;
|
||||||
|
reader = body.getReader();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
ws.send(value);
|
||||||
|
if (done) break;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
upstreamGetCount -= 1;
|
||||||
|
console.log("Dial GET DONE, remaining: ", upstreamGetCount);
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
} else if (method == "POST") {
|
||||||
|
upstreamPostCount += 1;
|
||||||
|
console.log("Dial POST", url);
|
||||||
|
ws.send("ok");
|
||||||
|
ws.onmessage = async (event) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
url,
|
||||||
|
{method: "POST", body: event.data}
|
||||||
|
);
|
||||||
|
if (response.ok) {
|
||||||
|
ws.send("ok");
|
||||||
|
} else {
|
||||||
|
console.error("bad status code");
|
||||||
|
ws.send("fail");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
upstreamPostCount -= 1;
|
||||||
|
console.log("Dial POST DONE, remaining: ", upstreamPostCount);
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
check()
|
||||||
|
}
|
||||||
|
ws.onerror = function (event) {
|
||||||
|
ws.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -0,0 +1,39 @@
|
|||||||
|
package splithttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
gonet "net"
|
||||||
|
|
||||||
|
"github.com/xtls/xray-core/transport/internet/browser_dialer"
|
||||||
|
"github.com/xtls/xray-core/transport/internet/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// implements splithttp.DialerClient in terms of browser dialer
|
||||||
|
// has no fields because everything is global state :O)
|
||||||
|
type BrowserDialerClient struct{}
|
||||||
|
|
||||||
|
func (c *BrowserDialerClient) OpenDownload(ctx context.Context, baseURL string) (io.ReadCloser, gonet.Addr, gonet.Addr, error) {
|
||||||
|
conn, err := browser_dialer.DialGet(baseURL)
|
||||||
|
dummyAddr := &gonet.IPAddr{}
|
||||||
|
if err != nil {
|
||||||
|
return nil, dummyAddr, dummyAddr, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return websocket.NewConnection(conn, dummyAddr, nil), conn.RemoteAddr(), conn.LocalAddr(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *BrowserDialerClient) SendUploadRequest(ctx context.Context, url string, payload io.ReadWriteCloser, contentLength int64) error {
|
||||||
|
bytes, err := ioutil.ReadAll(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = browser_dialer.DialPost(url, bytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
@ -0,0 +1,169 @@
|
|||||||
|
package splithttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
gonet "net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptrace"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/xtls/xray-core/common"
|
||||||
|
"github.com/xtls/xray-core/common/errors"
|
||||||
|
"github.com/xtls/xray-core/common/net"
|
||||||
|
"github.com/xtls/xray-core/common/signal/done"
|
||||||
|
)
|
||||||
|
|
||||||
|
// interface to abstract between use of browser dialer, vs net/http
|
||||||
|
type DialerClient interface {
|
||||||
|
// (ctx, baseURL, payload) -> err
|
||||||
|
// baseURL already contains sessionId and seq
|
||||||
|
SendUploadRequest(context.Context, string, io.ReadWriteCloser, int64) error
|
||||||
|
|
||||||
|
// (ctx, baseURL) -> (downloadReader, remoteAddr, localAddr)
|
||||||
|
// baseURL already contains sessionId
|
||||||
|
OpenDownload(context.Context, string) (io.ReadCloser, net.Addr, net.Addr, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// implements splithttp.DialerClient in terms of direct network connections
|
||||||
|
type DefaultDialerClient struct {
|
||||||
|
transportConfig *Config
|
||||||
|
download *http.Client
|
||||||
|
upload *http.Client
|
||||||
|
isH2 bool
|
||||||
|
// pool of net.Conn, created using dialUploadConn
|
||||||
|
uploadRawPool *sync.Pool
|
||||||
|
dialUploadConn func(ctxInner context.Context) (net.Conn, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DefaultDialerClient) OpenDownload(ctx context.Context, baseURL string) (io.ReadCloser, gonet.Addr, gonet.Addr, error) {
|
||||||
|
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()
|
||||||
|
|
||||||
|
var downResponse io.ReadCloser
|
||||||
|
gotDownResponse := done.New()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
trace := &httptrace.ClientTrace{
|
||||||
|
GotConn: func(connInfo httptrace.GotConnInfo) {
|
||||||
|
remoteAddr = connInfo.Conn.RemoteAddr()
|
||||||
|
localAddr = connInfo.Conn.LocalAddr()
|
||||||
|
gotConn.Close()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// in case we hit an error, we want to unblock this part
|
||||||
|
defer gotConn.Close()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(
|
||||||
|
httptrace.WithClientTrace(ctx, trace),
|
||||||
|
"GET",
|
||||||
|
baseURL,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
errors.LogInfoInner(ctx, err, "failed to construct download http request")
|
||||||
|
gotDownResponse.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header = c.transportConfig.GetRequestHeader()
|
||||||
|
|
||||||
|
response, err := c.download.Do(req)
|
||||||
|
gotConn.Close()
|
||||||
|
if err != nil {
|
||||||
|
errors.LogInfoInner(ctx, err, "failed to send download http request")
|
||||||
|
gotDownResponse.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.StatusCode != 200 {
|
||||||
|
response.Body.Close()
|
||||||
|
errors.LogInfo(ctx, "invalid status code on download:", response.Status)
|
||||||
|
gotDownResponse.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
downResponse = response.Body
|
||||||
|
gotDownResponse.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// we want to block Dial until we know the remote address of the server,
|
||||||
|
// for logging purposes
|
||||||
|
<-gotConn.Wait()
|
||||||
|
|
||||||
|
lazyDownload := &LazyReader{
|
||||||
|
CreateReader: func() (io.ReadCloser, error) {
|
||||||
|
<-gotDownResponse.Wait()
|
||||||
|
if downResponse == nil {
|
||||||
|
return nil, errors.New("downResponse failed")
|
||||||
|
}
|
||||||
|
return downResponse, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return lazyDownload, remoteAddr, localAddr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DefaultDialerClient) SendUploadRequest(ctx context.Context, url string, payload io.ReadWriteCloser, contentLength int64) error {
|
||||||
|
req, err := http.NewRequest("POST", url, payload)
|
||||||
|
req.ContentLength = contentLength
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header = c.transportConfig.GetRequestHeader()
|
||||||
|
|
||||||
|
if c.isH2 {
|
||||||
|
resp, err := c.upload.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return errors.New("bad status code:", resp.Status)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// stringify the entire HTTP/1.1 request so it can be
|
||||||
|
// safely retried. if instead req.Write is called multiple
|
||||||
|
// times, the body is already drained after the first
|
||||||
|
// request
|
||||||
|
requestBytes := new(bytes.Buffer)
|
||||||
|
common.Must(req.Write(requestBytes))
|
||||||
|
|
||||||
|
var uploadConn any
|
||||||
|
|
||||||
|
for {
|
||||||
|
uploadConn = c.uploadRawPool.Get()
|
||||||
|
newConnection := uploadConn == nil
|
||||||
|
if newConnection {
|
||||||
|
uploadConn, err = c.dialUploadConn(context.WithoutCancel(ctx))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = uploadConn.(net.Conn).Write(requestBytes.Bytes())
|
||||||
|
|
||||||
|
// if the write failed, we try another connection from
|
||||||
|
// the pool, until the write on a new connection fails.
|
||||||
|
// failed writes to a pooled connection are normal when
|
||||||
|
// the connection has been closed in the meantime.
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
} else if newConnection {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.uploadRawPool.Put(uploadConn)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
@ -1,59 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Browser Dialer</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script>
|
|
||||||
// Copyright (c) 2021 XRAY. Mozilla Public License 2.0.
|
|
||||||
var url = "ws://" + window.location.host + "/websocket?token=csrfToken"
|
|
||||||
var count = 0
|
|
||||||
setInterval(check, 1000)
|
|
||||||
function check() {
|
|
||||||
if (count <= 0) {
|
|
||||||
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) {
|
|
||||||
if (first) {
|
|
||||||
first = false
|
|
||||||
count -= 1
|
|
||||||
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
|
|
||||||
ws.send("ok")
|
|
||||||
}
|
|
||||||
wss.onmessage = function (event) {
|
|
||||||
ws.send(event.data)
|
|
||||||
}
|
|
||||||
wss.onclose = function (event) {
|
|
||||||
ws.close()
|
|
||||||
}
|
|
||||||
wss.onerror = function (event) {
|
|
||||||
!opened && ws.send("fail")
|
|
||||||
wss.close()
|
|
||||||
}
|
|
||||||
check()
|
|
||||||
} else wss.send(event.data)
|
|
||||||
}
|
|
||||||
ws.onclose = function (event) {
|
|
||||||
if (first) count -= 1
|
|
||||||
else wss.close()
|
|
||||||
}
|
|
||||||
ws.onerror = function (event) {
|
|
||||||
ws.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
Loading…
Reference in new issue