From c2fe6ec1be8f49276f16a3b235413fdbd0cdc1ae Mon Sep 17 00:00:00 2001 From: Emo-Damage <75520940+Emo-Damage@users.noreply.github.com> Date: Sat, 17 Jun 2023 22:35:17 +0800 Subject: [PATCH] add experimental file for socks5 --- .github/workflows/version-comment.yml | 6 + src/worker-vless.js | 220 +------ src/worker-with-socks5-experimental.js | 801 +++++++++++++++++++++++++ 3 files changed, 818 insertions(+), 209 deletions(-) create mode 100644 src/worker-with-socks5-experimental.js diff --git a/.github/workflows/version-comment.yml b/.github/workflows/version-comment.yml index c4e14ac..b561c30 100644 --- a/.github/workflows/version-comment.yml +++ b/.github/workflows/version-comment.yml @@ -22,3 +22,9 @@ jobs: path: src/worker-vless.js body: "version base on commit ${{ github.sha }}, time is {{date:YYYY-MM-DD HH:mm:ss}} UTC" message: "Update version worker-vless.js" + - name: Modify version worker-with-socks5-experimental.js + uses: jaywcjlove/github-action-modify-file-content@main + with: + path: src/worker-with-socks5-experimental.js + body: "version base on commit ${{ github.sha }}, time is {{date:YYYY-MM-DD HH:mm:ss}} UTC" + message: "Update version worker-with-socks5-experimental.js" diff --git a/src/worker-vless.js b/src/worker-vless.js index d86feb3..d503a5e 100644 --- a/src/worker-vless.js +++ b/src/worker-vless.js @@ -8,18 +8,11 @@ let userID = 'd342d11e-d424-4583-b36e-524ab1f0afa4'; let proxyIP = ''; -// The user name and password do not contain special characters -// Setting the address will ignore proxyIP -// Example: user:pass@host:port or host:port -let socks5Address = ''; if (!isValidUUID(userID)) { throw new Error('uuid is not valid'); } -let parsedSocks5Address = {}; -let enableSocks = false; - export default { /** * @param {import("@cloudflare/workers-types").Request} request @@ -31,17 +24,6 @@ export default { try { userID = env.UUID || userID; proxyIP = env.PROXYIP || proxyIP; - socks5Address = env.SOCKS5 || socks5Address; - if (socks5Address) { - try { - parsedSocks5Address = socks5AddressParser(socks5Address); - enableSocks = true; - } catch (err) { - /** @type {Error} */ let e = err; - console.log(e.toString()); - enableSocks = false; - } - } const upgradeHeader = request.headers.get('Upgrade'); if (!upgradeHeader || upgradeHeader !== 'websocket') { const url = new URL(request.url); @@ -117,7 +99,6 @@ async function vlessOverWSHandler(request) { const { hasError, message, - addressType, portRemote = 443, addressRemote = '', rawDataIndex, @@ -150,7 +131,7 @@ async function vlessOverWSHandler(request) { if (isDns) { return handleDNSQuery(rawClientData, webSocket, vlessResponseHeader, log); } - handleTCPOutBound(remoteSocketWapper, addressType, addressRemote, portRemote, rawClientData, webSocket, vlessResponseHeader, log); + handleTCPOutBound(remoteSocketWapper, addressRemote, portRemote, rawClientData, webSocket, vlessResponseHeader, log); }, close() { log(`readableWebSocketStream is close`); @@ -172,8 +153,7 @@ async function vlessOverWSHandler(request) { /** * Handles outbound TCP connections. * - * @param {any} remoteSocket - * @param {number} addressType The remote address type to connect to. + * @param {any} remoteSocket * @param {string} addressRemote The remote address to connect to. * @param {number} portRemote The remote port to connect to. * @param {Uint8Array} rawClientData The raw client data to write. @@ -182,29 +162,24 @@ async function vlessOverWSHandler(request) { * @param {function} log The logging function. * @returns {Promise} The remote socket. */ -async function handleTCPOutBound(remoteSocket, addressType, addressRemote, portRemote, rawClientData, webSocket, vlessResponseHeader, log,) { - async function connectAndWrite(address, port, socks = false) { +async function handleTCPOutBound(remoteSocket, addressRemote, portRemote, rawClientData, webSocket, vlessResponseHeader, log,) { + async function connectAndWrite(address, port) { /** @type {import("@cloudflare/workers-types").Socket} */ - const tcpSocket = socks ? await socks5Connect(addressType, address, port, log) - : connect({ - hostname: address, - port: port, - }); + const tcpSocket = connect({ + hostname: address, + port: port, + }); remoteSocket.value = tcpSocket; log(`connected to ${address}:${port}`); const writer = tcpSocket.writable.getWriter(); - await writer.write(rawClientData); // first write, normal is tls client hello + await writer.write(rawClientData); // first write, nomal is tls client hello writer.releaseLock(); return tcpSocket; } // if the cf connect tcp socket have no incoming data, we retry to redirect ip async function retry() { - if (enableSocks) { - tcpSocket = await connectAndWrite(addressRemote, portRemote, true); - } else { - tcpSocket = await connectAndWrite(proxyIP || addressRemote, portRemote); - } + const tcpSocket = await connectAndWrite(proxyIP || addressRemote, portRemote) // no matter retry success or not, close websocket tcpSocket.closed.catch(error => { console.log('retry tcpSocket closed error', error); @@ -214,7 +189,7 @@ async function handleTCPOutBound(remoteSocket, addressType, addressRemote, portR remoteSocketToWS(tcpSocket, webSocket, vlessResponseHeader, null, log); } - let tcpSocket = await connectAndWrite(addressRemote, portRemote); + const tcpSocket = await connectAndWrite(addressRemote, portRemote); // when remoteSocket is ready, pass to websocket // remote--> ws @@ -590,178 +565,6 @@ async function handleDNSQuery(udpChunk, webSocket, vlessResponseHeader, log) { } } -/** - * - * @param {number} addressType - * @param {string} addressRemote - * @param {number} portRemote - * @param {function} log The logging function. - */ -async function socks5Connect(addressType, addressRemote, portRemote, log) { - const { username, password, hostname, port } = parsedSocks5Address; - // Connect to the SOCKS server - const socket = connect({ - hostname, - port, - }); - - // Request head format (Worker -> Socks Server): - // +----+----------+----------+ - // |VER | NMETHODS | METHODS | - // +----+----------+----------+ - // | 1 | 1 | 1 to 255 | - // +----+----------+----------+ - - // https://en.wikipedia.org/wiki/SOCKS#SOCKS5 - // For METHODS: - // 0x00 NO AUTHENTICATION REQUIRED - // 0x02 USERNAME/PASSWORD https://datatracker.ietf.org/doc/html/rfc1929 - const socksGreeting = new Uint8Array([5, 2, 0, 2]); - - const writer = socket.writable.getWriter(); - - await writer.write(socksGreeting); - log('sent socks greeting'); - - const reader = socket.readable.getReader(); - const encoder = new TextEncoder(); - let res = (await reader.read()).value; - // Response format (Socks Server -> Worker): - // +----+--------+ - // |VER | METHOD | - // +----+--------+ - // | 1 | 1 | - // +----+--------+ - if (res[0] !== 0x05) { - log(`socks server version error: ${res[0]} expected: 5`); - return; - } - if (res[1] === 0xff) { - log("no acceptable methods"); - return; - } - - // if return 0x0502 - if (res[1] === 0x02) { - log("socks server needs auth"); - if (!username || !password) { - log("please provide username/password"); - return; - } - // +----+------+----------+------+----------+ - // |VER | ULEN | UNAME | PLEN | PASSWD | - // +----+------+----------+------+----------+ - // | 1 | 1 | 1 to 255 | 1 | 1 to 255 | - // +----+------+----------+------+----------+ - const authRequest = new Uint8Array([ - 1, - username.length, - ...encoder.encode(username), - password.length, - ...encoder.encode(password) - ]); - await writer.write(authRequest); - res = (await reader.read()).value; - // expected 0x0100 - if (res[0] !== 0x01 || res[1] !== 0x00) { - log("fail to auth socks server"); - return; - } - } - - // Request data format (Worker -> Socks Server): - // +----+-----+-------+------+----------+----------+ - // |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | - // +----+-----+-------+------+----------+----------+ - // | 1 | 1 | X'00' | 1 | Variable | 2 | - // +----+-----+-------+------+----------+----------+ - // ATYP: address type of following address - // 0x01: IPv4 address - // 0x03: Domain name - // 0x04: IPv6 address - // DST.ADDR: desired destination address - // DST.PORT: desired destination port in network octet order - - // addressType - // 1--> ipv4 addressLength =4 - // 2--> domain name - // 3--> ipv6 addressLength =16 - let DSTADDR; // DSTADDR = ATYP + DST.ADDR - switch (addressType) { - case 1: - DSTADDR = new Uint8Array( - [1, ...addressRemote.split('.').map(Number)] - ); - break; - case 2: - DSTADDR = new Uint8Array( - [3, addressRemote.length, ...encoder.encode(addressRemote)] - ); - break; - case 3: - DSTADDR = new Uint8Array( - [4, ...addressRemote.split(':').flatMap(x => [parseInt(x.slice(0, 2), 16), parseInt(x.slice(2), 16)])] - ); - break; - default: - log(`invild addressType is ${addressType}`); - return; - } - const socksRequest = new Uint8Array([5, 1, 0, ...DSTADDR, portRemote >> 8, portRemote & 0xff]); - await writer.write(socksRequest); - log('sent socks request'); - - res = (await reader.read()).value; - // Response format (Socks Server -> Worker): - // +----+-----+-------+------+----------+----------+ - // |VER | REP | RSV | ATYP | BND.ADDR | BND.PORT | - // +----+-----+-------+------+----------+----------+ - // | 1 | 1 | X'00' | 1 | Variable | 2 | - // +----+-----+-------+------+----------+----------+ - if (res[1] === 0x00) { - log("socks connection opened"); - } else { - log("fail to open socks connection"); - return; - } - writer.releaseLock(); - reader.releaseLock(); - return socket; -} - - -/** - * - * @param {string} address - */ -function socks5AddressParser(address) { - let [latter, former] = address.split("@").reverse(); - let username, password, hostname, port; - if (former) { - const formers = former.split(":"); - if (formers.length !== 2) { - throw new Error('Invalid SOCKS address format'); - } - [username, password] = formers; - } - const latters = latter.split(":"); - port = Number(latters.pop()); - if (isNaN(port)) { - throw new Error('Invalid SOCKS address format'); - } - hostname = latters.join(":"); - const regex = /^\[.*\]$/; - if (hostname.includes(":") && !regex.test(hostname)) { - throw new Error('Invalid SOCKS address format'); - } - return { - username, - password, - hostname, - port, - } -} - /** * * @param {string} userID @@ -798,4 +601,3 @@ clash-meta `; } - diff --git a/src/worker-with-socks5-experimental.js b/src/worker-with-socks5-experimental.js new file mode 100644 index 0000000..8783356 --- /dev/null +++ b/src/worker-with-socks5-experimental.js @@ -0,0 +1,801 @@ +// experimental version base on commit c17ceb86be8e16c53fcfdf4b7dcd09e9e372863b, time is 2023-06-05 18:02:59 UTC. +// @ts-ignore +import { connect } from 'cloudflare:sockets'; + +// How to generate your own UUID: +// [Windows] Press "Win + R", input cmd and run: Powershell -NoExit -Command "[guid]::NewGuid()" +let userID = 'd342d11e-d424-4583-b36e-524ab1f0afa4'; + +let proxyIP = ''; + +// The user name and password do not contain special characters +// Setting the address will ignore proxyIP +// Example: user:pass@host:port or host:port +let socks5Address = ''; + +if (!isValidUUID(userID)) { + throw new Error('uuid is not valid'); +} + +let parsedSocks5Address = {}; +let enableSocks = false; + +export default { + /** + * @param {import("@cloudflare/workers-types").Request} request + * @param {{UUID: string, PROXYIP: string}} env + * @param {import("@cloudflare/workers-types").ExecutionContext} ctx + * @returns {Promise} + */ + async fetch(request, env, ctx) { + try { + userID = env.UUID || userID; + proxyIP = env.PROXYIP || proxyIP; + socks5Address = env.SOCKS5 || socks5Address; + if (socks5Address) { + try { + parsedSocks5Address = socks5AddressParser(socks5Address); + enableSocks = true; + } catch (err) { + /** @type {Error} */ let e = err; + console.log(e.toString()); + enableSocks = false; + } + } + const upgradeHeader = request.headers.get('Upgrade'); + if (!upgradeHeader || upgradeHeader !== 'websocket') { + const url = new URL(request.url); + switch (url.pathname) { + case '/': + return new Response(JSON.stringify(request.cf), { status: 200 }); + case `/${userID}`: { + const vlessConfig = getVLESSConfig(userID, request.headers.get('Host')); + return new Response(`${vlessConfig}`, { + status: 200, + headers: { + "Content-Type": "text/plain;charset=utf-8", + } + }); + } + default: + return new Response('Not found', { status: 404 }); + } + } else { + return await vlessOverWSHandler(request); + } + } catch (err) { + /** @type {Error} */ let e = err; + return new Response(e.toString()); + } + }, +}; + + + + +/** + * + * @param {import("@cloudflare/workers-types").Request} request + */ +async function vlessOverWSHandler(request) { + + /** @type {import("@cloudflare/workers-types").WebSocket[]} */ + // @ts-ignore + const webSocketPair = new WebSocketPair(); + const [client, webSocket] = Object.values(webSocketPair); + + webSocket.accept(); + + let address = ''; + let portWithRandomLog = ''; + const log = (/** @type {string} */ info, /** @type {string | undefined} */ event) => { + console.log(`[${address}:${portWithRandomLog}] ${info}`, event || ''); + }; + const earlyDataHeader = request.headers.get('sec-websocket-protocol') || ''; + + const readableWebSocketStream = makeReadableWebSocketStream(webSocket, earlyDataHeader, log); + + /** @type {{ value: import("@cloudflare/workers-types").Socket | null}}*/ + let remoteSocketWapper = { + value: null, + }; + let isDns = false; + + // ws --> remote + readableWebSocketStream.pipeTo(new WritableStream({ + async write(chunk, controller) { + if (isDns) { + return await handleDNSQuery(chunk, webSocket, null, log); + } + if (remoteSocketWapper.value) { + const writer = remoteSocketWapper.value.writable.getWriter() + await writer.write(chunk); + writer.releaseLock(); + return; + } + + const { + hasError, + message, + addressType, + portRemote = 443, + addressRemote = '', + rawDataIndex, + vlessVersion = new Uint8Array([0, 0]), + isUDP, + } = processVlessHeader(chunk, userID); + address = addressRemote; + portWithRandomLog = `${portRemote}--${Math.random()} ${isUDP ? 'udp ' : 'tcp ' + } `; + if (hasError) { + // controller.error(message); + throw new Error(message); // cf seems has bug, controller.error will not end stream + // webSocket.close(1000, message); + return; + } + // if UDP but port not DNS port, close it + if (isUDP) { + if (portRemote === 53) { + isDns = true; + } else { + // controller.error('UDP proxy only enable for DNS which is port 53'); + throw new Error('UDP proxy only enable for DNS which is port 53'); // cf seems has bug, controller.error will not end stream + return; + } + } + // ["version", "附加信息长度 N"] + const vlessResponseHeader = new Uint8Array([vlessVersion[0], 0]); + const rawClientData = chunk.slice(rawDataIndex); + + if (isDns) { + return handleDNSQuery(rawClientData, webSocket, vlessResponseHeader, log); + } + handleTCPOutBound(remoteSocketWapper, addressType, addressRemote, portRemote, rawClientData, webSocket, vlessResponseHeader, log); + }, + close() { + log(`readableWebSocketStream is close`); + }, + abort(reason) { + log(`readableWebSocketStream is abort`, JSON.stringify(reason)); + }, + })).catch((err) => { + log('readableWebSocketStream pipeTo error', err); + }); + + return new Response(null, { + status: 101, + // @ts-ignore + webSocket: client, + }); +} + +/** + * Handles outbound TCP connections. + * + * @param {any} remoteSocket + * @param {number} addressType The remote address type to connect to. + * @param {string} addressRemote The remote address to connect to. + * @param {number} portRemote The remote port to connect to. + * @param {Uint8Array} rawClientData The raw client data to write. + * @param {import("@cloudflare/workers-types").WebSocket} webSocket The WebSocket to pass the remote socket to. + * @param {Uint8Array} vlessResponseHeader The VLESS response header. + * @param {function} log The logging function. + * @returns {Promise} The remote socket. + */ +async function handleTCPOutBound(remoteSocket, addressType, addressRemote, portRemote, rawClientData, webSocket, vlessResponseHeader, log,) { + async function connectAndWrite(address, port, socks = false) { + /** @type {import("@cloudflare/workers-types").Socket} */ + const tcpSocket = socks ? await socks5Connect(addressType, address, port, log) + : connect({ + hostname: address, + port: port, + }); + remoteSocket.value = tcpSocket; + log(`connected to ${address}:${port}`); + const writer = tcpSocket.writable.getWriter(); + await writer.write(rawClientData); // first write, normal is tls client hello + writer.releaseLock(); + return tcpSocket; + } + + // if the cf connect tcp socket have no incoming data, we retry to redirect ip + async function retry() { + if (enableSocks) { + tcpSocket = await connectAndWrite(addressRemote, portRemote, true); + } else { + tcpSocket = await connectAndWrite(proxyIP || addressRemote, portRemote); + } + // no matter retry success or not, close websocket + tcpSocket.closed.catch(error => { + console.log('retry tcpSocket closed error', error); + }).finally(() => { + safeCloseWebSocket(webSocket); + }) + remoteSocketToWS(tcpSocket, webSocket, vlessResponseHeader, null, log); + } + + let tcpSocket = await connectAndWrite(addressRemote, portRemote); + + // when remoteSocket is ready, pass to websocket + // remote--> ws + remoteSocketToWS(tcpSocket, webSocket, vlessResponseHeader, retry, log); +} + +/** + * + * @param {import("@cloudflare/workers-types").WebSocket} webSocketServer + * @param {string} earlyDataHeader for ws 0rtt + * @param {(info: string)=> void} log for ws 0rtt + */ +function makeReadableWebSocketStream(webSocketServer, earlyDataHeader, log) { + let readableStreamCancel = false; + const stream = new ReadableStream({ + start(controller) { + webSocketServer.addEventListener('message', (event) => { + if (readableStreamCancel) { + return; + } + const message = event.data; + controller.enqueue(message); + }); + + // The event means that the client closed the client -> server stream. + // However, the server -> client stream is still open until you call close() on the server side. + // The WebSocket protocol says that a separate close message must be sent in each direction to fully close the socket. + webSocketServer.addEventListener('close', () => { + // client send close, need close server + // if stream is cancel, skip controller.close + safeCloseWebSocket(webSocketServer); + if (readableStreamCancel) { + return; + } + controller.close(); + } + ); + webSocketServer.addEventListener('error', (err) => { + log('webSocketServer has error'); + controller.error(err); + } + ); + // for ws 0rtt + const { earlyData, error } = base64ToArrayBuffer(earlyDataHeader); + if (error) { + controller.error(error); + } else if (earlyData) { + controller.enqueue(earlyData); + } + }, + + pull(controller) { + // if ws can stop read if stream is full, we can implement backpressure + // https://streams.spec.whatwg.org/#example-rs-push-backpressure + }, + cancel(reason) { + // 1. pipe WritableStream has error, this cancel will called, so ws handle server close into here + // 2. if readableStream is cancel, all controller.close/enqueue need skip, + // 3. but from testing controller.error still work even if readableStream is cancel + if (readableStreamCancel) { + return; + } + log(`ReadableStream was canceled, due to ${reason}`) + readableStreamCancel = true; + safeCloseWebSocket(webSocketServer); + } + }); + + return stream; + +} + +// https://xtls.github.io/development/protocols/vless.html +// https://github.com/zizifn/excalidraw-backup/blob/main/v2ray-protocol.excalidraw + +/** + * + * @param { ArrayBuffer} vlessBuffer + * @param {string} userID + * @returns + */ +function processVlessHeader( + vlessBuffer, + userID +) { + if (vlessBuffer.byteLength < 24) { + return { + hasError: true, + message: 'invalid data', + }; + } + const version = new Uint8Array(vlessBuffer.slice(0, 1)); + let isValidUser = false; + let isUDP = false; + if (stringify(new Uint8Array(vlessBuffer.slice(1, 17))) === userID) { + isValidUser = true; + } + if (!isValidUser) { + return { + hasError: true, + message: 'invalid user', + }; + } + + const optLength = new Uint8Array(vlessBuffer.slice(17, 18))[0]; + //skip opt for now + + const command = new Uint8Array( + vlessBuffer.slice(18 + optLength, 18 + optLength + 1) + )[0]; + + // 0x01 TCP + // 0x02 UDP + // 0x03 MUX + if (command === 1) { + } else if (command === 2) { + isUDP = true; + } else { + return { + hasError: true, + message: `command ${command} is not support, command 01-tcp,02-udp,03-mux`, + }; + } + const portIndex = 18 + optLength + 1; + const portBuffer = vlessBuffer.slice(portIndex, portIndex + 2); + // port is big-Endian in raw data etc 80 == 0x005d + const portRemote = new DataView(portBuffer).getUint16(0); + + let addressIndex = portIndex + 2; + const addressBuffer = new Uint8Array( + vlessBuffer.slice(addressIndex, addressIndex + 1) + ); + + // 1--> ipv4 addressLength =4 + // 2--> domain name addressLength=addressBuffer[1] + // 3--> ipv6 addressLength =16 + const addressType = addressBuffer[0]; + let addressLength = 0; + let addressValueIndex = addressIndex + 1; + let addressValue = ''; + switch (addressType) { + case 1: + addressLength = 4; + addressValue = new Uint8Array( + vlessBuffer.slice(addressValueIndex, addressValueIndex + addressLength) + ).join('.'); + break; + case 2: + addressLength = new Uint8Array( + vlessBuffer.slice(addressValueIndex, addressValueIndex + 1) + )[0]; + addressValueIndex += 1; + addressValue = new TextDecoder().decode( + vlessBuffer.slice(addressValueIndex, addressValueIndex + addressLength) + ); + break; + case 3: + addressLength = 16; + const dataView = new DataView( + vlessBuffer.slice(addressValueIndex, addressValueIndex + addressLength) + ); + // 2001:0db8:85a3:0000:0000:8a2e:0370:7334 + const ipv6 = []; + for (let i = 0; i < 8; i++) { + ipv6.push(dataView.getUint16(i * 2).toString(16)); + } + addressValue = ipv6.join(':'); + // seems no need add [] for ipv6 + break; + default: + return { + hasError: true, + message: `invild addressType is ${addressType}`, + }; + } + if (!addressValue) { + return { + hasError: true, + message: `addressValue is empty, addressType is ${addressType}`, + }; + } + + return { + hasError: false, + addressRemote: addressValue, + addressType, + portRemote, + rawDataIndex: addressValueIndex + addressLength, + vlessVersion: version, + isUDP, + }; +} + + +/** + * + * @param {import("@cloudflare/workers-types").Socket} remoteSocket + * @param {import("@cloudflare/workers-types").WebSocket} webSocket + * @param {ArrayBuffer} vlessResponseHeader + * @param {(() => Promise) | null} retry + * @param {*} log + */ +async function remoteSocketToWS(remoteSocket, webSocket, vlessResponseHeader, retry, log) { + // remote--> ws + let remoteChunkCount = 0; + let chunks = []; + /** @type {ArrayBuffer | null} */ + let vlessHeader = vlessResponseHeader; + let hasIncomingData = false; // check if remoteSocket has incoming data + await remoteSocket.readable + .pipeTo( + new WritableStream({ + start() { + }, + /** + * + * @param {Uint8Array} chunk + * @param {*} controller + */ + async write(chunk, controller) { + hasIncomingData = true; + // remoteChunkCount++; + if (webSocket.readyState !== WS_READY_STATE_OPEN) { + controller.error( + 'webSocket.readyState is not open, maybe close' + ); + } + if (vlessHeader) { + webSocket.send(await new Blob([vlessHeader, chunk]).arrayBuffer()); + vlessHeader = null; + } else { + // seems no need rate limit this, CF seems fix this??.. + // if (remoteChunkCount > 20000) { + // // cf one package is 4096 byte(4kb), 4096 * 20000 = 80M + // await delay(1); + // } + webSocket.send(chunk); + } + }, + close() { + log(`remoteConnection!.readable is close with hasIncomingData is ${hasIncomingData}`); + // safeCloseWebSocket(webSocket); // no need server close websocket frist for some case will casue HTTP ERR_CONTENT_LENGTH_MISMATCH issue, client will send close event anyway. + }, + abort(reason) { + console.error(`remoteConnection!.readable abort`, reason); + }, + }) + ) + .catch((error) => { + console.error( + `remoteSocketToWS has exception `, + error.stack || error + ); + safeCloseWebSocket(webSocket); + }); + + // seems is cf connect socket have error, + // 1. Socket.closed will have error + // 2. Socket.readable will be close without any data coming + if (hasIncomingData === false && retry) { + log(`retry`) + retry(); + } +} + +/** + * + * @param {string} base64Str + * @returns + */ +function base64ToArrayBuffer(base64Str) { + if (!base64Str) { + return { error: null }; + } + try { + // go use modified Base64 for URL rfc4648 which js atob not support + base64Str = base64Str.replace(/-/g, '+').replace(/_/g, '/'); + const decode = atob(base64Str); + const arryBuffer = Uint8Array.from(decode, (c) => c.charCodeAt(0)); + return { earlyData: arryBuffer.buffer, error: null }; + } catch (error) { + return { error }; + } +} + +/** + * This is not real UUID validation + * @param {string} uuid + */ +function isValidUUID(uuid) { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(uuid); +} + +const WS_READY_STATE_OPEN = 1; +const WS_READY_STATE_CLOSING = 2; +/** + * Normally, WebSocket will not has exceptions when close. + * @param {import("@cloudflare/workers-types").WebSocket} socket + */ +function safeCloseWebSocket(socket) { + try { + if (socket.readyState === WS_READY_STATE_OPEN || socket.readyState === WS_READY_STATE_CLOSING) { + socket.close(); + } + } catch (error) { + console.error('safeCloseWebSocket error', error); + } +} + +const byteToHex = []; +for (let i = 0; i < 256; ++i) { + byteToHex.push((i + 256).toString(16).slice(1)); +} +function unsafeStringify(arr, offset = 0) { + return (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + "-" + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + "-" + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + "-" + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + "-" + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase(); +} +function stringify(arr, offset = 0) { + const uuid = unsafeStringify(arr, offset); + if (!isValidUUID(uuid)) { + throw TypeError("Stringified UUID is invalid"); + } + return uuid; +} + +/** + * + * @param {ArrayBuffer} udpChunk + * @param {import("@cloudflare/workers-types").WebSocket} webSocket + * @param {ArrayBuffer} vlessResponseHeader + * @param {(string)=> void} log + */ +async function handleDNSQuery(udpChunk, webSocket, vlessResponseHeader, log) { + // no matter which DNS server client send, we alwasy use hard code one. + // beacsue someof DNS server is not support DNS over TCP + try { + const dnsServer = '8.8.4.4'; // change to 1.1.1.1 after cf fix connect own ip bug + const dnsPort = 53; + /** @type {ArrayBuffer | null} */ + let vlessHeader = vlessResponseHeader; + /** @type {import("@cloudflare/workers-types").Socket} */ + const tcpSocket = connect({ + hostname: dnsServer, + port: dnsPort, + }); + + log(`connected to ${dnsServer}:${dnsPort}`); + const writer = tcpSocket.writable.getWriter(); + await writer.write(udpChunk); + writer.releaseLock(); + await tcpSocket.readable.pipeTo(new WritableStream({ + async write(chunk) { + if (webSocket.readyState === WS_READY_STATE_OPEN) { + if (vlessHeader) { + webSocket.send(await new Blob([vlessHeader, chunk]).arrayBuffer()); + vlessHeader = null; + } else { + webSocket.send(chunk); + } + } + }, + close() { + log(`dns server(${dnsServer}) tcp is close`); + }, + abort(reason) { + console.error(`dns server(${dnsServer}) tcp is abort`, reason); + }, + })); + } catch (error) { + console.error( + `handleDNSQuery have exception, error: ${error.message}` + ); + } +} + +/** + * + * @param {number} addressType + * @param {string} addressRemote + * @param {number} portRemote + * @param {function} log The logging function. + */ +async function socks5Connect(addressType, addressRemote, portRemote, log) { + const { username, password, hostname, port } = parsedSocks5Address; + // Connect to the SOCKS server + const socket = connect({ + hostname, + port, + }); + + // Request head format (Worker -> Socks Server): + // +----+----------+----------+ + // |VER | NMETHODS | METHODS | + // +----+----------+----------+ + // | 1 | 1 | 1 to 255 | + // +----+----------+----------+ + + // https://en.wikipedia.org/wiki/SOCKS#SOCKS5 + // For METHODS: + // 0x00 NO AUTHENTICATION REQUIRED + // 0x02 USERNAME/PASSWORD https://datatracker.ietf.org/doc/html/rfc1929 + const socksGreeting = new Uint8Array([5, 2, 0, 2]); + + const writer = socket.writable.getWriter(); + + await writer.write(socksGreeting); + log('sent socks greeting'); + + const reader = socket.readable.getReader(); + const encoder = new TextEncoder(); + let res = (await reader.read()).value; + // Response format (Socks Server -> Worker): + // +----+--------+ + // |VER | METHOD | + // +----+--------+ + // | 1 | 1 | + // +----+--------+ + if (res[0] !== 0x05) { + log(`socks server version error: ${res[0]} expected: 5`); + return; + } + if (res[1] === 0xff) { + log("no acceptable methods"); + return; + } + + // if return 0x0502 + if (res[1] === 0x02) { + log("socks server needs auth"); + if (!username || !password) { + log("please provide username/password"); + return; + } + // +----+------+----------+------+----------+ + // |VER | ULEN | UNAME | PLEN | PASSWD | + // +----+------+----------+------+----------+ + // | 1 | 1 | 1 to 255 | 1 | 1 to 255 | + // +----+------+----------+------+----------+ + const authRequest = new Uint8Array([ + 1, + username.length, + ...encoder.encode(username), + password.length, + ...encoder.encode(password) + ]); + await writer.write(authRequest); + res = (await reader.read()).value; + // expected 0x0100 + if (res[0] !== 0x01 || res[1] !== 0x00) { + log("fail to auth socks server"); + return; + } + } + + // Request data format (Worker -> Socks Server): + // +----+-----+-------+------+----------+----------+ + // |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | + // +----+-----+-------+------+----------+----------+ + // | 1 | 1 | X'00' | 1 | Variable | 2 | + // +----+-----+-------+------+----------+----------+ + // ATYP: address type of following address + // 0x01: IPv4 address + // 0x03: Domain name + // 0x04: IPv6 address + // DST.ADDR: desired destination address + // DST.PORT: desired destination port in network octet order + + // addressType + // 1--> ipv4 addressLength =4 + // 2--> domain name + // 3--> ipv6 addressLength =16 + let DSTADDR; // DSTADDR = ATYP + DST.ADDR + switch (addressType) { + case 1: + DSTADDR = new Uint8Array( + [1, ...addressRemote.split('.').map(Number)] + ); + break; + case 2: + DSTADDR = new Uint8Array( + [3, addressRemote.length, ...encoder.encode(addressRemote)] + ); + break; + case 3: + DSTADDR = new Uint8Array( + [4, ...addressRemote.split(':').flatMap(x => [parseInt(x.slice(0, 2), 16), parseInt(x.slice(2), 16)])] + ); + break; + default: + log(`invild addressType is ${addressType}`); + return; + } + const socksRequest = new Uint8Array([5, 1, 0, ...DSTADDR, portRemote >> 8, portRemote & 0xff]); + await writer.write(socksRequest); + log('sent socks request'); + + res = (await reader.read()).value; + // Response format (Socks Server -> Worker): + // +----+-----+-------+------+----------+----------+ + // |VER | REP | RSV | ATYP | BND.ADDR | BND.PORT | + // +----+-----+-------+------+----------+----------+ + // | 1 | 1 | X'00' | 1 | Variable | 2 | + // +----+-----+-------+------+----------+----------+ + if (res[1] === 0x00) { + log("socks connection opened"); + } else { + log("fail to open socks connection"); + return; + } + writer.releaseLock(); + reader.releaseLock(); + return socket; +} + + +/** + * + * @param {string} address + */ +function socks5AddressParser(address) { + let [latter, former] = address.split("@").reverse(); + let username, password, hostname, port; + if (former) { + const formers = former.split(":"); + if (formers.length !== 2) { + throw new Error('Invalid SOCKS address format'); + } + [username, password] = formers; + } + const latters = latter.split(":"); + port = Number(latters.pop()); + if (isNaN(port)) { + throw new Error('Invalid SOCKS address format'); + } + hostname = latters.join(":"); + const regex = /^\[.*\]$/; + if (hostname.includes(":") && !regex.test(hostname)) { + throw new Error('Invalid SOCKS address format'); + } + return { + username, + password, + hostname, + port, + } +} + +/** + * + * @param {string} userID + * @param {string | null} hostName + * @returns {string} + */ +function getVLESSConfig(userID, hostName) { + const vlessMain = `vless://${userID}@${hostName}:443?encryption=none&security=tls&sni=${hostName}&fp=randomized&type=ws&host=${hostName}&path=%2F%3Fed%3D2048#${hostName}` + return ` +################################################################ +v2ray +--------------------------------------------------------------- +${vlessMain} +--------------------------------------------------------------- +################################################################ +clash-meta +--------------------------------------------------------------- +- type: vless + name: ${hostName} + server: ${hostName} + port: 443 + uuid: ${userID} + network: ws + tls: true + udp: false + sni: ${hostName} + client-fingerprint: chrome + ws-opts: + path: "/?ed=2048" + headers: + host: ${hostName} +--------------------------------------------------------------- +################################################################ +`; +} + +