diff --git a/README.md b/README.md index 15dcad7a3d7..149ae9ec842 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ const response = await fetch('https://api.example.com/data'); - Superior performance, especially with `undici.request` - HTTP/1.1 pipelining support - Custom interceptors and middleware -- Advanced features like `ProxyAgent`, `MockAgent` +- Advanced features like `ProxyAgent`, `Socks5Agent`, `MockAgent` **Cons:** - Additional dependency to manage @@ -122,7 +122,7 @@ const response = await fetch('https://api.example.com/data'); #### Use Undici Module When: - You need the latest undici features and performance improvements - You require advanced connection pooling configuration -- You need APIs not available in the built-in fetch (`ProxyAgent`, `MockAgent`, etc.) +- You need APIs not available in the built-in fetch (`ProxyAgent`, `Socks5Agent`, `MockAgent`, etc.) - Performance is critical (use `undici.request` for maximum speed) - You want better error handling and debugging capabilities - You need HTTP/1.1 pipelining or advanced interceptors diff --git a/docs/docs/api/Socks5ProxyAgent.md b/docs/docs/api/Socks5ProxyAgent.md new file mode 100644 index 00000000000..9f4fa4358e7 --- /dev/null +++ b/docs/docs/api/Socks5ProxyAgent.md @@ -0,0 +1,264 @@ +# Class: Socks5ProxyAgent + +Extends: `undici.Dispatcher` + +A SOCKS5 proxy wrapper class that implements the Dispatcher API. It enables HTTP requests to be routed through a SOCKS5 proxy server, providing connection tunneling and authentication support. + +## `new Socks5ProxyAgent(proxyUrl[, options])` + +Arguments: + +* **proxyUrl** `string | URL` (required) - The SOCKS5 proxy server URL. Must use `socks5://` or `socks://` protocol. +* **options** `Socks5ProxyAgent.Options` (optional) - Additional configuration options. + +Returns: `Socks5ProxyAgent` + +### Parameter: `Socks5ProxyAgent.Options` + +Extends: [`PoolOptions`](/docs/docs/api/Pool.md#parameter-pooloptions) + +* **headers** `IncomingHttpHeaders` (optional) - Additional headers to send with proxy connections. +* **username** `string` (optional) - SOCKS5 proxy username for authentication. Can also be provided in the proxy URL. +* **password** `string` (optional) - SOCKS5 proxy password for authentication. Can also be provided in the proxy URL. +* **connect** `Function` (optional) - Custom connector function for the proxy connection. +* **proxyTls** `BuildOptions` (optional) - TLS options for the proxy connection (when using SOCKS5 over TLS). + +Examples: + +```js +import { Socks5ProxyAgent } from 'undici' + +const socks5Proxy = new Socks5ProxyAgent('socks5://localhost:1080') +// or with authentication +const socks5ProxyWithAuth = new Socks5ProxyAgent('socks5://user:pass@localhost:1080') +// or with options +const socks5ProxyWithOptions = new Socks5ProxyAgent('socks5://localhost:1080', { + username: 'user', + password: 'pass', + connections: 10 +}) +``` + +#### Example - Basic SOCKS5 Proxy instantiation + +This will instantiate the Socks5ProxyAgent. It will not do anything until registered as the dispatcher to use with requests. + +```js +import { Socks5ProxyAgent } from 'undici' + +const socks5Proxy = new Socks5ProxyAgent('socks5://localhost:1080') +``` + +#### Example - Basic SOCKS5 Proxy Request with global dispatcher + +```js +import { setGlobalDispatcher, request, Socks5ProxyAgent } from 'undici' + +const socks5Proxy = new Socks5ProxyAgent('socks5://localhost:1080') +setGlobalDispatcher(socks5Proxy) + +const { statusCode, body } = await request('http://localhost:3000/foo') + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Basic SOCKS5 Proxy Request with local dispatcher + +```js +import { Socks5ProxyAgent, request } from 'undici' + +const socks5Proxy = new Socks5ProxyAgent('socks5://localhost:1080') + +const { + statusCode, + body +} = await request('http://localhost:3000/foo', { dispatcher: socks5Proxy }) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - SOCKS5 Proxy Request with authentication + +```js +import { setGlobalDispatcher, request, Socks5ProxyAgent } from 'undici' + +// Authentication via URL +const socks5Proxy = new Socks5ProxyAgent('socks5://username:password@localhost:1080') + +// Or authentication via options +// const socks5Proxy = new Socks5ProxyAgent('socks5://localhost:1080', { +// username: 'username', +// password: 'password' +// }) + +setGlobalDispatcher(socks5Proxy) + +const { statusCode, body } = await request('http://localhost:3000/foo') + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - SOCKS5 Proxy with HTTPS requests + +SOCKS5 proxy supports both HTTP and HTTPS requests through tunneling: + +```js +import { Socks5ProxyAgent, request } from 'undici' + +const socks5Proxy = new Socks5ProxyAgent('socks5://localhost:1080') + +const response = await request('https://api.example.com/data', { + dispatcher: socks5Proxy, + method: 'GET' +}) + +console.log('Response status:', response.statusCode) +console.log('Response data:', await response.body.json()) +``` + +#### Example - SOCKS5 Proxy with Fetch + +```js +import { Socks5ProxyAgent, fetch } from 'undici' + +const socks5Proxy = new Socks5ProxyAgent('socks5://localhost:1080') + +const response = await fetch('http://localhost:3000/api/users', { + dispatcher: socks5Proxy, + method: 'GET' +}) + +console.log('Response status:', response.status) +console.log('Response data:', await response.text()) +``` + +#### Example - Connection Pooling + +SOCKS5ProxyWrapper automatically manages connection pooling for better performance: + +```js +import { Socks5ProxyAgent, request } from 'undici' + +const socks5Proxy = new Socks5ProxyAgent('socks5://localhost:1080', { + connections: 10, // Allow up to 10 concurrent connections + pipelining: 1 // Enable HTTP/1.1 pipelining +}) + +// Multiple requests will reuse connections through the SOCKS5 tunnel +const responses = await Promise.all([ + request('http://api.example.com/endpoint1', { dispatcher: socks5Proxy }), + request('http://api.example.com/endpoint2', { dispatcher: socks5Proxy }), + request('http://api.example.com/endpoint3', { dispatcher: socks5Proxy }) +]) + +console.log('All requests completed through the same SOCKS5 proxy') +``` + +### `Socks5ProxyAgent.close()` + +Closes the SOCKS5 proxy wrapper and waits for all underlying pools and connections to close before resolving. + +Returns: `Promise` + +#### Example - clean up after tests are complete + +```js +import { Socks5ProxyAgent, setGlobalDispatcher } from 'undici' + +const socks5Proxy = new Socks5ProxyAgent('socks5://localhost:1080') +setGlobalDispatcher(socks5Proxy) + +// ... make requests + +await socks5Proxy.close() +``` + +### `Socks5ProxyAgent.destroy([err])` + +Destroys the SOCKS5 proxy wrapper and all underlying connections immediately. + +Arguments: +* **err** `Error` (optional) - The error that caused the destruction. + +Returns: `Promise` + +#### Example - force close all connections + +```js +import { Socks5ProxyAgent } from 'undici' + +const socks5Proxy = new Socks5ProxyAgent('socks5://localhost:1080') + +// Force close all connections +await socks5Proxy.destroy() +``` + +### `Socks5ProxyAgent.dispatch(options, handlers)` + +Implements [`Dispatcher.dispatch(options, handlers)`](/docs/docs/api/Dispatcher.md#dispatcherdispatchoptions-handlers). + +### `Socks5ProxyAgent.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](/docs/docs/api/Dispatcher.md#dispatcherrequestoptions-callback). + +## SOCKS5 Protocol Support + +The Socks5ProxyAgent supports the following SOCKS5 features: + +### Authentication Methods + +- **No Authentication** (`0x00`) - For public or internal proxies +- **Username/Password** (`0x02`) - RFC 1929 authentication + +### Address Types + +- **IPv4** (`0x01`) - Standard IPv4 addresses +- **Domain Name** (`0x03`) - Domain names (recommended for flexibility) +- **IPv6** (`0x04`) - IPv6 addresses (full support for standard and compressed notation) + +### Commands + +- **CONNECT** (`0x01`) - Establish TCP connection (primary use case for HTTP) + +### Error Handling + +The wrapper handles various SOCKS5 error conditions: + +- Connection refused by proxy +- Authentication failures +- Network unreachable +- Host unreachable +- Unsupported address types or commands + +## Performance Considerations + +- **Connection Pooling**: Automatically pools connections through the SOCKS5 tunnel for better performance +- **HTTP/1.1 Pipelining**: Supports pipelining when enabled +- **DNS Resolution**: Domain names are resolved by the SOCKS5 proxy, reducing local DNS queries +- **TLS Termination**: HTTPS connections are encrypted end-to-end, with the SOCKS5 proxy only handling the TCP tunnel + +## Security Notes + +1. **Authentication**: Credentials are sent to the SOCKS5 proxy in plaintext unless using SOCKS5 over TLS +2. **DNS Leaks**: All DNS resolution happens on the proxy server, preventing DNS leaks +3. **End-to-end Encryption**: HTTPS traffic remains encrypted between client and final destination +4. **Connection Security**: Consider using authenticated proxies and secure networks + +## Compatibility + +- **Protocol**: SOCKS5 (RFC 1928) with Username/Password Authentication (RFC 1929) +- **Transport**: TCP only (UDP support not implemented) +- **Node.js**: Compatible with all supported Node.js versions +- **HTTP Versions**: Works with HTTP/1.1 and HTTP/2 over the tunnel \ No newline at end of file diff --git a/docs/docsify/sidebar.md b/docs/docsify/sidebar.md index f0fb898fde2..3eef42c0722 100644 --- a/docs/docsify/sidebar.md +++ b/docs/docsify/sidebar.md @@ -10,6 +10,7 @@ * [RoundRobinPool](/docs/api/RoundRobinPool.md "Undici API - RoundRobinPool") * [Agent](/docs/api/Agent.md "Undici API - Agent") * [ProxyAgent](/docs/api/ProxyAgent.md "Undici API - ProxyAgent") + * [Socks5Agent](/docs/api/Socks5Agent.md "Undici API - SOCKS5 Agent") * [RetryAgent](/docs/api/RetryAgent.md "Undici API - RetryAgent") * [Connector](/docs/api/Connector.md "Custom connector") * [Errors](/docs/api/Errors.md "Undici API - Errors") diff --git a/docs/examples/socks5-proxy.js b/docs/examples/socks5-proxy.js new file mode 100644 index 00000000000..15add067933 --- /dev/null +++ b/docs/examples/socks5-proxy.js @@ -0,0 +1,212 @@ +'use strict' + +const { Socks5Agent, request, fetch } = require('undici') + +// Basic example demonstrating SOCKS5 proxy usage +async function basicSocks5Example () { + console.log('=== Basic SOCKS5 Proxy Example ===') + + try { + // Create SOCKS5 proxy wrapper + const socks5Proxy = new Socks5Agent('socks5://localhost:1080') + + // Make request through SOCKS5 proxy + const response = await request('http://httpbin.org/ip', { + dispatcher: socks5Proxy + }) + + console.log('Status:', response.statusCode) + const body = await response.body.json() + console.log('Response:', body) + + await socks5Proxy.close() + } catch (error) { + console.error('Error:', error.message) + } +} + +// Example with authentication +async function authenticatedSocks5Example () { + console.log('\n=== Authenticated SOCKS5 Proxy Example ===') + + try { + // Using credentials in URL + const socks5Proxy = new Socks5Agent('socks5://username:password@localhost:1080') + + // Alternative: using options + // const socks5Proxy = new Socks5Agent('socks5://localhost:1080', { + // username: 'username', + // password: 'password' + // }) + + const response = await request('http://httpbin.org/headers', { + dispatcher: socks5Proxy + }) + + console.log('Status:', response.statusCode) + const body = await response.body.json() + console.log('Headers seen by server:', body.headers) + + await socks5Proxy.close() + } catch (error) { + console.error('Error:', error.message) + } +} + +// Example with fetch API +async function fetchWithSocks5Example () { + console.log('\n=== Fetch with SOCKS5 Proxy Example ===') + + try { + const socks5Proxy = new Socks5Agent('socks5://localhost:1080') + + const response = await fetch('http://httpbin.org/json', { + dispatcher: socks5Proxy + }) + + console.log('Status:', response.status) + const data = await response.json() + console.log('JSON data:', data) + + await socks5Proxy.close() + } catch (error) { + console.error('Error:', error.message) + } +} + +// Example with HTTPS +async function httpsWithSocks5Example () { + console.log('\n=== HTTPS with SOCKS5 Proxy Example ===') + + try { + const socks5Proxy = new Socks5Agent('socks5://localhost:1080') + + const response = await request('https://httpbin.org/ip', { + dispatcher: socks5Proxy + }) + + console.log('Status:', response.statusCode) + const body = await response.body.json() + console.log('HTTPS Response:', body) + + await socks5Proxy.close() + } catch (error) { + console.error('Error:', error.message) + } +} + +// Example with connection pooling +async function connectionPoolingExample () { + console.log('\n=== Connection Pooling Example ===') + + try { + const socks5Proxy = new Socks5Agent('socks5://localhost:1080', { + connections: 5, // Allow up to 5 concurrent connections + pipelining: 1 // Enable HTTP/1.1 pipelining + }) + + // Make multiple concurrent requests + const requests = [] + for (let i = 0; i < 3; i++) { + requests.push( + request(`http://httpbin.org/delay/${i}`, { + dispatcher: socks5Proxy + }) + ) + } + + console.log('Making 3 concurrent requests...') + const responses = await Promise.all(requests) + + for (let i = 0; i < responses.length; i++) { + console.log(`Request ${i + 1} status:`, responses[i].statusCode) + // Consume body to avoid warnings + await responses[i].body.dump() + } + + await socks5Proxy.close() + } catch (error) { + console.error('Error:', error.message) + } +} + +// Example with error handling +async function errorHandlingExample () { + console.log('\n=== Error Handling Example ===') + + try { + // Intentionally use a non-existent proxy + const socks5Proxy = new Socks5Agent('socks5://localhost:9999') + + await request('http://httpbin.org/ip', { + dispatcher: socks5Proxy + }) + } catch (error) { + console.log('Caught expected error:', error.message) + console.log('Error code:', error.code) + } +} + +// Global dispatcher example +async function globalDispatcherExample () { + console.log('\n=== Global Dispatcher Example ===') + + const { setGlobalDispatcher, getGlobalDispatcher } = require('undici') + + try { + const socks5Proxy = new Socks5Agent('socks5://localhost:1080') + + // Save original dispatcher + const originalDispatcher = getGlobalDispatcher() + + // Set SOCKS5 proxy as global dispatcher + setGlobalDispatcher(socks5Proxy) + + // All requests now go through SOCKS5 proxy automatically + const response = await request('http://httpbin.org/ip') + + console.log('Status:', response.statusCode) + const body = await response.body.json() + console.log('Response through global SOCKS5 proxy:', body) + + // Restore original dispatcher + setGlobalDispatcher(originalDispatcher) + + await socks5Proxy.close() + } catch (error) { + console.error('Error:', error.message) + } +} + +// Run examples +async function runExamples () { + console.log('SOCKS5 Proxy Examples for Undici') + console.log('================================') + console.log('Note: These examples require a SOCKS5 proxy running on localhost:1080') + console.log('You can use tools like dante-server, shadowsocks, or SSH tunneling.\n') + + await basicSocks5Example() + await authenticatedSocks5Example() + await fetchWithSocks5Example() + await httpsWithSocks5Example() + await connectionPoolingExample() + await errorHandlingExample() + await globalDispatcherExample() + + console.log('\n=== All examples completed ===') +} + +// Only run if this file is executed directly +if (require.main === module) { + runExamples().catch(console.error) +} + +module.exports = { + basicSocks5Example, + authenticatedSocks5Example, + fetchWithSocks5Example, + httpsWithSocks5Example, + connectionPoolingExample, + errorHandlingExample, + globalDispatcherExample +} diff --git a/index.js b/index.js index 14f439a2334..41b415e7a99 100644 --- a/index.js +++ b/index.js @@ -7,6 +7,7 @@ const BalancedPool = require('./lib/dispatcher/balanced-pool') const RoundRobinPool = require('./lib/dispatcher/round-robin-pool') const Agent = require('./lib/dispatcher/agent') const ProxyAgent = require('./lib/dispatcher/proxy-agent') +const Socks5ProxyAgent = require('./lib/dispatcher/socks5-proxy-agent') const EnvHttpProxyAgent = require('./lib/dispatcher/env-http-proxy-agent') const RetryAgent = require('./lib/dispatcher/retry-agent') const H2CClient = require('./lib/dispatcher/h2c-client') @@ -35,6 +36,7 @@ module.exports.BalancedPool = BalancedPool module.exports.RoundRobinPool = RoundRobinPool module.exports.Agent = Agent module.exports.ProxyAgent = ProxyAgent +module.exports.Socks5ProxyAgent = Socks5ProxyAgent module.exports.EnvHttpProxyAgent = EnvHttpProxyAgent module.exports.RetryAgent = RetryAgent module.exports.H2CClient = H2CClient diff --git a/lib/core/errors.js b/lib/core/errors.js index 4b1a8a10104..4af0d8cf11a 100644 --- a/lib/core/errors.js +++ b/lib/core/errors.js @@ -421,6 +421,15 @@ class MaxOriginsReachedError extends UndiciError { } } +class Socks5ProxyError extends UndiciError { + constructor (message, code) { + super(message) + this.name = 'Socks5ProxyError' + this.message = message || 'SOCKS5 proxy error' + this.code = code || 'UND_ERR_SOCKS5' + } +} + module.exports = { AbortError, HTTPParserError, @@ -444,5 +453,6 @@ module.exports = { RequestRetryError, ResponseError, SecureProxyConnectionError, - MaxOriginsReachedError + MaxOriginsReachedError, + Socks5ProxyError } diff --git a/lib/core/socks5-client.js b/lib/core/socks5-client.js new file mode 100644 index 00000000000..95f018c5385 --- /dev/null +++ b/lib/core/socks5-client.js @@ -0,0 +1,408 @@ +'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 + */ + async 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 + */ + async 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 reserved = this.buffer[2] // Not used + 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 +} diff --git a/lib/core/socks5-utils.js b/lib/core/socks5-utils.js new file mode 100644 index 00000000000..2b5a3662bf5 --- /dev/null +++ b/lib/core/socks5-utils.js @@ -0,0 +1,203 @@ +'use strict' + +const { Buffer } = require('node:buffer') +const net = require('node:net') +const { InvalidArgumentError } = require('./errors') + +/** + * Parse an address and determine its type + * @param {string} address - The address to parse + * @returns {{type: number, buffer: Buffer}} Address type and buffer + */ +function parseAddress (address) { + // Check if it's an IPv4 address + if (net.isIPv4(address)) { + const parts = address.split('.').map(Number) + return { + type: 0x01, // IPv4 + buffer: Buffer.from(parts) + } + } + + // Check if it's an IPv6 address + if (net.isIPv6(address)) { + return { + type: 0x04, // IPv6 + buffer: parseIPv6(address) + } + } + + // Otherwise, treat as domain name + const domainBuffer = Buffer.from(address, 'utf8') + if (domainBuffer.length > 255) { + throw new InvalidArgumentError('Domain name too long (max 255 bytes)') + } + + return { + type: 0x03, // Domain + buffer: Buffer.concat([Buffer.from([domainBuffer.length]), domainBuffer]) + } +} + +/** + * Parse IPv6 address to buffer + * @param {string} address - IPv6 address string + * @returns {Buffer} 16-byte buffer + */ +function parseIPv6 (address) { + const buffer = Buffer.alloc(16) + const parts = address.split(':') + let partIndex = 0 + let bufferIndex = 0 + + // Handle compressed notation (::) + const doubleColonIndex = address.indexOf('::') + if (doubleColonIndex !== -1) { + // Count non-empty parts + const nonEmptyParts = parts.filter(p => p.length > 0).length + const skipParts = 8 - nonEmptyParts + + for (let i = 0; i < parts.length; i++) { + if (parts[i] === '' && i === doubleColonIndex / 3) { + // Skip empty parts for :: + bufferIndex += skipParts * 2 + } else if (parts[i] !== '') { + const value = parseInt(parts[i], 16) + buffer.writeUInt16BE(value, bufferIndex) + bufferIndex += 2 + } + } + } else { + // No compression, parse normally + for (const part of parts) { + if (part === '') continue + const value = parseInt(part, 16) + buffer.writeUInt16BE(value, partIndex * 2) + partIndex++ + } + } + + return buffer +} + +/** + * Build a SOCKS5 address buffer + * @param {number} type - Address type (1=IPv4, 3=Domain, 4=IPv6) + * @param {Buffer} addressBuffer - The address data + * @param {number} port - Port number + * @returns {Buffer} Complete address buffer including type, address, and port + */ +function buildAddressBuffer (type, addressBuffer, port) { + const portBuffer = Buffer.allocUnsafe(2) + portBuffer.writeUInt16BE(port, 0) + + return Buffer.concat([ + Buffer.from([type]), + addressBuffer, + portBuffer + ]) +} + +/** + * Parse address from SOCKS5 response + * @param {Buffer} buffer - Buffer containing the address + * @param {number} offset - Starting offset in buffer + * @returns {{address: string, port: number, bytesRead: number}} + */ +function parseResponseAddress (buffer, offset = 0) { + if (buffer.length < offset + 1) { + throw new InvalidArgumentError('Buffer too small to contain address type') + } + + const addressType = buffer[offset] + let address + let currentOffset = offset + 1 + + switch (addressType) { + case 0x01: { // IPv4 + if (buffer.length < currentOffset + 6) { + throw new InvalidArgumentError('Buffer too small for IPv4 address') + } + address = Array.from(buffer.subarray(currentOffset, currentOffset + 4)).join('.') + currentOffset += 4 + break + } + + case 0x03: { // Domain + if (buffer.length < currentOffset + 1) { + throw new InvalidArgumentError('Buffer too small for domain length') + } + const domainLength = buffer[currentOffset] + currentOffset += 1 + + if (buffer.length < currentOffset + domainLength + 2) { + throw new InvalidArgumentError('Buffer too small for domain address') + } + address = buffer.subarray(currentOffset, currentOffset + domainLength).toString('utf8') + currentOffset += domainLength + break + } + + case 0x04: { // IPv6 + if (buffer.length < currentOffset + 18) { + throw new InvalidArgumentError('Buffer too small for IPv6 address') + } + // Convert buffer to IPv6 string + const parts = [] + for (let i = 0; i < 8; i++) { + const value = buffer.readUInt16BE(currentOffset + i * 2) + parts.push(value.toString(16)) + } + address = parts.join(':') + currentOffset += 16 + break + } + + default: + throw new InvalidArgumentError(`Invalid address type: ${addressType}`) + } + + // Parse port + if (buffer.length < currentOffset + 2) { + throw new InvalidArgumentError('Buffer too small for port') + } + const port = buffer.readUInt16BE(currentOffset) + currentOffset += 2 + + return { + address, + port, + bytesRead: currentOffset - offset + } +} + +/** + * Create error for SOCKS5 reply code + * @param {number} replyCode - SOCKS5 reply code + * @returns {Error} Appropriate error object + */ +function createReplyError (replyCode) { + const messages = { + 0x01: 'General SOCKS server failure', + 0x02: 'Connection not allowed by ruleset', + 0x03: 'Network unreachable', + 0x04: 'Host unreachable', + 0x05: 'Connection refused', + 0x06: 'TTL expired', + 0x07: 'Command not supported', + 0x08: 'Address type not supported' + } + + const message = messages[replyCode] || `Unknown SOCKS5 error code: ${replyCode}` + const error = new Error(message) + error.code = `SOCKS5_${replyCode}` + return error +} + +module.exports = { + parseAddress, + parseIPv6, + buildAddressBuffer, + parseResponseAddress, + createReplyError +} diff --git a/lib/core/symbols.js b/lib/core/symbols.js index ec45d7951ef..69872d1e7df 100644 --- a/lib/core/symbols.js +++ b/lib/core/symbols.js @@ -69,5 +69,6 @@ module.exports = { kHTTP2Stream: Symbol('http2session client stream'), kNoProxyAgent: Symbol('no proxy agent'), kHttpProxyAgent: Symbol('http proxy agent'), - kHttpsProxyAgent: Symbol('https proxy agent') + kHttpsProxyAgent: Symbol('https proxy agent'), + kSocks5ProxyAgent: Symbol('socks5 proxy agent') } diff --git a/lib/dispatcher/proxy-agent.js b/lib/dispatcher/proxy-agent.js index 4403b8d87ce..a77526740fc 100644 --- a/lib/dispatcher/proxy-agent.js +++ b/lib/dispatcher/proxy-agent.js @@ -8,6 +8,7 @@ const { InvalidArgumentError, RequestAbortedError, SecureProxyConnectionError } const buildConnector = require('../core/connect') const Client = require('./client') const { channels } = require('../core/diagnostics') +const Socks5ProxyAgent = require('./socks5-proxy-agent') const kAgent = Symbol('proxy agent') const kClient = Symbol('proxy client') @@ -132,6 +133,19 @@ class ProxyAgent extends DispatcherBase { const agentFactory = opts.factory || defaultAgentFactory const factory = (origin, options) => { const { protocol } = new URL(origin) + + // Handle SOCKS5 proxy + if (this[kProxy].protocol === 'socks5:' || this[kProxy].protocol === 'socks:') { + return new Socks5ProxyAgent(this[kProxy].uri, { + headers: this[kProxyHeaders], + connect, + factory: agentFactory, + username: opts.username || username, + password: opts.password || password, + proxyTls: opts.proxyTls + }) + } + if (!this[kTunnelProxy] && protocol === 'http:' && this[kProxy].protocol === 'http:') { return new Http1ProxyWrapper(this[kProxy].uri, { headers: this[kProxyHeaders], @@ -141,7 +155,15 @@ class ProxyAgent extends DispatcherBase { } return agentFactory(origin, options) } - this[kClient] = clientFactory(url, { connect }) + + // For SOCKS5 proxies, we don't need a client to the proxy itself + // The SOCKS5 connection is handled within Socks5ProxyAgent + if (protocol === 'socks5:' || protocol === 'socks:') { + this[kClient] = null + } else { + this[kClient] = clientFactory(url, { connect }) + } + this[kAgent] = new Agent({ ...opts, factory, @@ -233,17 +255,19 @@ class ProxyAgent extends DispatcherBase { } [kClose] () { - return Promise.all([ - this[kAgent].close(), - this[kClient].close() - ]) + const promises = [this[kAgent].close()] + if (this[kClient]) { + promises.push(this[kClient].close()) + } + return Promise.all(promises) } [kDestroy] () { - return Promise.all([ - this[kAgent].destroy(), - this[kClient].destroy() - ]) + const promises = [this[kAgent].destroy()] + if (this[kClient]) { + promises.push(this[kClient].destroy()) + } + return Promise.all(promises) } } diff --git a/lib/dispatcher/socks5-proxy-agent.js b/lib/dispatcher/socks5-proxy-agent.js new file mode 100644 index 00000000000..a2dd2f428c7 --- /dev/null +++ b/lib/dispatcher/socks5-proxy-agent.js @@ -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 diff --git a/test/fixtures/docker/dante/Dockerfile b/test/fixtures/docker/dante/Dockerfile new file mode 100644 index 00000000000..d3ec411db40 --- /dev/null +++ b/test/fixtures/docker/dante/Dockerfile @@ -0,0 +1,19 @@ +FROM alpine:latest + +# Install Dante SOCKS server +RUN apk add --no-cache dante-server + +# Create dante user +RUN adduser -D -s /bin/false dante + +# Copy configuration +COPY danted.conf /etc/danted.conf + +# Create log directory +RUN mkdir -p /var/log/dante && chown dante:dante /var/log/dante + +# Expose SOCKS port +EXPOSE 1080 + +# Run Dante +CMD ["danted", "-f", "/etc/danted.conf"] \ No newline at end of file diff --git a/test/fixtures/docker/dante/danted.conf b/test/fixtures/docker/dante/danted.conf new file mode 100644 index 00000000000..5da3a4d9066 --- /dev/null +++ b/test/fixtures/docker/dante/danted.conf @@ -0,0 +1,37 @@ +# Dante SOCKS5 server configuration for testing + +# Log settings +logoutput: /var/log/dante/danted.log +debug: 1 + +# Network interface configuration +internal: 0.0.0.0 port = 1080 +external: eth0 + +# Authentication methods +socksmethod: none +socksmethod: username + +# User for username/password auth +user.privileged: root +user.unprivileged: dante + +# Client access rules +client pass { + from: 0.0.0.0/0 to: 0.0.0.0/0 + log: error +} + +# SOCKS rules +socks pass { + from: 0.0.0.0/0 to: 0.0.0.0/0 + protocol: tcp udp + log: error +} + +# Route rules +route { + from: 0.0.0.0/0 to: 0.0.0.0/0 via: eth0 + protocol: tcp udp + proxyprotocol: socks_v5 +} \ No newline at end of file diff --git a/test/fixtures/docker/docker-compose.yml b/test/fixtures/docker/docker-compose.yml new file mode 100644 index 00000000000..1b2b8a1d80b --- /dev/null +++ b/test/fixtures/docker/docker-compose.yml @@ -0,0 +1,91 @@ +services: + # SOCKS5 proxy without authentication + socks5-no-auth: + image: serjs/go-socks5-proxy:latest + container_name: socks5-no-auth + environment: + - PROXY_PORT=1080 + - REQUIRE_AUTH=false + ports: + - "1080:1080" + networks: + - test-network + + # SOCKS5 proxy with username/password authentication + socks5-auth: + image: serjs/go-socks5-proxy:latest + container_name: socks5-auth + environment: + - PROXY_USER=testuser + - PROXY_PASSWORD=testpass + - PROXY_PORT=1081 + ports: + - "1081:1081" + networks: + - test-network + + # Alternative: Dante SOCKS5 server (more configurable) + dante-socks5: + build: + context: ./dante + dockerfile: Dockerfile + container_name: dante-socks5 + ports: + - "1082:1080" + networks: + - test-network + volumes: + - ./dante/danted.conf:/etc/danted.conf:ro + + # HTTP test server + http-server: + image: node:20-alpine + container_name: http-test-server + working_dir: /app + volumes: + - ../servers/http-server.js:/app/server.js:ro + command: node server.js + ports: + - "8080:8080" + networks: + - test-network + + # HTTPS test server + https-server: + image: node:20-alpine + container_name: https-test-server + working_dir: /app + volumes: + - ../servers/https-server.js:/app/server.js:ro + - ../certs:/app/certs:ro + command: node server.js + ports: + - "8443:8443" + networks: + - test-network + + # Echo server for testing + echo-server: + image: ealen/echo-server:latest + container_name: echo-server + environment: + - PORT=3000 + ports: + - "3000:3000" + networks: + - test-network + + # Blocked target (for testing connection failures) + blocked-server: + image: alpine:latest + container_name: blocked-server + command: sleep infinity + networks: + - isolated-network + +networks: + test-network: + driver: bridge + isolated-network: + driver: bridge + internal: true \ No newline at end of file diff --git a/test/fixtures/socks5-test-server.js b/test/fixtures/socks5-test-server.js new file mode 100644 index 00000000000..5f7c846d4d0 --- /dev/null +++ b/test/fixtures/socks5-test-server.js @@ -0,0 +1,227 @@ +'use strict' + +const net = require('node:net') +const { AUTH_METHODS, REPLY_CODES } = require('../../lib/core/socks5-client') + +/** + * Test SOCKS5 server for unit tests + * Implements SOCKS5 protocol with optional authentication + */ +class TestSocks5Server { + constructor (options = {}) { + this.options = options + this.server = null + this.connections = new Set() + this.requireAuth = options.requireAuth || false + this.validCredentials = options.credentials || { username: 'test', password: 'pass' } + } + + async listen (port = 0) { + return new Promise((resolve, reject) => { + this.server = net.createServer((socket) => { + this.connections.add(socket) + this.handleConnection(socket) + + socket.on('close', () => { + this.connections.delete(socket) + }) + }) + + this.server.listen(port, (err) => { + if (err) { + reject(err) + } else { + resolve(this.server.address()) + } + }) + }) + } + + handleConnection (socket) { + let state = 'handshake' + let buffer = Buffer.alloc(0) + + socket.on('data', (data) => { + buffer = Buffer.concat([buffer, data]) + + if (state === 'handshake') { + this.handleHandshake(socket, buffer, (newBuffer, method) => { + buffer = newBuffer + if (method === AUTH_METHODS.NO_AUTH) { + state = 'connect' + } else if (method === AUTH_METHODS.USERNAME_PASSWORD) { + state = 'auth' + } + }) + } else if (state === 'auth') { + this.handleAuth(socket, buffer, (newBuffer, success) => { + buffer = newBuffer + if (success) { + state = 'connect' + } else { + socket.end() + } + }) + } else if (state === 'connect') { + this.handleConnect(socket, buffer, (newBuffer) => { + buffer = newBuffer + state = 'relay' + }) + } + }) + + socket.on('error', () => { + // Handle socket errors silently + }) + } + + handleHandshake (socket, buffer, callback) { + if (buffer.length >= 2) { + const version = buffer[0] + const nmethods = buffer[1] + + if (version === 0x05 && buffer.length >= 2 + nmethods) { + const methods = Array.from(buffer.subarray(2, 2 + nmethods)) + + // Select authentication method + let selectedMethod + if (this.requireAuth && methods.includes(AUTH_METHODS.USERNAME_PASSWORD)) { + selectedMethod = AUTH_METHODS.USERNAME_PASSWORD + } else if (!this.requireAuth && methods.includes(AUTH_METHODS.NO_AUTH)) { + selectedMethod = AUTH_METHODS.NO_AUTH + } else { + selectedMethod = AUTH_METHODS.NO_ACCEPTABLE + } + + socket.write(Buffer.from([0x05, selectedMethod])) + callback(buffer.subarray(2 + nmethods), selectedMethod) + } + } + } + + handleAuth (socket, buffer, callback) { + if (buffer.length >= 2) { + const version = buffer[0] + if (version !== 0x01) { + socket.write(Buffer.from([0x01, 0x01])) // Failure + callback(buffer, false) + return + } + + const usernameLen = buffer[1] + if (buffer.length >= 3 + usernameLen) { + const username = buffer.subarray(2, 2 + usernameLen).toString() + const passwordLen = buffer[2 + usernameLen] + + if (buffer.length >= 3 + usernameLen + passwordLen) { + const password = buffer.subarray(3 + usernameLen, 3 + usernameLen + passwordLen).toString() + + const success = username === this.validCredentials.username && + password === this.validCredentials.password + + socket.write(Buffer.from([0x01, success ? 0x00 : 0x01])) + callback(buffer.subarray(3 + usernameLen + passwordLen), success) + } + } + } + } + + handleConnect (socket, buffer, callback) { + if (buffer.length >= 4) { + const version = buffer[0] + const cmd = buffer[1] + const atyp = buffer[3] + + if (version === 0x05 && cmd === 0x01) { + let addressLength = 0 + if (atyp === 0x01) { + addressLength = 4 // IPv4 + } else if (atyp === 0x03) { + if (buffer.length >= 5) { + addressLength = 1 + buffer[4] // Domain length + domain + } else { + return // Not enough data + } + } else if (atyp === 0x04) { + addressLength = 16 // IPv6 + } + + if (buffer.length >= 4 + addressLength + 2) { + // Extract target address and port + let targetHost + let offset = 4 + + if (atyp === 0x01) { + targetHost = Array.from(buffer.subarray(offset, offset + 4)).join('.') + offset += 4 + } else if (atyp === 0x03) { + const domainLen = buffer[offset] + offset += 1 + targetHost = buffer.subarray(offset, offset + domainLen).toString() + offset += domainLen + } + + const targetPort = buffer.readUInt16BE(offset) + + // Simulate connection failure if requested + if (this.options.simulateFailure) { + const response = Buffer.concat([ + Buffer.from([0x05, REPLY_CODES.CONNECTION_REFUSED, 0x00, 0x01]), + Buffer.from([0, 0, 0, 0]), + Buffer.from([0, 0]) + ]) + socket.write(response) + socket.end() + return + } + + // Connect to target + const targetSocket = net.connect(targetPort, targetHost) + + targetSocket.on('connect', () => { + // Send success response + const response = Buffer.concat([ + Buffer.from([0x05, 0x00, 0x00, 0x01]), // VER, REP, RSV, ATYP + Buffer.from([127, 0, 0, 1]), // Bind address (localhost) + Buffer.allocUnsafe(2) // Bind port + ]) + response.writeUInt16BE(targetPort, response.length - 2) + socket.write(response) + + // Start relaying data + socket.pipe(targetSocket) + targetSocket.pipe(socket) + + callback(buffer.subarray(4 + addressLength + 2)) + }) + + targetSocket.on('error', () => { + // Send connection refused + const response = Buffer.concat([ + Buffer.from([0x05, REPLY_CODES.CONNECTION_REFUSED, 0x00, 0x01]), + Buffer.from([0, 0, 0, 0]), + Buffer.from([0, 0]) + ]) + socket.write(response) + socket.end() + }) + } + } + } + } + + async close () { + if (this.server) { + // Close all connections + for (const socket of this.connections) { + socket.destroy() + } + + return new Promise((resolve) => { + this.server.close(resolve) + }) + } + } +} + +module.exports = { TestSocks5Server } diff --git a/test/socks5-client.js b/test/socks5-client.js new file mode 100644 index 00000000000..b9bee4219b5 --- /dev/null +++ b/test/socks5-client.js @@ -0,0 +1,332 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test } = require('node:test') +const net = require('node:net') +const { Socks5Client, STATES, AUTH_METHODS, REPLY_CODES } = require('../lib/core/socks5-client') +const { InvalidArgumentError, Socks5ProxyError } = require('../lib/core/errors') + +test('Socks5Client - constructor validation', async (t) => { + const p = tspl(t, { plan: 1 }) + + p.throws(() => { + // eslint-disable-next-line no-new + new Socks5Client() + }, InvalidArgumentError, 'should throw when socket is not provided') + + await p.completed +}) + +test('Socks5Client - handshake flow', async (t) => { + const p = tspl(t, { plan: 6 }) + + // Create a mock SOCKS5 server + const server = net.createServer((socket) => { + socket.on('data', (data) => { + // First message should be handshake + if (data[0] === 0x05 && data.length === 3) { + p.equal(data[0], 0x05, 'should send SOCKS version 5') + p.equal(data[1], 1, 'should send 1 auth method') + p.equal(data[2], AUTH_METHODS.NO_AUTH, 'should send NO_AUTH method') + + // Send response accepting NO_AUTH + socket.write(Buffer.from([0x05, AUTH_METHODS.NO_AUTH])) + } + }) + }) + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve) + }) + + const { port } = server.address() + const socket = net.connect(port, '127.0.0.1') + + await new Promise((resolve) => { + socket.on('connect', resolve) + }) + + const client = new Socks5Client(socket) + + p.equal(client.state, STATES.INITIAL, 'should start in INITIAL state') + + client.on('authenticated', () => { + p.equal(client.state, STATES.HANDSHAKING, 'should be in HANDSHAKING state after auth') + p.ok(true, 'should emit authenticated event') + }) + + await client.handshake() + + // Wait for the authenticated event + await new Promise((resolve) => { + if (client.state !== STATES.HANDSHAKING) { + resolve() + } else { + client.once('authenticated', resolve) + } + }) + + socket.destroy() + server.close() + + await p.completed +}) + +test('Socks5Client - username/password authentication', async (t) => { + const p = tspl(t, { plan: 7 }) + + const testUsername = 'testuser' + const testPassword = 'testpass' + + // Create a mock SOCKS5 server with auth + const server = net.createServer((socket) => { + let stage = 'handshake' + + socket.on('data', (data) => { + if (stage === 'handshake' && data[0] === 0x05) { + p.equal(data[0], 0x05, 'should send SOCKS version 5') + p.equal(data[1], 2, 'should send 2 auth methods') + p.equal(data[2], AUTH_METHODS.USERNAME_PASSWORD, 'should send USERNAME_PASSWORD first') + p.equal(data[3], AUTH_METHODS.NO_AUTH, 'should send NO_AUTH second') + + // Send response selecting USERNAME_PASSWORD + socket.write(Buffer.from([0x05, AUTH_METHODS.USERNAME_PASSWORD])) + stage = 'auth' + } else if (stage === 'auth') { + // Parse username/password auth request + p.equal(data[0], 0x01, 'should send auth version 1') + + const usernameLen = data[1] + const username = data.subarray(2, 2 + usernameLen).toString() + p.equal(username, testUsername, 'should send correct username') + + const passwordLen = data[2 + usernameLen] + const password = data.subarray(3 + usernameLen, 3 + usernameLen + passwordLen).toString() + p.equal(password, testPassword, 'should send correct password') + + // Send auth success response + socket.write(Buffer.from([0x01, 0x00])) + } + }) + }) + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve) + }) + + const { port } = server.address() + const socket = net.connect(port, '127.0.0.1') + + await new Promise((resolve) => { + socket.on('connect', resolve) + }) + + const client = new Socks5Client(socket, { + username: testUsername, + password: testPassword + }) + + client.on('authenticated', () => { + // Test passed + }) + + await client.handshake() + + // Wait for the authenticated event + await new Promise((resolve) => { + client.once('authenticated', resolve) + }) + + socket.destroy() + server.close() + + await p.completed +}) + +test('Socks5Client - connect command', async (t) => { + const p = tspl(t, { plan: 8 }) + + const targetHost = 'example.com' + const targetPort = 80 + + // Create a mock SOCKS5 server + const server = net.createServer((socket) => { + let stage = 'handshake' + + socket.on('data', (data) => { + if (stage === 'handshake' && data[0] === 0x05) { + // Send NO_AUTH response + socket.write(Buffer.from([0x05, AUTH_METHODS.NO_AUTH])) + stage = 'connect' + } else if (stage === 'connect') { + // Parse CONNECT request + p.equal(data[0], 0x05, 'should send SOCKS version 5') + p.equal(data[1], 0x01, 'should send CONNECT command') + p.equal(data[2], 0x00, 'should send reserved byte') + p.equal(data[3], 0x03, 'should send domain address type') + + const domainLen = data[4] + const domain = data.subarray(5, 5 + domainLen).toString() + p.equal(domain, targetHost, 'should send correct domain') + + const port = data.readUInt16BE(5 + domainLen) + p.equal(port, targetPort, 'should send correct port') + + // Send success response with bound address + const response = Buffer.from([ + 0x05, // Version + REPLY_CODES.SUCCEEDED, // Success + 0x00, // Reserved + 0x01, // IPv4 address type + 127, 0, 0, 1, // Bound address + 0x00, 0x50 // Bound port (80) + ]) + socket.write(response) + } + }) + }) + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve) + }) + + const { port } = server.address() + const socket = net.connect(port, '127.0.0.1') + + await new Promise((resolve) => { + socket.on('connect', resolve) + }) + + const client = new Socks5Client(socket) + + client.on('authenticated', async () => { + await client.connect(targetHost, targetPort) + }) + + client.on('connected', (info) => { + p.equal(info.address, '127.0.0.1', 'should return bound address') + p.equal(info.port, 80, 'should return bound port') + }) + + await client.handshake() + + // Wait for the connected event + await new Promise((resolve) => { + client.once('connected', resolve) + }) + + socket.destroy() + server.close() + + await p.completed +}) + +test('Socks5Client - authentication failure', async (t) => { + const p = tspl(t, { plan: 3 }) + + // Create a mock SOCKS5 server + const server = net.createServer((socket) => { + socket.on('data', (data) => { + if (data[0] === 0x05) { + // Send NO_ACCEPTABLE response + socket.write(Buffer.from([0x05, AUTH_METHODS.NO_ACCEPTABLE])) + } + }) + }) + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve) + }) + + const { port } = server.address() + const socket = net.connect(port, '127.0.0.1') + + await new Promise((resolve) => { + socket.on('connect', resolve) + }) + + const client = new Socks5Client(socket) + + client.on('error', (err) => { + p.ok(err instanceof Socks5ProxyError, 'should emit Socks5ProxyError') + p.equal(err.code, 'UND_ERR_SOCKS5_AUTH_REJECTED', 'should have correct error code') + p.equal(err.message, 'No acceptable authentication method', 'should have correct error message') + }) + + await client.handshake() + + // Wait for the error event + await new Promise((resolve) => { + client.once('error', resolve) + }) + + socket.destroy() + server.close() + + await p.completed +}) + +test('Socks5Client - connection refused', async (t) => { + const p = tspl(t, { plan: 3 }) + + // Create a mock SOCKS5 server + const server = net.createServer((socket) => { + let stage = 'handshake' + + socket.on('data', (data) => { + if (stage === 'handshake' && data[0] === 0x05) { + // Send NO_AUTH response + socket.write(Buffer.from([0x05, AUTH_METHODS.NO_AUTH])) + stage = 'connect' + } else if (stage === 'connect') { + // Send connection refused response + const response = Buffer.from([ + 0x05, // Version + REPLY_CODES.CONNECTION_REFUSED, // Connection refused + 0x00, // Reserved + 0x01, // IPv4 address type + 0, 0, 0, 0, // Bound address + 0x00, 0x00 // Bound port + ]) + socket.write(response) + } + }) + }) + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve) + }) + + const { port } = server.address() + const socket = net.connect(port, '127.0.0.1') + + await new Promise((resolve) => { + socket.on('connect', resolve) + }) + + const client = new Socks5Client(socket) + + client.on('authenticated', () => { + client.connect('example.com', 80).catch(() => { + // Error is handled in the error event + }) + }) + + client.on('error', (err) => { + p.ok(err instanceof Socks5ProxyError, 'should throw Socks5ProxyError') + p.equal(err.code, 'UND_ERR_SOCKS5_REPLY_5', 'should have correct error code') + p.match(err.message, /Connection refused/, 'should have correct error message') + }) + + await client.handshake() + + // Wait for the error event + await new Promise((resolve) => { + client.once('error', resolve) + }) + + socket.destroy() + server.close() + + await p.completed +}) diff --git a/test/socks5-proxy-agent.js b/test/socks5-proxy-agent.js new file mode 100644 index 00000000000..85185922647 --- /dev/null +++ b/test/socks5-proxy-agent.js @@ -0,0 +1,320 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test } = require('node:test') +const { request } = require('..') +const { InvalidArgumentError } = require('../lib/core/errors') +const Socks5ProxyAgent = require('../lib/dispatcher/socks5-proxy-agent') +const { createServer } = require('node:http') +const { TestSocks5Server } = require('./fixtures/socks5-test-server') + +test('Socks5ProxyAgent - constructor validation', async (t) => { + const p = tspl(t, { plan: 4 }) + + p.throws(() => { + // eslint-disable-next-line no-new + new Socks5ProxyAgent() + }, InvalidArgumentError, 'should throw when proxy URL is not provided') + + p.throws(() => { + // eslint-disable-next-line no-new + new Socks5ProxyAgent('http://localhost:1080') + }, InvalidArgumentError, 'should throw when proxy URL protocol is not socks5') + + p.doesNotThrow(() => { + // eslint-disable-next-line no-new + new Socks5ProxyAgent('socks5://localhost:1080') + }, 'should accept socks5:// URLs') + + p.doesNotThrow(() => { + // eslint-disable-next-line no-new + new Socks5ProxyAgent('socks://localhost:1080') + }, 'should accept socks:// URLs for compatibility') + + await p.completed +}) + +test('Socks5ProxyAgent - basic HTTP connection', async (t) => { + const p = tspl(t, { plan: 2 }) + + // Create target HTTP server + const server = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ message: 'Hello from target server', path: req.url })) + }) + + // Start target server + await new Promise((resolve) => { + server.listen(0, resolve) + }) + const serverPort = server.address().port + + // Create SOCKS5 proxy server + const socksServer = new TestSocks5Server() + const socksAddress = await socksServer.listen() + + try { + // Create Socks5ProxyAgent + const proxyWrapper = new Socks5ProxyAgent(`socks5://localhost:${socksAddress.port}`) + + // Make request through SOCKS5 proxy + const response = await request(`http://localhost:${serverPort}/test`, { + dispatcher: proxyWrapper + }) + + p.equal(response.statusCode, 200, 'should get 200 status code') + + const body = await response.body.json() + p.deepEqual(body, { + message: 'Hello from target server', + path: '/test' + }, 'should get correct response body') + } finally { + await socksServer.close() + server.close() + } + + await p.completed +}) + +test.skip('Socks5ProxyAgent - HTTPS connection', async (t) => { + // Skip HTTPS test for now - TLS option passing needs additional work + t.skip('HTTPS test requires TLS option refinement') +}) + +test('Socks5ProxyAgent - with authentication', async (t) => { + const p = tspl(t, { plan: 2 }) + + // Create target HTTP server + const server = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ message: 'Authenticated request successful' })) + }) + + // Start target server + await new Promise((resolve) => { + server.listen(0, resolve) + }) + const serverPort = server.address().port + + // Create SOCKS5 proxy server with auth + const socksServer = new TestSocks5Server({ + requireAuth: true, + credentials: { username: 'testuser', password: 'testpass' } + }) + const socksAddress = await socksServer.listen() + + try { + // Create Socks5ProxyAgent with auth + const proxyWrapper = new Socks5ProxyAgent(`socks5://testuser:testpass@localhost:${socksAddress.port}`) + + // Make request through SOCKS5 proxy + const response = await request(`http://localhost:${serverPort}/auth-test`, { + dispatcher: proxyWrapper + }) + + p.equal(response.statusCode, 200, 'should get 200 status code') + + const body = await response.body.json() + p.deepEqual(body, { + message: 'Authenticated request successful' + }, 'should get correct response body') + } finally { + await socksServer.close() + server.close() + } + + await p.completed +}) + +test('Socks5ProxyAgent - authentication with options', async (t) => { + const p = tspl(t, { plan: 2 }) + + // Create target HTTP server + const server = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ message: 'Options auth successful' })) + }) + + // Start target server + await new Promise((resolve) => { + server.listen(0, resolve) + }) + const serverPort = server.address().port + + // Create SOCKS5 proxy server with auth + const socksServer = new TestSocks5Server({ + requireAuth: true, + credentials: { username: 'optuser', password: 'optpass' } + }) + const socksAddress = await socksServer.listen() + + try { + // Create Socks5ProxyAgent with auth in options + const proxyWrapper = new Socks5ProxyAgent(`socks5://localhost:${socksAddress.port}`, { + username: 'optuser', + password: 'optpass' + }) + + // Make request through SOCKS5 proxy + const response = await request(`http://localhost:${serverPort}/options-auth`, { + dispatcher: proxyWrapper + }) + + p.equal(response.statusCode, 200, 'should get 200 status code') + + const body = await response.body.json() + p.deepEqual(body, { + message: 'Options auth successful' + }, 'should get correct response body') + } finally { + await socksServer.close() + server.close() + } + + await p.completed +}) + +test('Socks5ProxyAgent - multiple requests through same proxy', async (t) => { + const p = tspl(t, { plan: 4 }) + + // Create target HTTP server + let requestCount = 0 + const server = createServer((req, res) => { + requestCount++ + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ message: `Request ${requestCount}`, path: req.url })) + }) + + // Start target server + await new Promise((resolve) => { + server.listen(0, resolve) + }) + const serverPort = server.address().port + + // Create SOCKS5 proxy server + const socksServer = new TestSocks5Server() + const socksAddress = await socksServer.listen() + + try { + // Create Socks5ProxyAgent + const proxyWrapper = new Socks5ProxyAgent(`socks5://localhost:${socksAddress.port}`) + + // Make first request + const response1 = await request(`http://localhost:${serverPort}/request1`, { + dispatcher: proxyWrapper + }) + + p.equal(response1.statusCode, 200, 'should get 200 status code for first request') + const body1 = await response1.body.json() + p.deepEqual(body1, { message: 'Request 1', path: '/request1' }, 'should get correct response body for first request') + + // Make second request through same proxy + const response2 = await request(`http://localhost:${serverPort}/request2`, { + dispatcher: proxyWrapper + }) + + p.equal(response2.statusCode, 200, 'should get 200 status code for second request') + const body2 = await response2.body.json() + p.deepEqual(body2, { message: 'Request 2', path: '/request2' }, 'should get correct response body for second request') + } finally { + await socksServer.close() + server.close() + } + + await p.completed +}) + +test('Socks5ProxyAgent - connection failure', async (t) => { + const p = tspl(t, { plan: 1 }) + + // Create Socks5ProxyAgent pointing to non-existent proxy + const proxyWrapper = new Socks5ProxyAgent('socks5://localhost:9999') + + try { + await request('http://example.com/', { + dispatcher: proxyWrapper + }) + p.fail('should have thrown an error') + } catch (err) { + p.ok(err, 'should throw error when SOCKS5 proxy is not available') + } + + await p.completed +}) + +test('Socks5ProxyAgent - proxy connection refused', async (t) => { + const p = tspl(t, { plan: 1 }) + + // Create target HTTP server + const server = createServer((req, res) => { + res.writeHead(200) + res.end('OK') + }) + + await new Promise((resolve) => { + server.listen(0, resolve) + }) + const serverPort = server.address().port + + // Create SOCKS5 proxy server that simulates connection failure + const socksServer = new TestSocks5Server({ simulateFailure: true }) + const socksAddress = await socksServer.listen() + + try { + const proxyWrapper = new Socks5ProxyAgent(`socks5://localhost:${socksAddress.port}`) + + await request(`http://localhost:${serverPort}/`, { + dispatcher: proxyWrapper + }) + p.fail('should have thrown an error') + } catch (err) { + p.ok(err, 'should throw error when SOCKS5 proxy refuses connection') + } finally { + await socksServer.close() + server.close() + } + + await p.completed +}) + +test('Socks5ProxyAgent - close and destroy', async (t) => { + const p = tspl(t, { plan: 2 }) + + const proxyWrapper = new Socks5ProxyAgent('socks5://localhost:1080') + + // Test close + await proxyWrapper.close() + p.ok(true, 'should close without error') + + // Test destroy + await proxyWrapper.destroy() + p.ok(true, 'should destroy without error') + + await p.completed +}) + +test('Socks5ProxyAgent - URL parsing edge cases', async (t) => { + const p = tspl(t, { plan: 3 }) + + // Test with URL object + const url = new URL('socks5://user:pass@proxy.example.com:1080') + p.doesNotThrow(() => { + // eslint-disable-next-line no-new + new Socks5ProxyAgent(url) + }, 'should accept URL object') + + // Test with encoded credentials + p.doesNotThrow(() => { + // eslint-disable-next-line no-new + new Socks5ProxyAgent('socks5://user%40domain:p%40ss@localhost:1080') + }, 'should handle URL-encoded credentials') + + // Test default port + p.doesNotThrow(() => { + // eslint-disable-next-line no-new + new Socks5ProxyAgent('socks5://localhost') + }, 'should use default port 1080') + + await p.completed +}) diff --git a/test/socks5-utils.js b/test/socks5-utils.js new file mode 100644 index 00000000000..8dd551313b4 --- /dev/null +++ b/test/socks5-utils.js @@ -0,0 +1,181 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test } = require('node:test') +const { + parseAddress, + parseIPv6, + buildAddressBuffer, + parseResponseAddress, + createReplyError +} = require('../lib/core/socks5-utils') +const { InvalidArgumentError } = require('../lib/core/errors') + +test('parseAddress - IPv4', async (t) => { + const p = tspl(t, { plan: 3 }) + + const result = parseAddress('192.168.1.1') + p.equal(result.type, 0x01, 'should return IPv4 type') + p.equal(result.buffer.length, 4, 'should return 4-byte buffer') + p.deepEqual(Array.from(result.buffer), [192, 168, 1, 1], 'should parse IPv4 correctly') + + await p.completed +}) + +test('parseAddress - IPv6', async (t) => { + const p = tspl(t, { plan: 2 }) + + const result = parseAddress('2001:db8::1') + p.equal(result.type, 0x04, 'should return IPv6 type') + p.equal(result.buffer.length, 16, 'should return 16-byte buffer') + + await p.completed +}) + +test('parseAddress - Domain', async (t) => { + const p = tspl(t, { plan: 4 }) + + const result = parseAddress('example.com') + p.equal(result.type, 0x03, 'should return domain type') + p.equal(result.buffer[0], 11, 'should have correct length byte') + p.equal(result.buffer.subarray(1).toString(), 'example.com', 'should contain domain name') + + // Test domain too long + const longDomain = 'a'.repeat(256) + p.throws(() => parseAddress(longDomain), InvalidArgumentError, 'should throw for domain > 255 bytes') + + await p.completed +}) + +test('parseIPv6', async (t) => { + const p = tspl(t, { plan: 3 }) + + // Test full IPv6 + const buffer1 = parseIPv6('2001:0db8:0000:0042:0000:8a2e:0370:7334') + p.equal(buffer1.length, 16, 'should return 16-byte buffer') + + // Test compressed IPv6 + const buffer2 = parseIPv6('2001:db8::1') + p.equal(buffer2.length, 16, 'should return 16-byte buffer for compressed') + + // Test loopback + const buffer3 = parseIPv6('::1') + p.equal(buffer3.length, 16, 'should return 16-byte buffer for loopback') + + await p.completed +}) + +test('buildAddressBuffer', async (t) => { + const p = tspl(t, { plan: 5 }) + + // IPv4 address + const ipv4Buffer = buildAddressBuffer(0x01, Buffer.from([192, 168, 1, 1]), 80) + p.equal(ipv4Buffer[0], 0x01, 'should have IPv4 type') + p.deepEqual(Array.from(ipv4Buffer.subarray(1, 5)), [192, 168, 1, 1], 'should have IPv4 address') + p.equal(ipv4Buffer.readUInt16BE(5), 80, 'should have correct port') + + // Domain address + const domainBuffer = Buffer.concat([Buffer.from([11]), Buffer.from('example.com')]) + const result = buildAddressBuffer(0x03, domainBuffer, 443) + p.equal(result[0], 0x03, 'should have domain type') + p.equal(result.readUInt16BE(result.length - 2), 443, 'should have correct port') + + await p.completed +}) + +test('parseResponseAddress - IPv4', async (t) => { + const p = tspl(t, { plan: 4 }) + + const buffer = Buffer.from([ + 0x01, // IPv4 type + 192, 168, 1, 1, // IP address + 0x00, 0x50 // Port 80 + ]) + + const result = parseResponseAddress(buffer) + p.equal(result.address, '192.168.1.1', 'should parse IPv4 address') + p.equal(result.port, 80, 'should parse port') + p.equal(result.bytesRead, 7, 'should read 7 bytes') + + // Test with offset + const bufferWithOffset = Buffer.concat([Buffer.from([0, 0]), buffer]) + const resultWithOffset = parseResponseAddress(bufferWithOffset, 2) + p.equal(resultWithOffset.address, '192.168.1.1', 'should parse with offset') + + await p.completed +}) + +test('parseResponseAddress - Domain', async (t) => { + const p = tspl(t, { plan: 3 }) + + const buffer = Buffer.from([ + 0x03, // Domain type + 11, // Length + ...Buffer.from('example.com'), + 0x01, 0xBB // Port 443 + ]) + + const result = parseResponseAddress(buffer) + p.equal(result.address, 'example.com', 'should parse domain') + p.equal(result.port, 443, 'should parse port') + p.equal(result.bytesRead, 15, 'should read correct bytes') + + await p.completed +}) + +test('parseResponseAddress - IPv6', async (t) => { + const p = tspl(t, { plan: 3 }) + + const buffer = Buffer.alloc(19) + buffer[0] = 0x04 // IPv6 type + // Simple IPv6 address (all zeros except last byte) + buffer[17] = 1 + buffer[17] = 0x00 + buffer[18] = 0x50 // Port 80 + + const result = parseResponseAddress(buffer) + p.match(result.address, /:/, 'should return IPv6 format') + p.equal(result.port, 80, 'should parse port') + p.equal(result.bytesRead, 19, 'should read 19 bytes') + + await p.completed +}) + +test('parseResponseAddress - errors', async (t) => { + const p = tspl(t, { plan: 5 }) + + // Buffer too small for type + p.throws(() => parseResponseAddress(Buffer.alloc(0)), InvalidArgumentError) + + // Buffer too small for IPv4 + p.throws(() => parseResponseAddress(Buffer.from([0x01, 192])), InvalidArgumentError) + + // Buffer too small for domain length + p.throws(() => parseResponseAddress(Buffer.from([0x03])), InvalidArgumentError) + + // Buffer too small for domain + p.throws(() => parseResponseAddress(Buffer.from([0x03, 10, 65])), InvalidArgumentError) + + // Invalid address type + p.throws(() => parseResponseAddress(Buffer.from([0x99, 0, 0, 0, 0, 0, 0])), InvalidArgumentError) + + await p.completed +}) + +test('createReplyError', async (t) => { + const p = tspl(t, { plan: 6 }) + + const err1 = createReplyError(0x01) + p.equal(err1.message, 'General SOCKS server failure') + p.equal(err1.code, 'SOCKS5_1') + + const err2 = createReplyError(0x05) + p.equal(err2.message, 'Connection refused') + p.equal(err2.code, 'SOCKS5_5') + + const err3 = createReplyError(0x99) + p.equal(err3.message, 'Unknown SOCKS5 error code: 153') + p.equal(err3.code, 'SOCKS5_153') + + await p.completed +}) diff --git a/types/errors.d.ts b/types/errors.d.ts index fbf31955611..b28decf2a9f 100644 --- a/types/errors.d.ts +++ b/types/errors.d.ts @@ -154,8 +154,18 @@ declare namespace Errors { code: 'UND_ERR_PRX_TLS' } - class MaxOriginsReachedError extends UndiciError { + export class MaxOriginsReachedError extends UndiciError { name: 'MaxOriginsReachedError' code: 'UND_ERR_MAX_ORIGINS_REACHED' } + + /** SOCKS5 proxy related error. */ + export class Socks5ProxyError extends UndiciError { + constructor ( + message?: string, + code?: string + ) + name: 'Socks5ProxyError' + code: string + } } diff --git a/types/index.d.ts b/types/index.d.ts index 78ddeaae7b1..f1b66e811c4 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -18,6 +18,7 @@ import { SnapshotAgent } from './snapshot-agent' import { MockCallHistory, MockCallHistoryLog } from './mock-call-history' import mockErrors from './mock-errors' import ProxyAgent from './proxy-agent' +import Socks5ProxyAgent from './socks5-proxy-agent' import EnvHttpProxyAgent from './env-http-proxy-agent' import RetryHandler from './retry-handler' import RetryAgent from './retry-agent' @@ -43,7 +44,7 @@ export { Interceptable } from './mock-interceptor' declare function globalThisInstall (): void -export { Dispatcher, BalancedPool, RoundRobinPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, interceptors, cacheStores, MockClient, MockPool, MockAgent, SnapshotAgent, MockCallHistory, MockCallHistoryLog, mockErrors, ProxyAgent, EnvHttpProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler, RetryAgent, H2CClient, globalThisInstall as install } +export { Dispatcher, BalancedPool, RoundRobinPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, interceptors, cacheStores, MockClient, MockPool, MockAgent, SnapshotAgent, MockCallHistory, MockCallHistoryLog, mockErrors, ProxyAgent, Socks5ProxyAgent, EnvHttpProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler, RetryAgent, H2CClient, globalThisInstall as install } export default Undici declare namespace Undici { @@ -73,6 +74,8 @@ declare namespace Undici { const MockCallHistory: typeof import('./mock-call-history').MockCallHistory const MockCallHistoryLog: typeof import('./mock-call-history').MockCallHistoryLog const mockErrors: typeof import('./mock-errors').default + const ProxyAgent: typeof import('./proxy-agent').default + const Socks5ProxyAgent: typeof import('./socks5-proxy-agent').default const fetch: typeof import('./fetch').fetch const Headers: typeof import('./fetch').Headers const Response: typeof import('./fetch').Response diff --git a/types/socks5-proxy-agent.d.ts b/types/socks5-proxy-agent.d.ts new file mode 100644 index 00000000000..4b9c6a83a04 --- /dev/null +++ b/types/socks5-proxy-agent.d.ts @@ -0,0 +1,25 @@ +import Dispatcher from './dispatcher' +import buildConnector from './connector' +import { IncomingHttpHeaders } from './header' +import Pool from './pool' + +export default Socks5ProxyAgent + +declare class Socks5ProxyAgent extends Dispatcher { + constructor (proxyUrl: string | URL, options?: Socks5ProxyAgent.Options) +} + +declare namespace Socks5ProxyAgent { + export interface Options extends Pool.Options { + /** Additional headers to send with the proxy connection */ + headers?: IncomingHttpHeaders; + /** SOCKS5 proxy username for authentication */ + username?: string; + /** SOCKS5 proxy password for authentication */ + password?: string; + /** Custom connector function for proxy connection */ + connect?: buildConnector.connector; + /** TLS options for the proxy connection (for SOCKS5 over TLS) */ + proxyTls?: buildConnector.BuildOptions; + } +}