Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/docs/api/Socks5ProxyAgent.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ added: v7.23.0
a password embedded in `proxyUrl`. **Default:** the URL password, if any.
* `connect` {Function} Custom connector used to open the socket to the proxy.
**Default:** a connector built from `proxyTls`.
* `connectTimeout` {number} Maximum time in milliseconds for each proxy
connection, SOCKS5 negotiation, and target TLS negotiation stage. A value of
`0` disables these stage timeouts. `proxyTls.timeout` and
`requestTls.timeout` override it for their respective TLS stages.
**Default:** `5000`.
* `proxyTls` {BuildOptions} TLS options for the connection to the proxy itself
(SOCKS5 over TLS). When set, the proxy connection is established over TLS and
`servername` defaults to the proxy host name.
Expand All @@ -69,7 +74,8 @@ added: v7.23.0
host name.

Throws an `InvalidArgumentError` if `proxyUrl` is missing or does not use the
`socks5:` or `socks:` protocol.
`socks5:` or `socks:` protocol, or if `connectTimeout`, `proxyTls.timeout`, or
`requestTls.timeout` is not a finite, non-negative number.

```mjs
import { Socks5ProxyAgent } from 'undici'
Expand Down
1 change: 1 addition & 0 deletions lib/dispatcher/proxy-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ class ProxyAgent extends DispatcherBase {
factory: agentFactory,
username: opts.username || username,
password: opts.password || password,
connectTimeout,
proxyTls: opts.proxyTls,
requestTls: opts.requestTls
})
Expand Down
112 changes: 90 additions & 22 deletions lib/dispatcher/socks5-proxy-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,33 @@ 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 { ConnectTimeoutError, InvalidArgumentError } = require('../core/errors')
const { Socks5Client, STATES } = require('../core/socks5-client')
const { kBusy, kConnected, kDispatch, kClose, kDestroy } = require('../core/symbols')
const Pool = require('./pool')
const buildConnector = require('../core/connect')
const { setupConnectTimeout } = require('../core/util')
const { debuglog } = require('node:util')

const debug = debuglog('undici:socks5-proxy')

const DEFAULT_SOCKS5_CONNECT_TIMEOUT = 5000

const kProxyUrl = Symbol('proxy url')
const kProxyHeaders = Symbol('proxy headers')
const kProxyAuth = Symbol('proxy auth')
const kProxyProtocol = Symbol('proxy protocol')
const kPools = Symbol('pools')
const kConnector = Symbol('connector')
const kConnectTimeout = Symbol('connect timeout')
const kRequestTls = Symbol('request tls settings')
const kRequestTlsTimeout = Symbol('request tls timeout')

function createConnectTimeoutError (hostname, port, timeout) {
return new ConnectTimeoutError(
`Connect Timeout Error (attempted address: ${hostname}:${port}, timeout: ${timeout}ms)`
)
}

// Static flag to ensure warning is only emitted once per process
let experimentalWarningEmitted = false
Expand Down Expand Up @@ -54,7 +65,20 @@ class Socks5ProxyAgent extends DispatcherBase {
this[kProxyUrl] = url
this[kProxyHeaders] = options.headers || {}
this[kProxyProtocol] = options.proxyTls ? 'https:' : 'http:'
this[kRequestTls] = options.requestTls

const connectTimeout = options.connectTimeout ?? DEFAULT_SOCKS5_CONNECT_TIMEOUT
if (!Number.isFinite(connectTimeout) || connectTimeout < 0) {
throw new InvalidArgumentError('invalid connectTimeout')
}
this[kConnectTimeout] = connectTimeout

const { timeout, ...requestTls } = options.requestTls || {}
const requestTlsTimeout = timeout ?? connectTimeout
if (!Number.isFinite(requestTlsTimeout) || requestTlsTimeout < 0) {
throw new InvalidArgumentError('invalid requestTls.timeout')
}
this[kRequestTls] = requestTls
this[kRequestTlsTimeout] = requestTlsTimeout

// Extract auth from URL or options
this[kProxyAuth] = {
Expand All @@ -63,8 +87,13 @@ class Socks5ProxyAgent extends DispatcherBase {
}

// Create connector for proxy connection
const proxyTlsTimeout = options.proxyTls?.timeout ?? connectTimeout
if (!Number.isFinite(proxyTlsTimeout) || proxyTlsTimeout < 0) {
throw new InvalidArgumentError('invalid proxyTls.timeout')
}
this[kConnector] = options.connect || buildConnector({
...options.proxyTls,
timeout: proxyTlsTimeout,
servername: options.proxyTls?.servername || url.hostname
})

Expand Down Expand Up @@ -113,21 +142,29 @@ class Socks5ProxyAgent extends DispatcherBase {

// Wait for authentication (if required)
const authenticationReady = Promise.withResolvers()

const authenticationTimeout = setTimeout(() => {
socks5Client.destroy()
authenticationReady.reject(new Error('SOCKS5 authentication timeout'))
}, 5000)

const onAuthenticated = () => {
const authenticationTimeout = this[kConnectTimeout] === 0
? null
: setTimeout(() => {
cleanupAuthenticationListeners()
socks5Client.destroy()
authenticationReady.reject(
createConnectTimeoutError(proxyHost, proxyPort, this[kConnectTimeout])
)
}, this[kConnectTimeout])

const cleanupAuthenticationListeners = () => {
clearTimeout(authenticationTimeout)
socks5Client.removeListener('authenticated', onAuthenticated)
socks5Client.removeListener('error', onAuthenticationError)
}

const onAuthenticated = () => {
cleanupAuthenticationListeners()
authenticationReady.resolve()
}

const onAuthenticationError = (err) => {
clearTimeout(authenticationTimeout)
socks5Client.removeListener('authenticated', onAuthenticated)
cleanupAuthenticationListeners()
authenticationReady.reject(err)
}

Expand All @@ -147,22 +184,30 @@ class Socks5ProxyAgent extends DispatcherBase {

// Wait for connection
const connectionReady = Promise.withResolvers()

const connectionTimeout = setTimeout(() => {
socks5Client.destroy()
connectionReady.reject(new Error('SOCKS5 connection timeout'))
}, 5000)
const connectionTimeout = this[kConnectTimeout] === 0
? null
: setTimeout(() => {
cleanupConnectionListeners()
socks5Client.destroy()
connectionReady.reject(
createConnectTimeoutError(targetHost, targetPort, this[kConnectTimeout])
)
}, this[kConnectTimeout])

const cleanupConnectionListeners = () => {
clearTimeout(connectionTimeout)
socks5Client.removeListener('connected', onConnected)
socks5Client.removeListener('error', onConnectionError)
}

const onConnected = (info) => {
debug('SOCKS5 tunnel established to', targetHost, targetPort, 'via', info)
clearTimeout(connectionTimeout)
socks5Client.removeListener('error', onConnectionError)
cleanupConnectionListeners()
connectionReady.resolve()
}

const onConnectionError = (err) => {
clearTimeout(connectionTimeout)
socks5Client.removeListener('connected', onConnected)
cleanupConnectionListeners()
connectionReady.reject(err)
}

Expand Down Expand Up @@ -215,8 +260,31 @@ class Socks5ProxyAgent extends DispatcherBase {
})

const tlsReady = Promise.withResolvers()
finalSocket.once('secureConnect', tlsReady.resolve)
finalSocket.once('error', tlsReady.reject)

const cleanupTlsListeners = () => {
queueMicrotask(clearTlsTimeout)
finalSocket.removeListener('secureConnect', onSecureConnect)
finalSocket.removeListener('error', onTlsError)
}

const onSecureConnect = () => {
cleanupTlsListeners()
tlsReady.resolve()
}

const onTlsError = (err) => {
cleanupTlsListeners()
tlsReady.reject(err)
}

const clearTlsTimeout = setupConnectTimeout(new WeakRef(finalSocket), {
timeout: this[kRequestTlsTimeout],
hostname: targetHost,
port: targetPort
})

finalSocket.once('secureConnect', onSecureConnect)
finalSocket.once('error', onTlsError)
await tlsReady.promise
}

Expand Down
Loading
Loading