feat: initial commit of Railtrack Pro prototype with complete test suite
This commit is contained in:
+407
@@ -0,0 +1,407 @@
|
||||
'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
|
||||
}
|
||||
Reference in New Issue
Block a user