bittorrent-tracker/lib/client/websocket-tracker.js

443 lines
12 KiB
JavaScript
Raw Normal View History

const clone = require('clone')
2018-10-03 12:44:11 +00:00
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')
2018-10-03 12:44:11 +00:00
const common = require('../common')
const Tracker = require('./tracker')
// Use a socket pool, so tracker clients share WebSocket objects for the same server.
// In practice, WebSockets are pretty slow to establish, so this gives a nice performance
// boost, and saves browser resources.
2018-10-03 12:44:11 +00:00
const socketPool = {}
const RECONNECT_MINIMUM = 10 * 1000
const RECONNECT_MAXIMUM = 60 * 60 * 1000
const RECONNECT_VARIANCE = 5 * 60 * 1000
2018-10-03 12:44:11 +00:00
const OFFER_TIMEOUT = 50 * 1000
2018-10-03 12:44:11 +00:00
class WebSocketTracker extends Tracker {
2021-06-15 01:54:41 +00:00
constructor (client, announceUrl) {
2018-10-03 12:44:11 +00:00
super(client, announceUrl)
debug('new websocket tracker %s', announceUrl)
2018-10-03 13:06:38 +00:00
this.peers = {} // peers (offer id -> peer)
this.socket = null
2018-10-03 13:06:38 +00:00
this.reconnecting = false
this.retries = 0
this.reconnectTimer = null
2018-10-03 12:44:11 +00:00
// Simple boolean flag to track whether the socket has received data from
// the websocket server since the last time socket.send() was called.
2018-10-03 13:06:38 +00:00
this.expectingResponse = false
2018-10-03 13:06:38 +00:00
this._openSocket()
2015-04-10 23:58:21 +00:00
}
2018-10-03 12:44:11 +00:00
announce (opts) {
2018-10-03 13:06:38 +00:00
if (this.destroyed || this.reconnecting) return
if (!this.socket.connected) {
this.socket.once('connect', () => {
this.announce(opts)
2018-10-03 12:44:11 +00:00
})
return
}
2018-10-03 12:44:11 +00:00
const params = Object.assign({}, opts, {
action: 'announce',
2018-10-03 13:06:38 +00:00
info_hash: this.client._infoHashBinary,
peer_id: this.client._peerIdBinary
2018-10-03 12:44:11 +00:00
})
2018-10-03 13:06:38 +00:00
if (this._trackerId) params.trackerid = this._trackerId
2018-10-03 12:44:11 +00:00
if (opts.event === 'stopped' || opts.event === 'completed') {
// Don't include offers with 'stopped' or 'completed' event
2018-10-03 13:06:38 +00:00
this._send(params)
2018-10-03 12:44:11 +00:00
} else {
// Limit the number of offers that are generated, since it can be slow
const numwant = Math.min(opts.numwant, 5)
2018-10-03 13:06:38 +00:00
this._generateOffers(numwant, offers => {
2018-10-03 12:44:11 +00:00
params.numwant = numwant
params.offers = offers
2018-10-03 13:06:38 +00:00
this._send(params)
2018-10-03 12:44:11 +00:00
})
}
}
2018-10-03 12:44:11 +00:00
scrape (opts) {
2018-10-03 13:06:38 +00:00
if (this.destroyed || this.reconnecting) return
if (!this.socket.connected) {
this.socket.once('connect', () => {
this.scrape(opts)
2018-10-03 12:44:11 +00:00
})
return
}
2018-10-03 12:44:11 +00:00
const infoHashes = (Array.isArray(opts.infoHash) && opts.infoHash.length > 0)
2021-06-15 01:54:41 +00:00
? opts.infoHash.map(infoHash => infoHash.toString('binary'))
2018-10-03 13:06:38 +00:00
: (opts.infoHash && opts.infoHash.toString('binary')) || this.client._infoHashBinary
2018-10-03 12:44:11 +00:00
const params = {
action: 'scrape',
info_hash: infoHashes
}
2018-10-03 13:06:38 +00:00
this._send(params)
2018-10-03 12:44:11 +00:00
}
2018-10-03 13:06:38 +00:00
destroy (cb = noop) {
if (this.destroyed) return cb(null)
2018-10-03 13:06:38 +00:00
this.destroyed = true
2018-10-03 13:06:38 +00:00
clearInterval(this.interval)
clearTimeout(this.reconnectTimer)
2018-10-03 12:44:11 +00:00
// Destroy peers
2018-10-03 13:06:38 +00:00
for (const peerId in this.peers) {
const peer = this.peers[peerId]
2018-10-03 12:44:11 +00:00
clearTimeout(peer.trackerTimeout)
peer.destroy()
}
2018-10-03 13:06:38 +00:00
this.peers = null
if (this.socket) {
this.socket.removeListener('connect', this._onSocketConnectBound)
this.socket.removeListener('data', this._onSocketDataBound)
this.socket.removeListener('close', this._onSocketCloseBound)
this.socket.removeListener('error', this._onSocketErrorBound)
this.socket = null
2018-10-03 12:44:11 +00:00
}
2018-10-03 13:06:38 +00:00
this._onSocketConnectBound = null
this._onSocketErrorBound = null
this._onSocketDataBound = null
this._onSocketCloseBound = null
2018-10-03 13:06:38 +00:00
if (socketPool[this.announceUrl]) {
socketPool[this.announceUrl].consumers -= 1
2018-10-03 12:44:11 +00:00
}
2016-03-17 00:58:47 +00:00
2018-10-03 12:44:11 +00:00
// Other instances are using the socket, so there's nothing left to do here
2018-10-03 13:06:38 +00:00
if (socketPool[this.announceUrl].consumers > 0) return cb()
2018-10-03 13:06:38 +00:00
let socket = socketPool[this.announceUrl]
delete socketPool[this.announceUrl]
2018-10-03 12:44:11 +00:00
socket.on('error', noop) // ignore all future errors
socket.once('close', cb)
2020-10-29 20:25:57 +00:00
let timeout
2018-10-03 12:44:11 +00:00
// If there is no data response expected, destroy immediately.
2018-10-03 13:06:38 +00:00
if (!this.expectingResponse) return destroyCleanup()
2018-10-03 12:44:11 +00:00
// Otherwise, wait a short time for potential responses to come in from the
// server, then force close the socket.
2020-10-29 20:25:57 +00:00
timeout = setTimeout(destroyCleanup, common.DESTROY_TIMEOUT)
2018-10-03 12:44:11 +00:00
// But, if a response comes from the server before the timeout fires, do cleanup
// right away.
socket.once('data', destroyCleanup)
2018-10-03 12:44:11 +00:00
function destroyCleanup () {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
socket.removeListener('data', destroyCleanup)
socket.destroy()
socket = null
}
}
2018-10-03 12:44:11 +00:00
_openSocket () {
2018-10-03 13:06:38 +00:00
this.destroyed = false
2018-10-03 13:06:38 +00:00
if (!this.peers) this.peers = {}
2018-10-03 13:06:38 +00:00
this._onSocketConnectBound = () => {
this._onSocketConnect()
2018-10-03 12:44:11 +00:00
}
2018-10-03 13:06:38 +00:00
this._onSocketErrorBound = err => {
this._onSocketError(err)
2018-10-03 12:44:11 +00:00
}
2018-10-03 13:06:38 +00:00
this._onSocketDataBound = data => {
this._onSocketData(data)
2018-10-03 12:44:11 +00:00
}
2018-10-03 13:06:38 +00:00
this._onSocketCloseBound = () => {
this._onSocketClose()
2018-10-03 12:44:11 +00:00
}
2018-10-03 13:06:38 +00:00
this.socket = socketPool[this.announceUrl]
if (this.socket) {
socketPool[this.announceUrl].consumers += 1
if (this.socket.connected) {
this._onSocketConnectBound()
}
2018-10-03 12:44:11 +00:00
} else {
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 })
2018-10-03 13:06:38 +00:00
this.socket.consumers = 1
this.socket.once('connect', this._onSocketConnectBound)
2018-10-03 12:44:11 +00:00
}
2018-10-03 13:06:38 +00:00
this.socket.on('data', this._onSocketDataBound)
this.socket.once('close', this._onSocketCloseBound)
this.socket.once('error', this._onSocketErrorBound)
2018-10-03 12:44:11 +00:00
}
2018-10-03 12:44:11 +00:00
_onSocketConnect () {
2018-10-03 13:06:38 +00:00
if (this.destroyed) return
2018-10-03 13:06:38 +00:00
if (this.reconnecting) {
this.reconnecting = false
this.retries = 0
this.announce(this.client._defaultAnnounceOpts())
2018-10-03 12:44:11 +00:00
}
}
2018-10-03 12:44:11 +00:00
_onSocketData (data) {
2018-10-03 13:06:38 +00:00
if (this.destroyed) return
2018-10-03 13:06:38 +00:00
this.expectingResponse = false
2018-10-03 12:44:11 +00:00
try {
data = JSON.parse(data)
} catch (err) {
2018-10-03 13:06:38 +00:00
this.client.emit('warning', new Error('Invalid tracker response'))
2018-10-03 12:44:11 +00:00
return
}
2018-10-03 12:44:11 +00:00
if (data.action === 'announce') {
2018-10-03 13:06:38 +00:00
this._onAnnounceResponse(data)
2018-10-03 12:44:11 +00:00
} else if (data.action === 'scrape') {
2018-10-03 13:06:38 +00:00
this._onScrapeResponse(data)
2018-10-03 12:44:11 +00:00
} else {
2018-10-03 13:06:38 +00:00
this._onSocketError(new Error(`invalid action in WS response: ${data.action}`))
2018-10-03 12:44:11 +00:00
}
}
2018-10-03 12:44:11 +00:00
_onAnnounceResponse (data) {
2018-10-03 13:06:38 +00:00
if (data.info_hash !== this.client._infoHashBinary) {
2018-10-03 12:44:11 +00:00
debug(
'ignoring websocket data from %s for %s (looking for %s: reused socket)',
2018-10-03 13:06:38 +00:00
this.announceUrl, common.binaryToHex(data.info_hash), this.client.infoHash
2018-10-03 12:44:11 +00:00
)
return
}
2018-10-03 13:06:38 +00:00
if (data.peer_id && data.peer_id === this.client._peerIdBinary) {
2018-10-03 12:44:11 +00:00
// ignore offers/answers from this client
return
}
2018-10-03 12:44:11 +00:00
debug(
'received %s from %s for %s',
2018-10-03 13:06:38 +00:00
JSON.stringify(data), this.announceUrl, this.client.infoHash
2018-10-03 12:44:11 +00:00
)
2018-10-03 12:44:11 +00:00
const failure = data['failure reason']
2018-10-03 13:06:38 +00:00
if (failure) return this.client.emit('warning', new Error(failure))
2018-10-03 12:44:11 +00:00
const warning = data['warning message']
2018-10-03 13:06:38 +00:00
if (warning) this.client.emit('warning', new Error(warning))
2018-10-03 12:44:11 +00:00
const interval = data.interval || data['min interval']
2018-10-03 13:06:38 +00:00
if (interval) this.setInterval(interval * 1000)
2018-10-03 12:44:11 +00:00
const trackerId = data['tracker id']
if (trackerId) {
// If absent, do not discard previous trackerId value
2018-10-03 13:06:38 +00:00
this._trackerId = trackerId
2018-10-03 12:44:11 +00:00
}
2018-10-03 12:44:11 +00:00
if (data.complete != null) {
const response = Object.assign({}, data, {
2018-10-03 13:06:38 +00:00
announce: this.announceUrl,
2018-10-03 12:44:11 +00:00
infoHash: common.binaryToHex(data.info_hash)
})
2018-10-03 13:06:38 +00:00
this.client.emit('update', response)
2018-10-03 12:44:11 +00:00
}
2018-10-03 12:44:11 +00:00
let peer
if (data.offer && data.peer_id) {
debug('creating peer (from remote offer)')
2018-10-03 13:06:38 +00:00
peer = this._createPeer()
peer.id = common.binaryToHex(data.peer_id)
2018-10-03 12:44:11 +00:00
peer.once('signal', answer => {
const params = {
action: 'announce',
2018-10-03 13:06:38 +00:00
info_hash: this.client._infoHashBinary,
peer_id: this.client._peerIdBinary,
2018-10-03 12:44:11 +00:00
to_peer_id: data.peer_id,
answer,
offer_id: data.offer_id
}
2018-10-03 13:06:38 +00:00
if (this._trackerId) params.trackerid = this._trackerId
this._send(params)
2018-10-03 12:44:11 +00:00
})
2018-10-03 13:06:38 +00:00
this.client.emit('peer', peer)
peer.signal(data.offer)
2018-10-03 12:44:11 +00:00
}
2018-10-03 12:44:11 +00:00
if (data.answer && data.peer_id) {
const offerId = common.binaryToHex(data.offer_id)
2018-10-03 13:06:38 +00:00
peer = this.peers[offerId]
2018-10-03 12:44:11 +00:00
if (peer) {
peer.id = common.binaryToHex(data.peer_id)
2018-10-03 13:06:38 +00:00
this.client.emit('peer', peer)
peer.signal(data.answer)
2018-10-03 12:44:11 +00:00
clearTimeout(peer.trackerTimeout)
peer.trackerTimeout = null
2018-10-03 13:06:38 +00:00
delete this.peers[offerId]
2018-10-03 12:44:11 +00:00
} else {
debug(`got unexpected answer: ${JSON.stringify(data.answer)}`)
}
}
}
2018-10-03 12:44:11 +00:00
_onScrapeResponse (data) {
data = data.files || {}
2018-10-03 12:44:11 +00:00
const keys = Object.keys(data)
if (keys.length === 0) {
2018-10-03 13:06:38 +00:00
this.client.emit('warning', new Error('invalid scrape response'))
2018-10-03 12:44:11 +00:00
return
}
2018-10-03 12:44:11 +00:00
keys.forEach(infoHash => {
// TODO: optionally handle data.flags.min_request_interval
// (separate from announce interval)
const response = Object.assign(data[infoHash], {
2018-10-03 13:06:38 +00:00
announce: this.announceUrl,
2018-10-03 12:44:11 +00:00
infoHash: common.binaryToHex(infoHash)
})
2018-10-03 13:06:38 +00:00
this.client.emit('scrape', response)
})
2018-10-03 12:44:11 +00:00
}
2018-10-03 12:44:11 +00:00
_onSocketClose () {
2018-10-03 13:06:38 +00:00
if (this.destroyed) return
this.destroy()
this._startReconnectTimer()
2018-10-03 12:44:11 +00:00
}
2018-10-03 12:44:11 +00:00
_onSocketError (err) {
2018-10-03 13:06:38 +00:00
if (this.destroyed) return
this.destroy()
2018-10-03 12:44:11 +00:00
// errors will often happen if a tracker is offline, so don't treat it as fatal
2018-10-03 13:06:38 +00:00
this.client.emit('warning', err)
this._startReconnectTimer()
2018-10-03 12:44:11 +00:00
}
2018-10-03 12:44:11 +00:00
_startReconnectTimer () {
2018-10-03 13:06:38 +00:00
const ms = Math.floor(Math.random() * RECONNECT_VARIANCE) + Math.min(Math.pow(2, this.retries) * RECONNECT_MINIMUM, RECONNECT_MAXIMUM)
2015-07-17 01:33:54 +00:00
2018-10-03 13:06:38 +00:00
this.reconnecting = true
clearTimeout(this.reconnectTimer)
this.reconnectTimer = setTimeout(() => {
this.retries++
this._openSocket()
2018-10-03 12:44:11 +00:00
}, ms)
2018-10-03 13:06:38 +00:00
if (this.reconnectTimer.unref) this.reconnectTimer.unref()
2015-07-17 01:33:54 +00:00
2018-10-03 12:44:11 +00:00
debug('reconnecting socket in %s ms', ms)
}
2018-10-03 12:44:11 +00:00
_send (params) {
2018-10-03 13:06:38 +00:00
if (this.destroyed) return
this.expectingResponse = true
2018-10-03 12:44:11 +00:00
const message = JSON.stringify(params)
debug('send %s', message)
2018-10-03 13:06:38 +00:00
this.socket.send(message)
2018-10-03 12:44:11 +00:00
}
2018-10-03 12:44:11 +00:00
_generateOffers (numwant, cb) {
const self = this
const offers = []
debug('generating %s offers', numwant)
2018-10-03 12:44:11 +00:00
for (let i = 0; i < numwant; ++i) {
generateOffer()
}
checkDone()
function generateOffer () {
const offerId = randombytes(20).toString('hex')
debug('creating peer (from _generateOffers)')
const peer = self.peers[offerId] = self._createPeer({ initiator: true })
peer.once('signal', offer => {
offers.push({
offer,
offer_id: common.hexToBinary(offerId)
})
checkDone()
})
2018-10-03 12:44:11 +00:00
peer.trackerTimeout = setTimeout(() => {
debug('tracker timeout: destroying peer')
peer.trackerTimeout = null
delete self.peers[offerId]
peer.destroy()
}, OFFER_TIMEOUT)
if (peer.trackerTimeout.unref) peer.trackerTimeout.unref()
}
2018-10-03 12:44:11 +00:00
function checkDone () {
if (offers.length === numwant) {
debug('generated %s offers', numwant)
cb(offers)
}
}
}
2018-10-03 12:44:11 +00:00
_createPeer (opts) {
const self = this
2017-04-08 01:24:16 +00:00
2018-10-03 12:44:11 +00:00
opts = Object.assign({
trickle: false,
config: self.client._rtcConfig,
wrtc: self.client._wrtc
}, opts)
2017-04-08 01:24:16 +00:00
2018-10-03 12:44:11 +00:00
const peer = new Peer(opts)
2018-10-03 12:44:11 +00:00
peer.once('error', onError)
peer.once('connect', onConnect)
2018-10-03 12:44:11 +00:00
return peer
2018-10-03 12:44:11 +00:00
// Handle peer 'error' events that are fired *before* the peer is emitted in
// a 'peer' event.
function onError (err) {
self.client.emit('warning', new Error(`Connection error: ${err.message}`))
peer.destroy()
}
2018-10-03 12:44:11 +00:00
// Once the peer is emitted in a 'peer' event, then it's the consumer's
// responsibility to listen for errors, so the listeners are removed here.
function onConnect () {
peer.removeListener('error', onError)
peer.removeListener('connect', onConnect)
}
}
}
2018-10-03 12:44:11 +00:00
WebSocketTracker.prototype.DEFAULT_ANNOUNCE_INTERVAL = 30 * 1000 // 30 seconds
// Normally this shouldn't be accessed but is occasionally useful
WebSocketTracker._socketPool = socketPool
function noop () {}
2018-10-03 12:44:11 +00:00
module.exports = WebSocketTracker