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 <yoann@sonora.io>
This commit is contained in:
Alex 2021-08-20 23:08:36 +02:00 committed by GitHub
parent 71deb99dca
commit ad64dc3a68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 198 additions and 31 deletions

View File

@ -65,6 +65,12 @@ var requiredOpts = {
} }
var optionalOpts = { 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 () { getAnnounceOpts: function () {
// Provide a callback that will be called whenever announce() is called // Provide a callback that will be called whenever announce() is called
// internally (on timer), or by the user // internally (on timer), or by the user
@ -75,12 +81,44 @@ var optionalOpts = {
customParam: 'blah' // custom parameters supported customParam: 'blah' // custom parameters supported
} }
}, },
// RTCPeerConnection config object (only used in browser) // Proxy config object
rtcConfig: {}, proxyOpts: {
// User-Agent header for http requests // Socks proxy options (used to proxy requests in node)
userAgent: '', socksProxy: {
// Custom webrtc impl, useful in node to specify [wrtc](https://npmjs.com/package/wrtc) // Configuration from socks module (https://github.com/JoshGlazebrook/socks)
wrtc: {}, 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) var client = new Client(requiredOpts)

View File

@ -24,6 +24,7 @@ const WebSocketTracker = require('./lib/client/websocket-tracker')
* @param {number} opts.rtcConfig RTCPeerConnection configuration object * @param {number} opts.rtcConfig RTCPeerConnection configuration object
* @param {number} opts.userAgent User-Agent header for http requests * @param {number} opts.userAgent User-Agent header for http requests
* @param {number} opts.wrtc custom webrtc impl (useful in node.js) * @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 { class Client extends EventEmitter {
constructor (opts = {}) { constructor (opts = {}) {
@ -54,6 +55,7 @@ class Client extends EventEmitter {
this._getAnnounceOpts = opts.getAnnounceOpts this._getAnnounceOpts = opts.getAnnounceOpts
this._rtcConfig = opts.rtcConfig this._rtcConfig = opts.rtcConfig
this._userAgent = opts.userAgent this._userAgent = opts.userAgent
this._proxyOpts = opts.proxyOpts
// Support lazy 'wrtc' module initialization // Support lazy 'wrtc' module initialization
// See: https://github.com/webtorrent/webtorrent-hybrid/issues/46 // See: https://github.com/webtorrent/webtorrent-hybrid/issues/46

View File

@ -1,8 +1,10 @@
const arrayRemove = require('unordered-array-remove') const arrayRemove = require('unordered-array-remove')
const bencode = require('bencode') const bencode = require('bencode')
const clone = require('clone')
const compact2string = require('compact2string') const compact2string = require('compact2string')
const debug = require('debug')('bittorrent-tracker:http-tracker') const debug = require('debug')('bittorrent-tracker:http-tracker')
const get = require('simple-get') const get = require('simple-get')
const Socks = require('socks')
const common = require('../common') const common = require('../common')
const Tracker = require('./tracker') const Tracker = require('./tracker')
@ -110,13 +112,20 @@ class HTTPTracker extends Tracker {
_request (requestUrl, params, cb) { _request (requestUrl, params, cb) {
const self = this const self = this
const u = requestUrl + (!requestUrl.includes('?') ? '?' : '&') + const parsedUrl = new URL(requestUrl + (requestUrl.indexOf('?') === -1 ? '?' : '&') + common.querystringStringify(params))
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) this.cleanupFns.push(cleanup)
let request = get.concat({ let request = get.concat({
url: u, url: parsedUrl.toString(),
agent: agent,
timeout: common.REQUEST_TIMEOUT, timeout: common.REQUEST_TIMEOUT,
headers: { headers: {
'user-agent': this.client._userAgent || '' 'user-agent': this.client._userAgent || ''

View File

@ -1,9 +1,11 @@
const arrayRemove = require('unordered-array-remove') const arrayRemove = require('unordered-array-remove')
const BN = require('bn.js') const BN = require('bn.js')
const clone = require('clone')
const compact2string = require('compact2string') const compact2string = require('compact2string')
const debug = require('debug')('bittorrent-tracker:udp-tracker') const debug = require('debug')('bittorrent-tracker:udp-tracker')
const dgram = require('dgram') const dgram = require('dgram')
const randombytes = require('randombytes') const randombytes = require('randombytes')
const Socks = require('socks')
const common = require('../common') const common = require('../common')
const Tracker = require('./tracker') const Tracker = require('./tracker')
@ -77,27 +79,65 @@ class UDPTracker extends Tracker {
let { hostname, port } = common.parseUrl(this.announceUrl) let { hostname, port } = common.parseUrl(this.announceUrl)
if (port === '') port = 80 if (port === '') port = 80
let transactionId = genTransactionId() let timeout
let socket = dgram.createSocket('udp4') // 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(() => { let transactionId = genTransactionId()
// does not matter if `stopped` event arrives, so supress errors
if (opts.event === 'stopped') cleanup() const proxyOpts = this.client._proxyOpts && clone(this.client._proxyOpts.socksProxy)
else onError(new Error(`tracker request timed out (${opts.event})`)) if (proxyOpts) {
timeout = null if (!proxyOpts.proxy) proxyOpts.proxy = {}
}, common.REQUEST_TIMEOUT) // UDP requests uses the associate command
if (timeout.unref) timeout.unref() 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) this.cleanupFns.push(cleanup)
send(Buffer.concat([ function onGotConnection (err, s, info) {
common.CONNECTION_ID, if (err) return onError(err)
common.toUInt32(common.ACTIONS.CONNECT),
transactionId
]))
socket.once('error', onError) proxySocket = s
socket.on('message', onSocketMessage) 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 () { function cleanup () {
if (timeout) { if (timeout) {
@ -111,6 +151,10 @@ class UDPTracker extends Tracker {
socket.on('error', noop) // ignore all future errors socket.on('error', noop) // ignore all future errors
try { socket.close() } catch (err) {} try { socket.close() } catch (err) {}
socket = null socket = null
if (proxySocket) {
try { proxySocket.close() } catch (err) {}
proxySocket = null
}
} }
if (self.maybeDestroyCleanup) self.maybeDestroyCleanup() if (self.maybeDestroyCleanup) self.maybeDestroyCleanup()
} }
@ -128,6 +172,7 @@ class UDPTracker extends Tracker {
} }
function onSocketMessage (msg) { function onSocketMessage (msg) {
if (proxySocket) msg = msg.slice(10)
if (msg.length < 8 || msg.readUInt32BE(4) !== transactionId.readUInt32BE(0)) { if (msg.length < 8 || msg.readUInt32BE(4) !== transactionId.readUInt32BE(0)) {
return onError(new Error('tracker sent invalid transaction id')) return onError(new Error('tracker sent invalid transaction id'))
} }
@ -211,8 +256,13 @@ class UDPTracker extends Tracker {
} }
} }
function send (message) { function send (message, proxyInfo) {
socket.send(message, 0, message.length, port, hostname) 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) { function announce (connectionId, opts) {
@ -232,7 +282,7 @@ class UDPTracker extends Tracker {
common.toUInt32(0), // key (optional) common.toUInt32(0), // key (optional)
common.toUInt32(opts.numwant), common.toUInt32(opts.numwant),
toUInt16(self.client._port) toUInt16(self.client._port)
])) ]), relay)
} }
function scrape (connectionId) { function scrape (connectionId) {
@ -247,7 +297,7 @@ class UDPTracker extends Tracker {
common.toUInt32(common.ACTIONS.SCRAPE), common.toUInt32(common.ACTIONS.SCRAPE),
transactionId, transactionId,
infoHash infoHash
])) ]), relay)
} }
} }
} }

View File

@ -1,7 +1,9 @@
const clone = require('clone')
const debug = require('debug')('bittorrent-tracker:websocket-tracker') const debug = require('debug')('bittorrent-tracker:websocket-tracker')
const Peer = require('simple-peer') const Peer = require('simple-peer')
const randombytes = require('randombytes') const randombytes = require('randombytes')
const Socket = require('simple-websocket') const Socket = require('simple-websocket')
const Socks = require('socks')
const common = require('../common') const common = require('../common')
const Tracker = require('./tracker') const Tracker = require('./tracker')
@ -176,7 +178,15 @@ class WebSocketTracker extends Tracker {
this._onSocketConnectBound() this._onSocketConnectBound()
} }
} else { } 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.consumers = 1
this.socket.once('connect', this._onSocketConnectBound) this.socket.once('connect', this._onSocketConnectBound)
} }

View File

@ -14,7 +14,8 @@
"./lib/common-node.js": false, "./lib/common-node.js": false,
"./lib/client/http-tracker.js": false, "./lib/client/http-tracker.js": false,
"./lib/client/udp-tracker.js": false, "./lib/client/udp-tracker.js": false,
"./server.js": false "./server.js": false,
"socks": false
}, },
"chromeapp": { "chromeapp": {
"./server.js": false, "./server.js": false,
@ -28,6 +29,7 @@
"bittorrent-peerid": "^1.3.3", "bittorrent-peerid": "^1.3.3",
"bn.js": "^5.2.0", "bn.js": "^5.2.0",
"chrome-dgram": "^3.0.6", "chrome-dgram": "^3.0.6",
"clone": "^1.0.2",
"compact2string": "^1.4.1", "compact2string": "^1.4.1",
"debug": "^4.1.1", "debug": "^4.1.1",
"ip": "^1.1.5", "ip": "^1.1.5",
@ -42,6 +44,7 @@
"simple-get": "^4.0.0", "simple-get": "^4.0.0",
"simple-peer": "^9.11.0", "simple-peer": "^9.11.0",
"simple-websocket": "^9.1.0", "simple-websocket": "^9.1.0",
"socks": "^1.1.9",
"string2compact": "^1.3.0", "string2compact": "^1.3.0",
"unordered-array-remove": "^1.0.2", "unordered-array-remove": "^1.0.2",
"ws": "^7.4.5" "ws": "^7.4.5"

View File

@ -1,6 +1,8 @@
const Client = require('../') const Client = require('../')
const common = require('./common') const common = require('./common')
const http = require('http')
const fixtures = require('webtorrent-fixtures') const fixtures = require('webtorrent-fixtures')
const net = require('net')
const test = require('tape') const test = require('tape')
const peerId1 = Buffer.from('01234567890123456789') const peerId1 = Buffer.from('01234567890123456789')
@ -565,3 +567,56 @@ test('ws: invalid tracker url', t => {
test('ws: invalid tracker url with slash', t => { test('ws: invalid tracker url with slash', t => {
testUnsupportedTracker(t, 'ws://') 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')
})