From ad64dc3a68cddccc2c1f05d0d8bb833f2c4860b2 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 20 Aug 2021 23:08:36 +0200 Subject: [PATCH] feat: add proxy support for tracker clients (#356) * Add a httpAgent options to http and websocket client trackers. * Add a socks proxy to udp client trackers. * Update http agent mock to node 5+ * Bugfix in socks configuration * Use new socket to connect to the proxy relay and slice the proxy header from the message * Add documentation for proxy * Provide http and https agents for proxy. Change proxy options structure and auto populate socks HTTP agents. * Update documentation * Check socks version for UDP proxy * Clone proxy settings to prevent Socks instances concurrency * Generate socks http agents on the fly (reuse is not working) * Use clone to deepcopy socks opts * Dont create agent for now since we cannot reuse it between requests. * Removed unused require * Add .gitignore * Fix merge conflict * Fix URL toString * Fix new Socket constructor Co-authored-by: Yoann Ciabaud --- README.md | 50 +++++++++++++++--- client.js | 2 + lib/client/http-tracker.js | 15 ++++-- lib/client/udp-tracker.js | 90 +++++++++++++++++++++++++-------- lib/client/websocket-tracker.js | 12 ++++- package.json | 5 +- test/client.js | 55 ++++++++++++++++++++ 7 files changed, 198 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 1ac3605..a7a5724 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,12 @@ var requiredOpts = { } var optionalOpts = { + // RTCPeerConnection config object (only used in browser) + rtcConfig: {}, + // User-Agent header for http requests + userAgent: '', + // Custom webrtc impl, useful in node to specify [wrtc](https://npmjs.com/package/wrtc) + wrtc: {}, getAnnounceOpts: function () { // Provide a callback that will be called whenever announce() is called // internally (on timer), or by the user @@ -75,12 +81,44 @@ var optionalOpts = { customParam: 'blah' // custom parameters supported } }, - // RTCPeerConnection config object (only used in browser) - rtcConfig: {}, - // User-Agent header for http requests - userAgent: '', - // Custom webrtc impl, useful in node to specify [wrtc](https://npmjs.com/package/wrtc) - wrtc: {}, + // Proxy config object + proxyOpts: { + // Socks proxy options (used to proxy requests in node) + socksProxy: { + // Configuration from socks module (https://github.com/JoshGlazebrook/socks) + proxy: { + // IP Address of Proxy (Required) + ipaddress: "1.2.3.4", + // TCP Port of Proxy (Required) + port: 1080, + // Proxy Type [4, 5] (Required) + // Note: 4 works for both 4 and 4a. + // Type 4 does not support UDP association relay + type: 5, + + // SOCKS 4 Specific: + + // UserId used when making a SOCKS 4/4a request. (Optional) + userid: "someuserid", + + // SOCKS 5 Specific: + + // Authentication used for SOCKS 5 (when it's required) (Optional) + authentication: { + username: "Josh", + password: "somepassword" + } + }, + + // Amount of time to wait for a connection to be established. (Optional) + // - defaults to 10000ms (10 seconds) + timeout: 10000 + }, + // NodeJS HTTP agents (used to proxy HTTP and Websocket requests in node) + // Populated with Socks.Agent if socksProxy is provided + httpAgent: {}, + httpsAgent: {} + }, } var client = new Client(requiredOpts) diff --git a/client.js b/client.js index 5a34f47..c091c31 100644 --- a/client.js +++ b/client.js @@ -24,6 +24,7 @@ const WebSocketTracker = require('./lib/client/websocket-tracker') * @param {number} opts.rtcConfig RTCPeerConnection configuration object * @param {number} opts.userAgent User-Agent header for http requests * @param {number} opts.wrtc custom webrtc impl (useful in node.js) + * @param {object} opts.proxyOpts proxy options (useful in node.js) */ class Client extends EventEmitter { constructor (opts = {}) { @@ -54,6 +55,7 @@ class Client extends EventEmitter { this._getAnnounceOpts = opts.getAnnounceOpts this._rtcConfig = opts.rtcConfig this._userAgent = opts.userAgent + this._proxyOpts = opts.proxyOpts // Support lazy 'wrtc' module initialization // See: https://github.com/webtorrent/webtorrent-hybrid/issues/46 diff --git a/lib/client/http-tracker.js b/lib/client/http-tracker.js index 890c1ea..604c682 100644 --- a/lib/client/http-tracker.js +++ b/lib/client/http-tracker.js @@ -1,8 +1,10 @@ const arrayRemove = require('unordered-array-remove') const bencode = require('bencode') +const clone = require('clone') const compact2string = require('compact2string') const debug = require('debug')('bittorrent-tracker:http-tracker') const get = require('simple-get') +const Socks = require('socks') const common = require('../common') const Tracker = require('./tracker') @@ -110,13 +112,20 @@ class HTTPTracker extends Tracker { _request (requestUrl, params, cb) { const self = this - const u = requestUrl + (!requestUrl.includes('?') ? '?' : '&') + - common.querystringStringify(params) + const parsedUrl = new URL(requestUrl + (requestUrl.indexOf('?') === -1 ? '?' : '&') + common.querystringStringify(params)) + let agent + if (this.client._proxyOpts) { + agent = parsedUrl.protocol === 'https:' ? this.client._proxyOpts.httpsAgent : this.client._proxyOpts.httpAgent + if (!agent && this.client._proxyOpts.socksProxy) { + agent = new Socks.Agent(clone(this.client._proxyOpts.socksProxy), (parsedUrl.protocol === 'https:')) + } + } this.cleanupFns.push(cleanup) let request = get.concat({ - url: u, + url: parsedUrl.toString(), + agent: agent, timeout: common.REQUEST_TIMEOUT, headers: { 'user-agent': this.client._userAgent || '' diff --git a/lib/client/udp-tracker.js b/lib/client/udp-tracker.js index 84a6059..79f777c 100644 --- a/lib/client/udp-tracker.js +++ b/lib/client/udp-tracker.js @@ -1,9 +1,11 @@ const arrayRemove = require('unordered-array-remove') const BN = require('bn.js') +const clone = require('clone') const compact2string = require('compact2string') const debug = require('debug')('bittorrent-tracker:udp-tracker') const dgram = require('dgram') const randombytes = require('randombytes') +const Socks = require('socks') const common = require('../common') const Tracker = require('./tracker') @@ -77,27 +79,65 @@ class UDPTracker extends Tracker { let { hostname, port } = common.parseUrl(this.announceUrl) if (port === '') port = 80 - let transactionId = genTransactionId() - let socket = dgram.createSocket('udp4') + let timeout + // Socket used to connect to the socks server to create a relay, null if socks is disabled + let proxySocket + // Socket used to connect to the tracker or to the socks relay if socks is enabled + let socket + // Contains the host/port of the socks relay + let relay - let timeout = setTimeout(() => { - // does not matter if `stopped` event arrives, so supress errors - if (opts.event === 'stopped') cleanup() - else onError(new Error(`tracker request timed out (${opts.event})`)) - timeout = null - }, common.REQUEST_TIMEOUT) - if (timeout.unref) timeout.unref() + let transactionId = genTransactionId() + + const proxyOpts = this.client._proxyOpts && clone(this.client._proxyOpts.socksProxy) + if (proxyOpts) { + if (!proxyOpts.proxy) proxyOpts.proxy = {} + // UDP requests uses the associate command + proxyOpts.proxy.command = 'associate' + if (!proxyOpts.target) { + // This should contain client IP and port but can be set to 0 if we don't have this information + proxyOpts.target = { + host: '0.0.0.0', + port: 0 + } + } + + if (proxyOpts.proxy.type === 5) { + Socks.createConnection(proxyOpts, onGotConnection) + } else { + debug('Ignoring Socks proxy for UDP request because type 5 is required') + onGotConnection(null) + } + } else { + onGotConnection(null) + } this.cleanupFns.push(cleanup) - send(Buffer.concat([ - common.CONNECTION_ID, - common.toUInt32(common.ACTIONS.CONNECT), - transactionId - ])) + function onGotConnection (err, s, info) { + if (err) return onError(err) - socket.once('error', onError) - socket.on('message', onSocketMessage) + proxySocket = s + socket = dgram.createSocket('udp4') + relay = info + + timeout = setTimeout(() => { + // does not matter if `stopped` event arrives, so supress errors + if (opts.event === 'stopped') cleanup() + else onError(new Error(`tracker request timed out (${opts.event})`)) + timeout = null + }, common.REQUEST_TIMEOUT) + if (timeout.unref) timeout.unref() + + send(Buffer.concat([ + common.CONNECTION_ID, + common.toUInt32(common.ACTIONS.CONNECT), + transactionId + ]), relay) + + socket.once('error', onError) + socket.on('message', onSocketMessage) + } function cleanup () { if (timeout) { @@ -111,6 +151,10 @@ class UDPTracker extends Tracker { socket.on('error', noop) // ignore all future errors try { socket.close() } catch (err) {} socket = null + if (proxySocket) { + try { proxySocket.close() } catch (err) {} + proxySocket = null + } } if (self.maybeDestroyCleanup) self.maybeDestroyCleanup() } @@ -128,6 +172,7 @@ class UDPTracker extends Tracker { } function onSocketMessage (msg) { + if (proxySocket) msg = msg.slice(10) if (msg.length < 8 || msg.readUInt32BE(4) !== transactionId.readUInt32BE(0)) { return onError(new Error('tracker sent invalid transaction id')) } @@ -211,8 +256,13 @@ class UDPTracker extends Tracker { } } - function send (message) { - socket.send(message, 0, message.length, port, hostname) + function send (message, proxyInfo) { + if (proxyInfo) { + const pack = Socks.createUDPFrame({ host: hostname, port: port }, message) + socket.send(pack, 0, pack.length, proxyInfo.port, proxyInfo.host) + } else { + socket.send(message, 0, message.length, port, hostname) + } } function announce (connectionId, opts) { @@ -232,7 +282,7 @@ class UDPTracker extends Tracker { common.toUInt32(0), // key (optional) common.toUInt32(opts.numwant), toUInt16(self.client._port) - ])) + ]), relay) } function scrape (connectionId) { @@ -247,7 +297,7 @@ class UDPTracker extends Tracker { common.toUInt32(common.ACTIONS.SCRAPE), transactionId, infoHash - ])) + ]), relay) } } } diff --git a/lib/client/websocket-tracker.js b/lib/client/websocket-tracker.js index 0a81a8c..480f7de 100644 --- a/lib/client/websocket-tracker.js +++ b/lib/client/websocket-tracker.js @@ -1,7 +1,9 @@ +const clone = require('clone') const debug = require('debug')('bittorrent-tracker:websocket-tracker') const Peer = require('simple-peer') const randombytes = require('randombytes') const Socket = require('simple-websocket') +const Socks = require('socks') const common = require('../common') const Tracker = require('./tracker') @@ -176,7 +178,15 @@ class WebSocketTracker extends Tracker { this._onSocketConnectBound() } } else { - this.socket = socketPool[this.announceUrl] = new Socket(this.announceUrl) + const parsedUrl = new URL(this.announceUrl) + let agent + if (this.client._proxyOpts) { + agent = parsedUrl.protocol === 'wss:' ? this.client._proxyOpts.httpsAgent : this.client._proxyOpts.httpAgent + if (!agent && this.client._proxyOpts.socksProxy) { + agent = new Socks.Agent(clone(this.client._proxyOpts.socksProxy), (parsedUrl.protocol === 'wss:')) + } + } + this.socket = socketPool[this.announceUrl] = new Socket({ url: this.announceUrl, agent: agent }) this.socket.consumers = 1 this.socket.once('connect', this._onSocketConnectBound) } diff --git a/package.json b/package.json index e9c6213..4640ffb 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "./lib/common-node.js": false, "./lib/client/http-tracker.js": false, "./lib/client/udp-tracker.js": false, - "./server.js": false + "./server.js": false, + "socks": false }, "chromeapp": { "./server.js": false, @@ -28,6 +29,7 @@ "bittorrent-peerid": "^1.3.3", "bn.js": "^5.2.0", "chrome-dgram": "^3.0.6", + "clone": "^1.0.2", "compact2string": "^1.4.1", "debug": "^4.1.1", "ip": "^1.1.5", @@ -42,6 +44,7 @@ "simple-get": "^4.0.0", "simple-peer": "^9.11.0", "simple-websocket": "^9.1.0", + "socks": "^1.1.9", "string2compact": "^1.3.0", "unordered-array-remove": "^1.0.2", "ws": "^7.4.5" diff --git a/test/client.js b/test/client.js index 326aa3a..8afab8b 100644 --- a/test/client.js +++ b/test/client.js @@ -1,6 +1,8 @@ const Client = require('../') const common = require('./common') +const http = require('http') const fixtures = require('webtorrent-fixtures') +const net = require('net') const test = require('tape') const peerId1 = Buffer.from('01234567890123456789') @@ -565,3 +567,56 @@ test('ws: invalid tracker url', t => { test('ws: invalid tracker url with slash', t => { testUnsupportedTracker(t, 'ws://') }) + +function testClientStartHttpAgent (t, serverType) { + t.plan(5) + + common.createServer(t, serverType, function (server, announceUrl) { + const agent = new http.Agent() + let agentUsed = false + agent.createConnection = function (opts, fn) { + agentUsed = true + return net.createConnection(opts, fn) + } + const client = new Client({ + infoHash: fixtures.leaves.parsedTorrent.infoHash, + announce: announceUrl, + peerId: peerId1, + port: port, + wrtc: {}, + proxyOpts: { + httpAgent: agent + } + }) + + if (serverType === 'ws') common.mockWebsocketTracker(client) + client.on('error', function (err) { t.error(err) }) + client.on('warning', function (err) { t.error(err) }) + + client.once('update', function (data) { + t.equal(data.announce, announceUrl) + t.equal(typeof data.complete, 'number') + t.equal(typeof data.incomplete, 'number') + + t.ok(agentUsed) + + client.stop() + + client.once('update', function () { + t.pass('got response to stop') + server.close() + client.destroy() + }) + }) + + client.start() + }) +} + +test('http: client.start(httpAgent)', function (t) { + testClientStartHttpAgent(t, 'http') +}) + +test('ws: client.start(httpAgent)', function (t) { + testClientStartHttpAgent(t, 'ws') +})