socks5-proxy-agent.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. 'use strict'
  2. const { URL } = require('node:url')
  3. let tls // include tls conditionally since it is not always available
  4. const DispatcherBase = require('./dispatcher-base')
  5. const { InvalidArgumentError } = require('../core/errors')
  6. const { Socks5Client, STATES } = require('../core/socks5-client')
  7. const { kDispatch, kClose, kDestroy } = require('../core/symbols')
  8. const Pool = require('./pool')
  9. const buildConnector = require('../core/connect')
  10. const { debuglog } = require('node:util')
  11. const debug = debuglog('undici:socks5-proxy')
  12. const kProxyUrl = Symbol('proxy url')
  13. const kProxyHeaders = Symbol('proxy headers')
  14. const kProxyAuth = Symbol('proxy auth')
  15. const kProxyProtocol = Symbol('proxy protocol')
  16. const kPools = Symbol('pools')
  17. const kConnector = Symbol('connector')
  18. const kRequestTls = Symbol('request tls settings')
  19. // Static flag to ensure warning is only emitted once per process
  20. let experimentalWarningEmitted = false
  21. /**
  22. * SOCKS5 proxy agent for dispatching requests through a SOCKS5 proxy
  23. */
  24. class Socks5ProxyAgent extends DispatcherBase {
  25. constructor (proxyUrl, options = {}) {
  26. super()
  27. // Emit experimental warning only once
  28. if (!experimentalWarningEmitted) {
  29. process.emitWarning(
  30. 'SOCKS5 proxy support is experimental and subject to change',
  31. 'ExperimentalWarning'
  32. )
  33. experimentalWarningEmitted = true
  34. }
  35. if (!proxyUrl) {
  36. throw new InvalidArgumentError('Proxy URL is mandatory')
  37. }
  38. // Parse proxy URL
  39. const url = typeof proxyUrl === 'string' ? new URL(proxyUrl) : proxyUrl
  40. if (url.protocol !== 'socks5:' && url.protocol !== 'socks:') {
  41. throw new InvalidArgumentError('Proxy URL must use socks5:// or socks:// protocol')
  42. }
  43. this[kProxyUrl] = url
  44. this[kProxyHeaders] = options.headers || {}
  45. this[kProxyProtocol] = options.proxyTls ? 'https:' : 'http:'
  46. this[kRequestTls] = options.requestTls
  47. // Extract auth from URL or options
  48. this[kProxyAuth] = {
  49. username: options.username || (url.username ? decodeURIComponent(url.username) : null),
  50. password: options.password || (url.password ? decodeURIComponent(url.password) : null)
  51. }
  52. // Create connector for proxy connection
  53. this[kConnector] = options.connect || buildConnector({
  54. ...options.proxyTls,
  55. servername: options.proxyTls?.servername || url.hostname
  56. })
  57. // Pools for the actual HTTP connections (with SOCKS5 tunnel connect function), keyed by origin
  58. this[kPools] = new Map()
  59. }
  60. /**
  61. * Create a SOCKS5 connection to the proxy
  62. */
  63. async createSocks5Connection (targetHost, targetPort) {
  64. const proxyHost = this[kProxyUrl].hostname
  65. const proxyPort = parseInt(this[kProxyUrl].port) || 1080
  66. debug('creating SOCKS5 connection to', proxyHost, proxyPort)
  67. // Connect to the SOCKS5 proxy
  68. const socket = await new Promise((resolve, reject) => {
  69. this[kConnector]({
  70. hostname: proxyHost,
  71. host: proxyHost,
  72. port: proxyPort,
  73. protocol: this[kProxyProtocol]
  74. }, (err, socket) => {
  75. if (err) {
  76. reject(err)
  77. } else {
  78. resolve(socket)
  79. }
  80. })
  81. })
  82. // Create SOCKS5 client
  83. const socks5Client = new Socks5Client(socket, this[kProxyAuth])
  84. // Handle SOCKS5 errors
  85. socks5Client.on('error', (err) => {
  86. debug('SOCKS5 error:', err)
  87. socket.destroy()
  88. })
  89. // Perform SOCKS5 handshake
  90. await socks5Client.handshake()
  91. // Wait for authentication (if required)
  92. await new Promise((resolve, reject) => {
  93. const timeout = setTimeout(() => {
  94. reject(new Error('SOCKS5 authentication timeout'))
  95. }, 5000)
  96. const onAuthenticated = () => {
  97. clearTimeout(timeout)
  98. socks5Client.removeListener('error', onError)
  99. resolve()
  100. }
  101. const onError = (err) => {
  102. clearTimeout(timeout)
  103. socks5Client.removeListener('authenticated', onAuthenticated)
  104. reject(err)
  105. }
  106. // Check if already authenticated (for NO_AUTH method)
  107. if (socks5Client.state === STATES.AUTHENTICATED) {
  108. clearTimeout(timeout)
  109. resolve()
  110. } else {
  111. socks5Client.once('authenticated', onAuthenticated)
  112. socks5Client.once('error', onError)
  113. }
  114. })
  115. // Send CONNECT command
  116. await socks5Client.connect(targetHost, targetPort)
  117. // Wait for connection
  118. await new Promise((resolve, reject) => {
  119. const timeout = setTimeout(() => {
  120. reject(new Error('SOCKS5 connection timeout'))
  121. }, 5000)
  122. const onConnected = (info) => {
  123. debug('SOCKS5 tunnel established to', targetHost, targetPort, 'via', info)
  124. clearTimeout(timeout)
  125. socks5Client.removeListener('error', onError)
  126. resolve()
  127. }
  128. const onError = (err) => {
  129. clearTimeout(timeout)
  130. socks5Client.removeListener('connected', onConnected)
  131. reject(err)
  132. }
  133. socks5Client.once('connected', onConnected)
  134. socks5Client.once('error', onError)
  135. })
  136. return socket
  137. }
  138. /**
  139. * Dispatch a request through the SOCKS5 proxy
  140. */
  141. [kDispatch] (opts, handler) {
  142. const { origin } = opts
  143. debug('dispatching request to', origin, 'via SOCKS5')
  144. try {
  145. const originKey = String(origin)
  146. let pool = this[kPools].get(originKey)
  147. // Create a Pool per origin so requests are not routed to the wrong host
  148. if (!pool || pool.destroyed || pool.closed) {
  149. pool = new Pool(origin, {
  150. pipelining: opts.pipelining,
  151. connections: opts.connections,
  152. connect: async (connectOpts, callback) => {
  153. try {
  154. const url = new URL(origin)
  155. const targetHost = url.hostname
  156. const targetPort = parseInt(url.port) || (url.protocol === 'https:' ? 443 : 80)
  157. debug('establishing SOCKS5 connection to', targetHost, targetPort)
  158. // Create SOCKS5 tunnel
  159. const socket = await this.createSocks5Connection(targetHost, targetPort)
  160. // Handle TLS if needed
  161. let finalSocket = socket
  162. if (url.protocol === 'https:') {
  163. if (!tls) {
  164. tls = require('node:tls')
  165. }
  166. debug('upgrading to TLS')
  167. finalSocket = tls.connect({
  168. ...this[kRequestTls],
  169. socket,
  170. servername: this[kRequestTls]?.servername || targetHost
  171. })
  172. await new Promise((resolve, reject) => {
  173. finalSocket.once('secureConnect', resolve)
  174. finalSocket.once('error', reject)
  175. })
  176. }
  177. callback(null, finalSocket)
  178. } catch (err) {
  179. debug('SOCKS5 connection error:', err)
  180. callback(err)
  181. }
  182. }
  183. })
  184. this[kPools].set(originKey, pool)
  185. }
  186. // Dispatch the request through the per-origin pool
  187. return pool[kDispatch](opts, handler)
  188. } catch (err) {
  189. debug('dispatch error:', err)
  190. if (typeof handler.onResponseError === 'function') {
  191. handler.onResponseError(null, err)
  192. return false
  193. } else if (typeof handler.onError === 'function') {
  194. handler.onError(err)
  195. return false
  196. } else {
  197. throw err
  198. }
  199. }
  200. }
  201. async [kClose] () {
  202. const closePromises = []
  203. for (const pool of this[kPools].values()) {
  204. closePromises.push(pool.close())
  205. }
  206. this[kPools].clear()
  207. await Promise.all(closePromises)
  208. }
  209. async [kDestroy] (err) {
  210. const destroyPromises = []
  211. for (const pool of this[kPools].values()) {
  212. destroyPromises.push(pool.destroy(err))
  213. }
  214. this[kPools].clear()
  215. await Promise.all(destroyPromises)
  216. }
  217. }
  218. module.exports = Socks5ProxyAgent