UNPKG

undici

Version:

An HTTP/1.1 client, written from scratch for Node.js

408 lines (348 loc) 11.3 kB
'use strict' 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') // 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', 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 = Buffer.alloc(0) // 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.onData.bind(this)) this.socket.on('error', this.onError.bind(this)) this.socket.on('close', this.onClose.bind(this)) } /** * 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() } } /** * 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.emit('authenticated') } 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.emit('authenticated') } /** * Send CONNECT command * @param {string} address - Target address (IP or domain) * @param {number} port - Target port */ connect (address, port) { if (this.state === STATES.CONNECTED) { throw new InvalidArgumentError('Already connected') } 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 = this.buffer.subarray(responseLength) this.state = STATES.CONNECTED 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 }