From 31b82e0a2b1bff84283d69ff5ee81d4c260efafc Mon Sep 17 00:00:00 2001 From: Feross Aboukhadijeh Date: Thu, 27 Mar 2014 00:17:49 -0700 Subject: [PATCH] add bittorrent tracker server implementation! --- README.md | 2 +- index.js | 184 +++++++++++++++++++++++++++++++++-- package.json | 7 +- test/{basic.js => client.js} | 8 +- test/server.js | 74 ++++++++++++++ 5 files changed, 264 insertions(+), 11 deletions(-) rename test/{basic.js => client.js} (92%) create mode 100644 test/server.js diff --git a/README.md b/README.md index 50f9c79..0ed3bd7 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ clients. The requests include metrics from clients that help the tracker keep ov statistics about the torrent. The response includes a peer list that helps the client participate in the torrent. -Also see [BitTorrent DHT](https://github.com/feross/bittorrent-dht). This module is used +Also see [bittorrent-dht](https://github.com/feross/bittorrent-dht). This module is used by [WebTorrent](http://webtorrent.io). ## install diff --git a/index.js b/index.js index d998ada..fa495a5 100644 --- a/index.js +++ b/index.js @@ -1,20 +1,21 @@ exports.Client = Client -// TODO: exports.Server = Server -// TODO: support connecting to UDP trackers (http://www.bittorrent.org/beps/bep_0015.html) +exports.Server = Server var bncode = require('bncode') var compact2string = require('compact2string') var EventEmitter = require('events').EventEmitter var extend = require('extend.js') +var hat = require('hat') var http = require('http') var inherits = require('inherits') var querystring = require('querystring') +var string2compact = require('string2compact') inherits(Client, EventEmitter) function Client (peerId, port, torrent, opts) { var self = this - if (!(self instanceof Client)) return new Client(peerId, port, torrent) + if (!(self instanceof Client)) return new Client(peerId, port, torrent, opts) EventEmitter.call(self) self._opts = opts || {} @@ -143,7 +144,7 @@ Client.prototype._handleResponse = function (data, announce) { if (interval && !self._opts.interval && self._intervalMs !== 0) { // use the interval the tracker recommends, UNLESS the user manually specifies an // interval they want to use - self.setInterval(interval) + self.setInterval(interval * 1000) } var trackerId = data['tracker id'] @@ -173,9 +174,180 @@ Client.prototype._handleResponse = function (data, announce) { } } +inherits(Server, EventEmitter) + +function Server (opts) { + var self = this + if (!(self instanceof Server)) return new Server(opts) + EventEmitter.call(self) + opts = opts || {} + + self._interval = opts.interval + ? opts.interval / 1000 + : 10 * 60 // 10 min (in secs) + + self._trustProxy = !!opts.trustProxy + + self._swarms = {} + + self._server = http.createServer() + self._server.on('request', self._onRequest.bind(self)) +} + +Server.prototype.listen = function (port) { + var self = this + self._server.listen(port, function () { + self.emit('listening') + }) +} + +Server.prototype.close = function (cb) { + var self = this + self._server.close(cb) +} + +Server.prototype._onRequest = function (req, res) { + var self = this + var s = req.url.split('?') + if (s[0] === '/announce') { + var params = querystring.parse(s[1]) + + var ip = self._trustProxy + ? req.headers['x-forwarded-for'] || req.connection.remoteAddress + : req.connection.remoteAddress + var port = Number(params.port) + var addr = ip + ':' + port + + var infoHash = bytewiseDecodeURIComponent(params.info_hash) + var peerId = bytewiseDecodeURIComponent(params.peer_id) + + var swarm = self._swarms[infoHash] + if (!swarm) { + swarm = self._swarms[infoHash] = { + complete: 0, + incomplete: 0, + peers: {} + } + } + var peer = swarm.peers[addr] + switch (params.event) { + case 'started': + if (peer) { + return + } + + var left = Number(params.left) + + if (left === 0) { + swarm.complete += 1 + } else { + swarm.incomplete += 1 + } + + peer = swarm.peers[addr] = { + ip: ip, + port: port, + peerId: peerId + } + self.emit('start', addr, params) + break + + case 'stopped': + if (!peer) { + return + } + + if (peer.complete) { + swarm.complete -= 1 + } else { + swarm.incomplete -= 1 + } + + delete swarm.peers[addr] + + self.emit('stop', addr, params) + break + + case 'completed': + if (!peer || peer.complete) { + return + } + peer.complete = true + + swarm.complete += 1 + swarm.incomplete -= 1 + + self.emit('complete', addr, params) + break + + case '': // update + case undefined: + if (!peer) { + return + } + + self.emit('update', addr, params) + break + + default: + res.end(bncode.encode({ + 'failure reason': 'unexpected event: ' + params.event + })) + self.emit('error', new Error('unexpected event: ' + params.event)) + return // early return + } + + // send peers + var peers = Number(params.compact) === 1 + ? self._getPeersCompact(swarm) + : self._getPeers(swarm) + + res.end(bncode.encode({ + complete: swarm.complete, + incomplete: swarm.incomplete, + peers: peers, + interval: self._interval + })) + } +} + +Server.prototype._getPeers = function (swarm) { + var self = this + var peers = [] + for (var peerId in swarm.peers) { + var peer = swarm.peers[peerId] + peers.push({ + 'peer id': peer.peerId, + ip: peer.ip, + port: peer.port + }) + } + return peers +} + +Server.prototype._getPeersCompact = function (swarm) { + var self = this + var addrs = Object.keys(swarm.peers).map(function (peerId) { + var peer = swarm.peers[peerId] + return peer.ip + ':' + peer.port + }) + return string2compact(addrs) +} + +// +// HELPERS +// + function bytewiseEncodeURIComponent (buf) { if (!Buffer.isBuffer(buf)) { buf = new Buffer(buf, 'hex') } - return escape(buf.toString('binary')) -} \ No newline at end of file + return encodeURIComponent(buf.toString('binary')) +} + +function bytewiseDecodeURIComponent (str) { + if (Buffer.isBuffer(str)) { + str = str.toString('utf8') + } + return (new Buffer(decodeURIComponent(str), 'binary').toString('hex')) +} diff --git a/package.json b/package.json index c7ec9ea..2341c48 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,14 @@ "bncode": "^0.5.2", "compact2string": "^1.2.0", "extend.js": "0.0.1", + "hat": "0.0.3", "inherits": "^2.0.1", - "querystring": "^0.2.0" + "querystring": "^0.2.0", + "string2compact": "^1.1.0" }, "devDependencies": { "parse-torrent": "^0.6.0", + "portfinder": "^0.2.1", "tape": "2.x" }, "homepage": "http://webtorrent.io", @@ -41,4 +44,4 @@ "scripts": { "test": "tape test/*.js" } -} +} \ No newline at end of file diff --git a/test/basic.js b/test/client.js similarity index 92% rename from test/basic.js rename to test/client.js index 60fd8d9..2371d1f 100644 --- a/test/basic.js +++ b/test/client.js @@ -5,7 +5,7 @@ var test = require('tape') var torrent = fs.readFileSync(__dirname + '/torrents/bitlove-intro.torrent') var parsedTorrent = parseTorrent(torrent) - +console.log(parsedTorrent.infoHash.toString('hex')) var peerId = new Buffer('01234567890123456789') var port = 6881 @@ -33,7 +33,7 @@ test('client.start()', function (t) { }) test('client.stop()', function (t) { - t.plan(3) + t.plan(4) var client = new Client(peerId, port, parsedTorrent) @@ -52,6 +52,10 @@ test('client.stop()', function (t) { t.equal(typeof data.complete, 'number') t.equal(typeof data.incomplete, 'number') }) + + client.once('peer', function () { + t.pass('should get more peers on stop()') + }) }, 1000) }) diff --git a/test/server.js b/test/server.js new file mode 100644 index 0000000..01c6232 --- /dev/null +++ b/test/server.js @@ -0,0 +1,74 @@ +var Client = require('../').Client +var portfinder = require('portfinder') +var Server = require('../').Server +var test = require('tape') + +var peerId = new Buffer('12345678901234567890') +var infoHash = new Buffer('4cb67059ed6bd08362da625b3ae77f6f4a075705', 'hex') +var torrentLength = 50000 + +test('server', function (t) { + t.plan(12) + + var server = new Server() // { interval: 50000, compactOnly: false } + + server.on('error', function (err) { + t.fail(err.message) + }) + + server.on('start', function () { + t.pass('got start message') + }) + server.on('complete', function () {}) + server.on('update', function () {}) + server.on('stop', function () {}) + + server.on('listening', function () { + t.pass('server listening') + }) + + // server.torrents // + // server.torrents[infoHash] // + // server.torrents[infoHash].complete // + // server.torrents[infoHash].incomplete // + // server.torrents[infoHash].peers // + + portfinder.getPort(function (err, port) { + t.error(err, 'found free port') + server.listen(port) + + var announceUrl = 'http://127.0.0.1:' + port + '/announce' + + var client = new Client(peerId, 6881, { + infoHash: infoHash, + length: torrentLength, + announce: [ announceUrl ] + }) + + client.start() + + client.once('update', function (data) { + t.equal(data.announce, announceUrl) + t.equal(data.complete, 0) + t.equal(data.incomplete, 1) + + client.complete() + + client.once('update', function (data) { + t.equal(data.announce, announceUrl) + t.equal(data.complete, 1) + t.equal(data.incomplete, 0) + + client.stop() + + client.once('update', function (data) { + t.equal(data.announce, announceUrl) + t.equal(data.complete, 0) + t.equal(data.incomplete, 0) + + server.close() + }) + }) + }) + }) +})