Spaces:
Paused
Paused
| const { EventEmitter } = require('node:events') | |
| const { Buffer } = require('node:buffer') | |
| const { InvalidArgumentError, Socks5ProxyError } = require('./errors') | |
| const { debuglog } = require('node:util') | |
| const { parseAddress } = require('./socks5-utils') | |
| const debug = debuglog('undici:socks5') | |
| const EMPTY_BUFFER = Buffer.alloc(0) | |
| // SOCKS5 constants | |
| const SOCKS_VERSION = 0x05 | |
| // Authentication methods | |
| const AUTH_METHODS = { | |
| NO_AUTH: 0x00, | |
| GSSAPI: 0x01, | |
| USERNAME_PASSWORD: 0x02, | |
| NO_ACCEPTABLE: 0xFF | |
| } | |
| // SOCKS5 commands | |
| const COMMANDS = { | |
| CONNECT: 0x01, | |
| BIND: 0x02, | |
| UDP_ASSOCIATE: 0x03 | |
| } | |
| // Address types | |
| const ADDRESS_TYPES = { | |
| IPV4: 0x01, | |
| DOMAIN: 0x03, | |
| IPV6: 0x04 | |
| } | |
| // Reply codes | |
| const REPLY_CODES = { | |
| SUCCEEDED: 0x00, | |
| GENERAL_FAILURE: 0x01, | |
| CONNECTION_NOT_ALLOWED: 0x02, | |
| NETWORK_UNREACHABLE: 0x03, | |
| HOST_UNREACHABLE: 0x04, | |
| CONNECTION_REFUSED: 0x05, | |
| TTL_EXPIRED: 0x06, | |
| COMMAND_NOT_SUPPORTED: 0x07, | |
| ADDRESS_TYPE_NOT_SUPPORTED: 0x08 | |
| } | |
| // State machine states | |
| const STATES = { | |
| INITIAL: 'initial', | |
| HANDSHAKING: 'handshaking', | |
| AUTHENTICATING: 'authenticating', | |
| AUTHENTICATED: 'authenticated', | |
| CONNECTING: 'connecting', | |
| CONNECTED: 'connected', | |
| ERROR: 'error', | |
| CLOSED: 'closed' | |
| } | |
| /** | |
| * SOCKS5 client implementation | |
| * Handles SOCKS5 protocol negotiation and connection establishment | |
| */ | |
| class Socks5Client extends EventEmitter { | |
| constructor (socket, options = {}) { | |
| super() | |
| if (!socket) { | |
| throw new InvalidArgumentError('socket is required') | |
| } | |
| this.socket = socket | |
| this.options = options | |
| this.state = STATES.INITIAL | |
| this.buffer = EMPTY_BUFFER | |
| this.onSocketData = this.onData.bind(this) | |
| this.onSocketError = this.onError.bind(this) | |
| this.onSocketClose = this.onClose.bind(this) | |
| // Authentication settings | |
| this.authMethods = [] | |
| if (options.username && options.password) { | |
| this.authMethods.push(AUTH_METHODS.USERNAME_PASSWORD) | |
| } | |
| this.authMethods.push(AUTH_METHODS.NO_AUTH) | |
| // Socket event handlers | |
| this.socket.on('data', this.onSocketData) | |
| this.socket.on('error', this.onSocketError) | |
| this.socket.on('close', this.onSocketClose) | |
| } | |
| /** | |
| * Handle incoming data from the socket | |
| */ | |
| onData (data) { | |
| debug('received data', data.length, 'bytes in state', this.state) | |
| this.buffer = Buffer.concat([this.buffer, data]) | |
| try { | |
| switch (this.state) { | |
| case STATES.HANDSHAKING: | |
| this.handleHandshakeResponse() | |
| break | |
| case STATES.AUTHENTICATING: | |
| this.handleAuthResponse() | |
| break | |
| case STATES.CONNECTING: | |
| this.handleConnectResponse() | |
| break | |
| } | |
| } catch (err) { | |
| this.onError(err) | |
| } | |
| } | |
| /** | |
| * Handle socket errors | |
| */ | |
| onError (err) { | |
| debug('socket error', err) | |
| this.state = STATES.ERROR | |
| this.emit('error', err) | |
| this.destroy() | |
| } | |
| /** | |
| * Handle socket close | |
| */ | |
| onClose () { | |
| debug('socket closed') | |
| this.state = STATES.CLOSED | |
| this.emit('close') | |
| } | |
| /** | |
| * Destroy the client and underlying socket | |
| */ | |
| destroy () { | |
| if (this.socket && !this.socket.destroyed) { | |
| this.socket.destroy() | |
| } | |
| } | |
| markAuthenticated () { | |
| this.state = STATES.AUTHENTICATED | |
| this.emit('authenticated') | |
| } | |
| /** | |
| * Start the SOCKS5 handshake | |
| */ | |
| handshake () { | |
| if (this.state !== STATES.INITIAL) { | |
| throw new InvalidArgumentError('Handshake already started') | |
| } | |
| debug('starting handshake with', this.authMethods.length, 'auth methods') | |
| this.state = STATES.HANDSHAKING | |
| // Build handshake request | |
| // +----+----------+----------+ | |
| // |VER | NMETHODS | METHODS | | |
| // +----+----------+----------+ | |
| // | 1 | 1 | 1 to 255 | | |
| // +----+----------+----------+ | |
| const request = Buffer.alloc(2 + this.authMethods.length) | |
| request[0] = SOCKS_VERSION | |
| request[1] = this.authMethods.length | |
| this.authMethods.forEach((method, i) => { | |
| request[2 + i] = method | |
| }) | |
| this.socket.write(request) | |
| } | |
| /** | |
| * Handle handshake response from server | |
| */ | |
| handleHandshakeResponse () { | |
| if (this.buffer.length < 2) { | |
| return // Not enough data yet | |
| } | |
| const version = this.buffer[0] | |
| const method = this.buffer[1] | |
| if (version !== SOCKS_VERSION) { | |
| throw new Socks5ProxyError(`Invalid SOCKS version: ${version}`, 'UND_ERR_SOCKS5_VERSION') | |
| } | |
| if (method === AUTH_METHODS.NO_ACCEPTABLE) { | |
| throw new Socks5ProxyError('No acceptable authentication method', 'UND_ERR_SOCKS5_AUTH_REJECTED') | |
| } | |
| this.buffer = this.buffer.subarray(2) | |
| debug('server selected auth method', method) | |
| if (method === AUTH_METHODS.NO_AUTH) { | |
| this.markAuthenticated() | |
| } else if (method === AUTH_METHODS.USERNAME_PASSWORD) { | |
| this.state = STATES.AUTHENTICATING | |
| this.sendAuthRequest() | |
| } else { | |
| throw new Socks5ProxyError(`Unsupported authentication method: ${method}`, 'UND_ERR_SOCKS5_AUTH_METHOD') | |
| } | |
| } | |
| /** | |
| * Send username/password authentication request | |
| */ | |
| sendAuthRequest () { | |
| const { username, password } = this.options | |
| if (!username || !password) { | |
| throw new InvalidArgumentError('Username and password required for authentication') | |
| } | |
| debug('sending username/password auth') | |
| // Username/Password authentication request (RFC 1929) | |
| // +----+------+----------+------+----------+ | |
| // |VER | ULEN | UNAME | PLEN | PASSWD | | |
| // +----+------+----------+------+----------+ | |
| // | 1 | 1 | 1 to 255 | 1 | 1 to 255 | | |
| // +----+------+----------+------+----------+ | |
| const usernameBuffer = Buffer.from(username) | |
| const passwordBuffer = Buffer.from(password) | |
| if (usernameBuffer.length > 255 || passwordBuffer.length > 255) { | |
| throw new InvalidArgumentError('Username or password too long') | |
| } | |
| const request = Buffer.alloc(3 + usernameBuffer.length + passwordBuffer.length) | |
| request[0] = 0x01 // Sub-negotiation version | |
| request[1] = usernameBuffer.length | |
| usernameBuffer.copy(request, 2) | |
| request[2 + usernameBuffer.length] = passwordBuffer.length | |
| passwordBuffer.copy(request, 3 + usernameBuffer.length) | |
| this.socket.write(request) | |
| } | |
| /** | |
| * Handle authentication response | |
| */ | |
| handleAuthResponse () { | |
| if (this.buffer.length < 2) { | |
| return // Not enough data yet | |
| } | |
| const version = this.buffer[0] | |
| const status = this.buffer[1] | |
| if (version !== 0x01) { | |
| throw new Socks5ProxyError(`Invalid auth sub-negotiation version: ${version}`, 'UND_ERR_SOCKS5_AUTH_VERSION') | |
| } | |
| if (status !== 0x00) { | |
| throw new Socks5ProxyError('Authentication failed', 'UND_ERR_SOCKS5_AUTH_FAILED') | |
| } | |
| this.buffer = this.buffer.subarray(2) | |
| debug('authentication successful') | |
| this.markAuthenticated() | |
| } | |
| /** | |
| * Send CONNECT command | |
| * @param {string} address - Target address (IP or domain) | |
| * @param {number} port - Target port | |
| */ | |
| connect (address, port) { | |
| if (this.state === STATES.CONNECTING || this.state === STATES.CONNECTED) { | |
| throw new InvalidArgumentError('Connection already in progress') | |
| } | |
| if (this.state !== STATES.AUTHENTICATED) { | |
| throw new InvalidArgumentError('Client must be authenticated before CONNECT') | |
| } | |
| debug('connecting to', address, port) | |
| this.state = STATES.CONNECTING | |
| const request = this.buildConnectRequest(COMMANDS.CONNECT, address, port) | |
| this.socket.write(request) | |
| } | |
| /** | |
| * Build a SOCKS5 request | |
| */ | |
| buildConnectRequest (command, address, port) { | |
| // Parse address to determine type and buffer | |
| const { type: addressType, buffer: addressBuffer } = parseAddress(address) | |
| // Build request | |
| // +----+-----+-------+------+----------+----------+ | |
| // |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | | |
| // +----+-----+-------+------+----------+----------+ | |
| // | 1 | 1 | X'00' | 1 | Variable | 2 | | |
| // +----+-----+-------+------+----------+----------+ | |
| const request = Buffer.alloc(4 + addressBuffer.length + 2) | |
| request[0] = SOCKS_VERSION | |
| request[1] = command | |
| request[2] = 0x00 // Reserved | |
| request[3] = addressType | |
| addressBuffer.copy(request, 4) | |
| request.writeUInt16BE(port, 4 + addressBuffer.length) | |
| return request | |
| } | |
| /** | |
| * Handle CONNECT response | |
| */ | |
| handleConnectResponse () { | |
| if (this.buffer.length < 4) { | |
| return // Not enough data for header | |
| } | |
| const version = this.buffer[0] | |
| const reply = this.buffer[1] | |
| const addressType = this.buffer[3] | |
| if (version !== SOCKS_VERSION) { | |
| throw new Socks5ProxyError(`Invalid SOCKS version in reply: ${version}`, 'UND_ERR_SOCKS5_REPLY_VERSION') | |
| } | |
| // Calculate the expected response length | |
| let responseLength = 4 // VER + REP + RSV + ATYP | |
| if (addressType === ADDRESS_TYPES.IPV4) { | |
| responseLength += 4 + 2 // IPv4 + port | |
| } else if (addressType === ADDRESS_TYPES.DOMAIN) { | |
| if (this.buffer.length < 5) { | |
| return // Need domain length byte | |
| } | |
| responseLength += 1 + this.buffer[4] + 2 // length byte + domain + port | |
| } else if (addressType === ADDRESS_TYPES.IPV6) { | |
| responseLength += 16 + 2 // IPv6 + port | |
| } else { | |
| throw new Socks5ProxyError(`Invalid address type in reply: ${addressType}`, 'UND_ERR_SOCKS5_ADDR_TYPE') | |
| } | |
| if (this.buffer.length < responseLength) { | |
| return // Not enough data for full response | |
| } | |
| if (reply !== REPLY_CODES.SUCCEEDED) { | |
| const errorMessage = this.getReplyErrorMessage(reply) | |
| throw new Socks5ProxyError(`SOCKS5 connection failed: ${errorMessage}`, `UND_ERR_SOCKS5_REPLY_${reply}`) | |
| } | |
| // Parse bound address and port | |
| let boundAddress | |
| let offset = 4 | |
| if (addressType === ADDRESS_TYPES.IPV4) { | |
| boundAddress = Array.from(this.buffer.subarray(offset, offset + 4)).join('.') | |
| offset += 4 | |
| } else if (addressType === ADDRESS_TYPES.DOMAIN) { | |
| const domainLength = this.buffer[offset] | |
| offset += 1 | |
| boundAddress = this.buffer.subarray(offset, offset + domainLength).toString() | |
| offset += domainLength | |
| } else if (addressType === ADDRESS_TYPES.IPV6) { | |
| // Parse IPv6 address from 16-byte buffer | |
| const parts = [] | |
| for (let i = 0; i < 8; i++) { | |
| const value = this.buffer.readUInt16BE(offset + i * 2) | |
| parts.push(value.toString(16)) | |
| } | |
| boundAddress = parts.join(':') | |
| offset += 16 | |
| } | |
| const boundPort = this.buffer.readUInt16BE(offset) | |
| this.buffer = EMPTY_BUFFER | |
| this.state = STATES.CONNECTED | |
| this.socket.removeListener('data', this.onSocketData) | |
| debug('connected, bound address:', boundAddress, 'port:', boundPort) | |
| this.emit('connected', { address: boundAddress, port: boundPort }) | |
| } | |
| /** | |
| * Get human-readable error message for reply code | |
| */ | |
| getReplyErrorMessage (reply) { | |
| switch (reply) { | |
| case REPLY_CODES.GENERAL_FAILURE: | |
| return 'General SOCKS server failure' | |
| case REPLY_CODES.CONNECTION_NOT_ALLOWED: | |
| return 'Connection not allowed by ruleset' | |
| case REPLY_CODES.NETWORK_UNREACHABLE: | |
| return 'Network unreachable' | |
| case REPLY_CODES.HOST_UNREACHABLE: | |
| return 'Host unreachable' | |
| case REPLY_CODES.CONNECTION_REFUSED: | |
| return 'Connection refused' | |
| case REPLY_CODES.TTL_EXPIRED: | |
| return 'TTL expired' | |
| case REPLY_CODES.COMMAND_NOT_SUPPORTED: | |
| return 'Command not supported' | |
| case REPLY_CODES.ADDRESS_TYPE_NOT_SUPPORTED: | |
| return 'Address type not supported' | |
| default: | |
| return `Unknown error code: ${reply}` | |
| } | |
| } | |
| } | |
| module.exports = { | |
| Socks5Client, | |
| AUTH_METHODS, | |
| COMMANDS, | |
| ADDRESS_TYPES, | |
| REPLY_CODES, | |
| STATES | |
| } | |