feat: initial commit of Railtrack Pro prototype with complete test suite
This commit is contained in:
+249
@@ -0,0 +1,249 @@
|
||||
'use strict'
|
||||
|
||||
const net = require('node:net')
|
||||
const { URL } = require('node:url')
|
||||
|
||||
let tls // include tls conditionally since it is not always available
|
||||
const DispatcherBase = require('./dispatcher-base')
|
||||
const { InvalidArgumentError } = require('../core/errors')
|
||||
const { Socks5Client } = require('../core/socks5-client')
|
||||
const { kDispatch, kClose, kDestroy } = require('../core/symbols')
|
||||
const Pool = require('./pool')
|
||||
const buildConnector = require('../core/connect')
|
||||
const { debuglog } = require('node:util')
|
||||
|
||||
const debug = debuglog('undici:socks5-proxy')
|
||||
|
||||
const kProxyUrl = Symbol('proxy url')
|
||||
const kProxyHeaders = Symbol('proxy headers')
|
||||
const kProxyAuth = Symbol('proxy auth')
|
||||
const kPool = Symbol('pool')
|
||||
const kConnector = Symbol('connector')
|
||||
|
||||
// Static flag to ensure warning is only emitted once per process
|
||||
let experimentalWarningEmitted = false
|
||||
|
||||
/**
|
||||
* SOCKS5 proxy agent for dispatching requests through a SOCKS5 proxy
|
||||
*/
|
||||
class Socks5ProxyAgent extends DispatcherBase {
|
||||
constructor (proxyUrl, options = {}) {
|
||||
super()
|
||||
|
||||
// Emit experimental warning only once
|
||||
if (!experimentalWarningEmitted) {
|
||||
process.emitWarning(
|
||||
'SOCKS5 proxy support is experimental and subject to change',
|
||||
'ExperimentalWarning'
|
||||
)
|
||||
experimentalWarningEmitted = true
|
||||
}
|
||||
|
||||
if (!proxyUrl) {
|
||||
throw new InvalidArgumentError('Proxy URL is mandatory')
|
||||
}
|
||||
|
||||
// Parse proxy URL
|
||||
const url = typeof proxyUrl === 'string' ? new URL(proxyUrl) : proxyUrl
|
||||
|
||||
if (url.protocol !== 'socks5:' && url.protocol !== 'socks:') {
|
||||
throw new InvalidArgumentError('Proxy URL must use socks5:// or socks:// protocol')
|
||||
}
|
||||
|
||||
this[kProxyUrl] = url
|
||||
this[kProxyHeaders] = options.headers || {}
|
||||
|
||||
// Extract auth from URL or options
|
||||
this[kProxyAuth] = {
|
||||
username: options.username || (url.username ? decodeURIComponent(url.username) : null),
|
||||
password: options.password || (url.password ? decodeURIComponent(url.password) : null)
|
||||
}
|
||||
|
||||
// Create connector for proxy connection
|
||||
this[kConnector] = options.connect || buildConnector({
|
||||
...options.proxyTls,
|
||||
servername: options.proxyTls?.servername || url.hostname
|
||||
})
|
||||
|
||||
// Pool for the actual HTTP connections (with SOCKS5 tunnel connect function)
|
||||
this[kPool] = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a SOCKS5 connection to the proxy
|
||||
*/
|
||||
async createSocks5Connection (targetHost, targetPort) {
|
||||
const proxyHost = this[kProxyUrl].hostname
|
||||
const proxyPort = parseInt(this[kProxyUrl].port) || 1080
|
||||
|
||||
debug('creating SOCKS5 connection to', proxyHost, proxyPort)
|
||||
|
||||
// Connect to the SOCKS5 proxy
|
||||
const socket = await new Promise((resolve, reject) => {
|
||||
const onConnect = () => {
|
||||
socket.removeListener('error', onError)
|
||||
resolve(socket)
|
||||
}
|
||||
|
||||
const onError = (err) => {
|
||||
socket.removeListener('connect', onConnect)
|
||||
reject(err)
|
||||
}
|
||||
|
||||
const socket = net.connect({
|
||||
host: proxyHost,
|
||||
port: proxyPort
|
||||
})
|
||||
|
||||
socket.once('connect', onConnect)
|
||||
socket.once('error', onError)
|
||||
})
|
||||
|
||||
// Create SOCKS5 client
|
||||
const socks5Client = new Socks5Client(socket, this[kProxyAuth])
|
||||
|
||||
// Handle SOCKS5 errors
|
||||
socks5Client.on('error', (err) => {
|
||||
debug('SOCKS5 error:', err)
|
||||
socket.destroy()
|
||||
})
|
||||
|
||||
// Perform SOCKS5 handshake
|
||||
await socks5Client.handshake()
|
||||
|
||||
// Wait for authentication (if required)
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('SOCKS5 authentication timeout'))
|
||||
}, 5000)
|
||||
|
||||
const onAuthenticated = () => {
|
||||
clearTimeout(timeout)
|
||||
socks5Client.removeListener('error', onError)
|
||||
resolve()
|
||||
}
|
||||
|
||||
const onError = (err) => {
|
||||
clearTimeout(timeout)
|
||||
socks5Client.removeListener('authenticated', onAuthenticated)
|
||||
reject(err)
|
||||
}
|
||||
|
||||
// Check if already authenticated (for NO_AUTH method)
|
||||
if (socks5Client.state === 'authenticated') {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
} else {
|
||||
socks5Client.once('authenticated', onAuthenticated)
|
||||
socks5Client.once('error', onError)
|
||||
}
|
||||
})
|
||||
|
||||
// Send CONNECT command
|
||||
await socks5Client.connect(targetHost, targetPort)
|
||||
|
||||
// Wait for connection
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('SOCKS5 connection timeout'))
|
||||
}, 5000)
|
||||
|
||||
const onConnected = (info) => {
|
||||
debug('SOCKS5 tunnel established to', targetHost, targetPort, 'via', info)
|
||||
clearTimeout(timeout)
|
||||
socks5Client.removeListener('error', onError)
|
||||
resolve()
|
||||
}
|
||||
|
||||
const onError = (err) => {
|
||||
clearTimeout(timeout)
|
||||
socks5Client.removeListener('connected', onConnected)
|
||||
reject(err)
|
||||
}
|
||||
|
||||
socks5Client.once('connected', onConnected)
|
||||
socks5Client.once('error', onError)
|
||||
})
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a request through the SOCKS5 proxy
|
||||
*/
|
||||
async [kDispatch] (opts, handler) {
|
||||
const { origin } = opts
|
||||
|
||||
debug('dispatching request to', origin, 'via SOCKS5')
|
||||
|
||||
try {
|
||||
// Create Pool with custom connect function if we don't have one yet
|
||||
if (!this[kPool] || this[kPool].destroyed || this[kPool].closed) {
|
||||
this[kPool] = new Pool(origin, {
|
||||
pipelining: opts.pipelining,
|
||||
connections: opts.connections,
|
||||
connect: async (connectOpts, callback) => {
|
||||
try {
|
||||
const url = new URL(origin)
|
||||
const targetHost = url.hostname
|
||||
const targetPort = parseInt(url.port) || (url.protocol === 'https:' ? 443 : 80)
|
||||
|
||||
debug('establishing SOCKS5 connection to', targetHost, targetPort)
|
||||
|
||||
// Create SOCKS5 tunnel
|
||||
const socket = await this.createSocks5Connection(targetHost, targetPort)
|
||||
|
||||
// Handle TLS if needed
|
||||
let finalSocket = socket
|
||||
if (url.protocol === 'https:') {
|
||||
if (!tls) {
|
||||
tls = require('node:tls')
|
||||
}
|
||||
debug('upgrading to TLS')
|
||||
finalSocket = tls.connect({
|
||||
socket,
|
||||
servername: targetHost,
|
||||
...connectOpts.tls || {}
|
||||
})
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
finalSocket.once('secureConnect', resolve)
|
||||
finalSocket.once('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
callback(null, finalSocket)
|
||||
} catch (err) {
|
||||
debug('SOCKS5 connection error:', err)
|
||||
callback(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Dispatch the request through the pool
|
||||
return this[kPool][kDispatch](opts, handler)
|
||||
} catch (err) {
|
||||
debug('dispatch error:', err)
|
||||
if (typeof handler.onError === 'function') {
|
||||
handler.onError(err)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async [kClose] () {
|
||||
if (this[kPool]) {
|
||||
await this[kPool].close()
|
||||
}
|
||||
}
|
||||
|
||||
async [kDestroy] (err) {
|
||||
if (this[kPool]) {
|
||||
await this[kPool].destroy(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Socks5ProxyAgent
|
||||
Reference in New Issue
Block a user