Spaces:
Paused
Paused
| // <!--GAMFC-->version base on commit 58686d5d125194d34a1137913b3a64ddcf55872f, time is 2024-11-27 09:26:01 UTC<!--GAMFC-END-->. | |
| // @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 = ''; | |
| if (!isValidUUID(userID)) { | |
| throw new Error('uuid is not valid'); | |
| } | |
| export default { | |
| /** | |
| * @param {import("@cloudflare/workers-types").Request} request | |
| * @param {{UUID: string, PROXYIP: string}} env | |
| * @param {import("@cloudflare/workers-types").ExecutionContext} ctx | |
| * @returns {Promise<Response>} | |
| */ | |
| async fetch(request, env, ctx) { | |
| try { | |
| userID = env.UUID || userID; | |
| proxyIP = env.PROXYIP || proxyIP; | |
| 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 udpStreamWrite = null; | |
| let isDns = false; | |
| // ws --> remote | |
| readableWebSocketStream.pipeTo(new WritableStream({ | |
| async write(chunk, controller) { | |
| if (isDns && udpStreamWrite) { | |
| return udpStreamWrite(chunk); | |
| } | |
| if (remoteSocketWapper.value) { | |
| const writer = remoteSocketWapper.value.writable.getWriter() | |
| await writer.write(chunk); | |
| writer.releaseLock(); | |
| return; | |
| } | |
| const { | |
| hasError, | |
| message, | |
| 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); | |
| // TODO: support udp here when cf runtime has udp support | |
| if (isDns) { | |
| const { write } = await handleUDPOutBound(webSocket, vlessResponseHeader, log); | |
| udpStreamWrite = write; | |
| udpStreamWrite(rawClientData); | |
| return; | |
| } | |
| handleTCPOutBound(remoteSocketWapper, 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 {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<void>} The remote socket. | |
| */ | |
| async function handleTCPOutBound(remoteSocket, addressRemote, portRemote, rawClientData, webSocket, vlessResponseHeader, log,) { | |
| async function connectAndWrite(address, port) { | |
| /** @type {import("@cloudflare/workers-types").Socket} */ | |
| 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, 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() { | |
| 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); | |
| }).finally(() => { | |
| safeCloseWebSocket(webSocket); | |
| }) | |
| remoteSocketToWS(tcpSocket, webSocket, vlessResponseHeader, null, log); | |
| } | |
| const 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<void>) | 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 {import("@cloudflare/workers-types").WebSocket} webSocket | |
| * @param {ArrayBuffer} vlessResponseHeader | |
| * @param {(string)=> void} log | |
| */ | |
| async function handleUDPOutBound(webSocket, vlessResponseHeader, log) { | |
| let isVlessHeaderSent = false; | |
| const transformStream = new TransformStream({ | |
| start(controller) { | |
| }, | |
| transform(chunk, controller) { | |
| // udp message 2 byte is the the length of udp data | |
| // TODO: this should have bug, beacsue maybe udp chunk can be in two websocket message | |
| for (let index = 0; index < chunk.byteLength;) { | |
| const lengthBuffer = chunk.slice(index, index + 2); | |
| const udpPakcetLength = new DataView(lengthBuffer).getUint16(0); | |
| const udpData = new Uint8Array( | |
| chunk.slice(index + 2, index + 2 + udpPakcetLength) | |
| ); | |
| index = index + 2 + udpPakcetLength; | |
| controller.enqueue(udpData); | |
| } | |
| }, | |
| flush(controller) { | |
| } | |
| }); | |
| // only handle dns udp for now | |
| transformStream.readable.pipeTo(new WritableStream({ | |
| async write(chunk) { | |
| const resp = await fetch('https://1.1.1.1/dns-query', | |
| { | |
| method: 'POST', | |
| headers: { | |
| 'content-type': 'application/dns-message', | |
| }, | |
| body: chunk, | |
| }) | |
| const dnsQueryResult = await resp.arrayBuffer(); | |
| const udpSize = dnsQueryResult.byteLength; | |
| // console.log([...new Uint8Array(dnsQueryResult)].map((x) => x.toString(16))); | |
| const udpSizeBuffer = new Uint8Array([(udpSize >> 8) & 0xff, udpSize & 0xff]); | |
| if (webSocket.readyState === WS_READY_STATE_OPEN) { | |
| log(`doh success and dns message length is ${udpSize}`); | |
| if (isVlessHeaderSent) { | |
| webSocket.send(await new Blob([udpSizeBuffer, dnsQueryResult]).arrayBuffer()); | |
| } else { | |
| webSocket.send(await new Blob([vlessResponseHeader, udpSizeBuffer, dnsQueryResult]).arrayBuffer()); | |
| isVlessHeaderSent = true; | |
| } | |
| } | |
| } | |
| })).catch((error) => { | |
| log('dns udp has error' + error) | |
| }); | |
| const writer = transformStream.writable.getWriter(); | |
| return { | |
| /** | |
| * | |
| * @param {Uint8Array} chunk | |
| */ | |
| write(chunk) { | |
| writer.write(chunk); | |
| } | |
| }; | |
| } | |
| /** | |
| * | |
| * @param {string} userID | |
| * @param {string | null} hostName | |
| * @returns {string} | |
| */ | |
| function getVLESSConfig(userID, hostName) { | |
| const protocol = "vless"; | |
| const vlessMain = | |
| `${protocol}` + | |
| `://${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} | |
| --------------------------------------------------------------- | |
| ################################################################ | |
| `; | |
| } | |