Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions transport/internet/browser_dialer/client/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// 2025 (C) Team Cloudchaser
// Licensed under MIT License

"use strict";

import AppatController from "../dialer/index.mjs";

let pagePrefix;
if (self.location?.href) {
pagePrefix = `${location.protocol}//${location.host}`;
} else if (self.Deno?.args[0]?.length > 0) {
pagePrefix = `http://${self.Deno.args[0]}`;
} else {
// Port 5779 should never be used for browser dialer controllers
pagePrefix = `http://127.0.0.1:5779`;
};
console.debug(`Received dialer prefix: ${pagePrefix}`);

const nullCSRF = atob("X19DU1JGX1RPS0VOX18");
let pageCSRF = "__CSRF_TOKEN__"; // Replaced with a valid token dynamically
if (pageCSRF === nullCSRF) {
// The CSRF token must be a valid UUID
if (self.location?.search?.indexOf("?token=") === 0) {
// Only a single query parameter is expected
pageCSRF = self.location.search.substring(7);
} else if (self.Deno?.args[1]?.length > 0) {
pageCSRF = self.Deno.args[1];
} else {
pageCSRF = "00000000-0000-0000-0000-000000000000";
};
};
console.debug(`Received CSRF token: ${pageCSRF}`);

let dialer = new AppatController(pagePrefix, pageCSRF);
dialer.start();
213 changes: 152 additions & 61 deletions transport/internet/browser_dialer/dialer.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,70 +6,142 @@ import (
_ "embed"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"strings"
"sync"
"time"

"github.com/gorilla/websocket"
"github.com/xtls/xray-core/common/errors"
"github.com/xtls/xray-core/common/platform"
u "github.com/xtls/xray-core/common/utils"
"github.com/xtls/xray-core/common/uuid"
)

//go:embed dialer.html
var webpage []byte

type task struct {
Method string `json:"method"`
URL string `json:"url"`
Extra any `json:"extra,omitempty"`
type pageWithConnMap struct {
UUID string
ControlConn *websocket.Conn
ConnMap map[string]chan *websocket.Conn
ConnMapLock sync.Mutex
}

var conns chan *websocket.Conn
var globalConnMap *u.TypedSyncMap[string, *pageWithConnMap]

type task struct {
Method string `json:"m"` // request method
URL string `json:"u"` // destination URL
ConnUUID string `json:"c"` // connection UUID
Extra any `json:"e,omitempty"` // extra information (headers, WS subprotocol, referrer...)
}

var upgrader = &websocket.Upgrader{
ReadBufferSize: 0,
WriteBufferSize: 0,
HandshakeTimeout: time.Second * 4,
CheckOrigin: func(r *http.Request) bool {
return true
if r.URL.Query().Get("token") == csrfToken {
return true
} else {
errors.LogError(context.Background(), "Browser dialer rejected connection: Invalid CSRF token")
return false
}
},
}

var csrfToken string

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")
}
if addr == "" {
return
}
token := uuid.New()
csrfToken = token.String()
globalConnMap = u.NewTypedSyncMap[string, *pageWithConnMap]()
webpage = bytes.ReplaceAll(webpage, []byte("__CSRF_TOKEN__"), []byte(csrfToken))
go http.ListenAndServe(addr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// user requests the HTML page
if !strings.HasPrefix(r.URL.Path, "/ws") {
w.Write(webpage)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
errors.LogError(context.Background(), "Browser dialer failed: Unhandled error")
return
}
path := strings.TrimPrefix(r.URL.Path, "/ws/")
pathParts := strings.Split(path, "/")
if len(pathParts) < 2 {
errors.LogError(context.Background(), "Browser dialer failed WebSocket upgrade: Insufficient UUID")
return
}
pageUUID := pathParts[0]
connUUID := pathParts[1]
if connUUID == "ctrl" {
page := &pageWithConnMap{
UUID: pageUUID,
ControlConn: conn,
ConnMap: make(map[string]chan *websocket.Conn),
}
if _, ok := globalConnMap.Load(pageUUID); ok {
errors.LogError(context.Background(), "Browser dialer received duplicate control connection with same page UUID")
conn.Close()
return
}
globalConnMap.Store(pageUUID, page)
go func() {
_, reader, err := conn.NextReader()
if err != nil {
return
}
} else {
w.Write(webpage)
// design and implement control message handling in the future if needed
io.Copy(io.Discard, reader)
}()
} else {
var page *pageWithConnMap
if page, _ = globalConnMap.Load(pageUUID); page == nil {
errors.LogError(context.Background(), "Browser dialer received sub-connection without existing control connection")
conn.Close()
return
}
}))
}
page.ConnMapLock.Lock()
c := page.ConnMap[connUUID]
page.ConnMapLock.Unlock()
if c == nil {
errors.LogError(context.Background(), "Browser dialer received a sub-connection but we didn't request it")
conn.Close()
return
}
select {
case c <- conn:
case <-time.After(5 * time.Second):
conn.Close()
errors.LogError(context.Background(), "Browser dialer http upgrade unexpected error")
}
}
}))
go monitor()
}

func HasBrowserDialer() bool {
return conns != nil
return globalConnMap != nil
}

type webSocketExtra struct {
Protocol string `json:"protocol,omitempty"`
Protocol string `json:"p,omitempty"`
}

func DialWS(uri string, ed []byte) (*websocket.Conn, error) {
UUID := uuid.New()
task := task{
Method: "WS",
URL: uri,
Method: "WS",
URL: uri,
ConnUUID: UUID.String(),
}

if ed != nil {
Expand All @@ -82,8 +154,8 @@ func DialWS(uri string, ed []byte) (*websocket.Conn, error) {
}

type httpExtra struct {
Referrer string `json:"referrer,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Referrer string `json:"r,omitempty"`
Headers map[string]string `json:"h,omitempty"`
}

func httpExtraFromHeaders(headers http.Header) *httpExtra {
Expand All @@ -108,20 +180,24 @@ func httpExtraFromHeaders(headers http.Header) *httpExtra {
}

func DialGet(uri string, headers http.Header) (*websocket.Conn, error) {
UUID := uuid.New()
task := task{
Method: "GET",
URL: uri,
Extra: httpExtraFromHeaders(headers),
Method: "GET",
URL: uri,
ConnUUID: UUID.String(),
Extra: httpExtraFromHeaders(headers),
}

return dialTask(task)
}

func DialPost(uri string, headers http.Header, payload []byte) error {
UUID := uuid.New()
task := task{
Method: "POST",
URL: uri,
Extra: httpExtraFromHeaders(headers),
Method: "POST",
URL: uri,
ConnUUID: UUID.String(),
Extra: httpExtraFromHeaders(headers),
}

conn, err := dialTask(task)
Expand All @@ -134,11 +210,6 @@ func DialPost(uri string, headers http.Header, payload []byte) error {
return err
}

err = CheckOK(conn)
if err != nil {
return err
}

conn.Close()
return nil
}
Expand All @@ -149,31 +220,51 @@ func dialTask(task task) (*websocket.Conn, error) {
return nil, err
}

var conn *websocket.Conn
for {
conn = <-conns
if conn.WriteMessage(websocket.TextMessage, data) != nil {
conn.Close()
} else {
break
}
var Page *pageWithConnMap
// the order of iterating a map is random
globalConnMap.Range(func(_ string, page *pageWithConnMap) bool {
Page = page
return false
})
if Page == nil {
return nil, errors.New("no control connection available")
}
err = CheckOK(conn)
var conn *websocket.Conn
connChan := make(chan *websocket.Conn, 1)
Page.ConnMapLock.Lock()
Page.ConnMap[task.ConnUUID] = connChan
Page.ConnMapLock.Unlock()
defer func() {
Page.ConnMapLock.Lock()
delete(Page.ConnMap, task.ConnUUID)
Page.ConnMapLock.Unlock()
}()
err = Page.ControlConn.WriteMessage(websocket.TextMessage, data)
if err != nil {
return nil, err
return nil, errors.New("failed to send task to control connection").Base(err)
}
select {
case conn = <-connChan:
return conn, nil
case <-time.After(5 * time.Second):
return nil, errors.New("timeout waiting for connection")
}

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)
func monitor() {
ticker := time.NewTicker(16 * time.Second)
defer ticker.Stop()
for {
<-ticker.C
var pageToDel []*pageWithConnMap
globalConnMap.Range(func(_ string, page *pageWithConnMap) bool {
if err := page.ControlConn.WriteControl(websocket.PingMessage, []byte{}, time.Time{}); err != nil {
pageToDel = append(pageToDel, page)
}
return true
})
for _, page := range pageToDel {
globalConnMap.Delete(page.UUID)
}
}

return nil
}
Loading