├── .gitignore ├── .travis.yml ├── AUTHORS.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── api.md ├── bin ├── cmd.js └── update-authors.sh ├── client.js ├── examples ├── express-embed │ ├── package.json │ └── server.js └── tracker-scrape.md ├── img.png ├── index.js ├── lib ├── client │ ├── http-tracker.js │ ├── tracker.js │ ├── udp-tracker.js │ └── websocket-tracker.js ├── common-node.js ├── common-zero.js ├── common.js └── server │ ├── parse-http.js │ ├── parse-udp.js │ ├── parse-websocket.js │ ├── parse-zero.js │ └── swarm.js ├── package.json ├── server.js ├── test ├── client-large-torrent.js ├── client-magnet.js ├── client-ws-socket-pool.js ├── client.js ├── common.js ├── destroy.js ├── evict.js ├── filter.js ├── querystring.js ├── request-handler.js ├── scrape.js ├── server.js └── stats.js └── trackerStats.png /.gitignore: -------------------------------------------------------------------------------- 1 | # File is synced from zeronet-js repo 2 | 3 | # Configs, etc. 4 | *.pem 5 | config.json 6 | 7 | # NodeJS 8 | node_modules 9 | coverage 10 | .nyc_output 11 | package-lock.json 12 | 13 | # aegir 14 | dist 15 | docs 16 | 17 | # pkg 18 | zeronet-linux* 19 | zeronet-macos* 20 | zeronet-win* 21 | .pkg 22 | 23 | # Other garbage 24 | *.bak 25 | *.log 26 | tmp 27 | /data 28 | .zeronet 29 | 30 | # Python 31 | *.pyc 32 | 33 | # snap 34 | parts 35 | prime 36 | stage 37 | *.tar.bz2 38 | *.snap 39 | snap/.snapcraft 40 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - "node" 5 | before_script: 6 | - export DISPLAY=:99.0; sh -e /etc/init.d/xvfb start 7 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Authors 2 | 3 | #### Ordered by first contribution. 4 | 5 | - Feross Aboukhadijeh (feross@feross.org) 6 | - Mathias Buus (mathiasbuus@gmail.com) 7 | - thermatk (thermatk@thermatk.com) 8 | - fisch0920 (fisch0920@gmail.com) 9 | - Aliaksei Sapach (aliaksei.dreamsonic@gmail.com) 10 | - John Hiesey (john@hiesey.com) 11 | - hicom150 (necrox666@gmail.com) 12 | - Theadd (pantallazo@gmail.com) 13 | - Astro (astro@spaceboyz.net) 14 | - Anthony MOI (xn1t0x@gmail.com) 15 | - Max Ogden (max@maxogden.com) 16 | - Sidd Sridharan (sidd@sidd.com) 17 | - Nick Rafter (nicholas.rafter@gmail.com) 18 | - zckevin (zckevinzc@gmail.com) 19 | - Michael Williams (dinosaur@riseup.net) 20 | - Garret Buell (gmbuell@gmail.com) 21 | - Linus Unnebäck (linus@folkdatorn.se) 22 | - Aram Drevekenin (aram@onetwotrade.com) 23 | - Gustavo Rodrigues (qgustavor@gmail.com) 24 | - Alex (alxmorais8@msn.com) 25 | - Harsh Vakharia (harshjv@users.noreply.github.com) 26 | - Yoann Ciabaud (yoann@sonora.io) 27 | - Autarc (autarc@gmail.com) 28 | - Diego Rodríguez Baquero (diegorbaquero@gmail.com) 29 | - Kirill Fomichev (fanatid@ya.ru) 30 | - Matt Bell (mappum@gmail.com) 31 | - Diego Rodríguez (diegorbaquero@gmail.com) 32 | - Philipp Henkel (henkel@users.noreply.github.com) 33 | - jakefb (jacobafb@gmail.com) 34 | - Nick Frost (nickfrostatx@gmail.com) 35 | - ZunSThy (zunsthy@gmail.com) 36 | - vijayanand nandam (vijay@cybrilla.com) 37 | - Luigi Pinca (luigipinca@gmail.com) 38 | - Diego R. B (diegorbaquero@gmail.com) 39 | - greenkeeper[bot] (greenkeeper[bot]@users.noreply.github.com) 40 | - hrafnkell orri sigurdsson (hrafnkellos@gmail.com) 41 | - Brian Clifton (brian@clifton.me) 42 | - mkg20001 (mkg20001@gmail.com) 43 | 44 | #### Generated by bin/update-authors.sh. 45 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Contributions welcome! 4 | 5 | **Before spending lots of time on something, ask for feedback on your idea first!** 6 | 7 | Please search issues and pull requests before adding something new to avoid duplicating 8 | efforts and conversations. 9 | 10 | This project welcomes non-code contributions, too! The following types of contributions 11 | are welcome: 12 | 13 | - **Ideas**: participate in an issue thread or start your own to have your voice heard. 14 | - **Writing**: contribute your expertise in an area by helping expand the included docs. 15 | - **Copy editing**: fix typos, clarify language, and improve the quality of the docs. 16 | - **Formatting**: help keep docs easy to read with consistent formatting. 17 | 18 | ## Code Style 19 | 20 | [![JavaScript Style Guide][standard-image]][standard-url] 21 | 22 | This repository uses [`standard`][standard-url] to maintain code style and consistency, 23 | and to avoid style arguments. `npm test` runs `standard` automatically, so you don't have 24 | to! 25 | 26 | [standard-image]: https://cdn.rawgit.com/feross/standard/master/badge.svg 27 | [standard-url]: https://standardjs.com 28 | 29 | ## Project Governance 30 | 31 | Individuals making significant and valuable contributions are given commit-access to the 32 | project to contribute as they see fit. This project is more like an open wiki than a 33 | standard guarded open source project. 34 | 35 | ### Rules 36 | 37 | There are a few basic ground-rules for contributors: 38 | 39 | 1. **No `--force` pushes** or modifying the Git history in any way. 40 | 2. **Non-master branches** should be used for ongoing work. 41 | 3. **Significant modifications** like API changes should be subject to a **pull request** 42 | to solicit feedback from other contributors. 43 | 4. **Pull requests** are *encouraged* for all contributions to solicit feedback, but left to 44 | the discretion of the contributor. 45 | 46 | ### Releases 47 | 48 | Declaring formal releases remains the prerogative of the project maintainer. 49 | 50 | ### Changes to this arrangement 51 | 52 | This is an experiment and feedback is welcome! This document may also be subject to pull- 53 | requests or changes by contributors where you believe you have something valuable to add 54 | or change. 55 | 56 | ## Developer's Certificate of Origin 1.1 57 | 58 | By making a contribution to this project, I certify that: 59 | 60 | - (a) The contribution was created in whole or in part by me and I have the right to 61 | submit it under the open source license indicated in the file; or 62 | 63 | - (b) The contribution is based upon previous work that, to the best of my knowledge, is 64 | covered under an appropriate open source license and I have the right under that license 65 | to submit that work with modifications, whether created in whole or in part by me, under 66 | the same open source license (unless I am permitted to submit under a different 67 | license), as indicated in the file; or 68 | 69 | - (c) The contribution was provided directly to me by some other person who certified 70 | (a), (b) or (c) and I have not modified it. 71 | 72 | - (d) I understand and agree that this project and the contribution are public and that a 73 | record of the contribution (including all personal information I submit with it, 74 | including my sign-off) is maintained indefinitely and may be redistributed consistent 75 | with this project or the open source license(s) involved. 76 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Feross Aboukhadijeh and WebTorrent, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zeronet-tracker [![travis][travis-image]][travis-url] [![npm][npm-image]][npm-url] [![downloads][downloads-image]][downloads-url] [![javascript style guide][standard-image]][standard-url] [![Greenkeeper badge][greenkeeper-image]][greenkeeper-url] 2 | 3 | [travis-image]: https://img.shields.io/travis/ZeroNetJS/zeronet-tracker/master.svg 4 | [travis-url]: https://travis-ci.org/ZeroNetJS/zeronet-tracker 5 | [npm-image]: https://img.shields.io/npm/v/zeronet-tracker.svg 6 | [npm-url]: https://npmjs.org/package/zeronet-tracker 7 | [downloads-image]: https://img.shields.io/npm/dm/zeronet-tracker.svg 8 | [downloads-url]: https://npmjs.org/package/zeronet-tracker 9 | [standard-image]: https://img.shields.io/badge/code_style-standard-brightgreen.svg 10 | [standard-url]: https://standardjs.com 11 | [greenkeeper-image]: https://badges.greenkeeper.io/zeronetjs/zeronet-tracker.svg 12 | [greenkeeper-url]: https://greenkeeper.io/ 13 | 14 | #### Simple, robust, ZeroNet tracker (client & server) implementation 15 | 16 | ##### Basically I've slaped both the functionalities of a regular Bittorrent Tracker and a ZeroNet Tracker into one server and made it easily usable 17 | 18 | ![tracker](https://raw.githubusercontent.com/ZeroNetJS/zeronet-tracker/master/img.png) 19 | 20 | Node.js implementation of a ZeroNet Tracker (which is a [BitTorrent tracker](https://wiki.theory.org/BitTorrentSpecification#Tracker_HTTP.2FHTTPS_Protocol) that has support for the [ZeroProtocol](https://zeronet.readthedocs.io/en/latest/help_zeronet/network_protocol/) and .onion addresses) 21 | 22 | A **ZeroNet tracker** is a web service which responds to requests from ZeroNet 23 | clients. The requests include metrics from clients that help the tracker keep overall 24 | statistics about the torrent. The response includes a peer list that helps the client 25 | participate in the torrent swarm. 26 | 27 | This module is used by [ZeroNetJS](https://zeronetjs.github.io). 28 | 29 | ## features 30 | 31 | - Includes client & server implementations 32 | - Supports all mainstream tracker types: 33 | - HTTP trackers 34 | - UDP trackers ([BEP 15](http://www.bittorrent.org/beps/bep_0015.html)) 35 | - WebTorrent trackers ([BEP forthcoming](http://webtorrent.io)) 36 | - Zero trackers ([No spec, just code](https://github.com/HelloZeroNet/ZeroNet/blob/master/plugins/disabled-Bootstrapper/BootstrapperPlugin.py)) 37 | - Supports ipv4 & ipv6 & onion 38 | - Supports tracker "scrape" extension 39 | - Robust and well-tested 40 | - Comprehensive test suite (runs entirely offline, so it's reliable) 41 | - Tracker statistics available via web interface at `/stats` or JSON data at `/stats.json` 42 | 43 | Also see [zeronet-swarm](https://www.npmjs.com/package/zeronet-swarm). 44 | 45 | ### Tracker stats 46 | 47 | ![Screenshot](trackerStats.png) 48 | 49 | ## install 50 | 51 | ``` 52 | npm install zeronet-tracker 53 | ``` 54 | 55 | ## command line 56 | 57 | Install `zeronet-tracker` globally: 58 | 59 | ```sh 60 | $ npm install -g zeronet-tracker 61 | ``` 62 | 63 | Easily start a tracker server: 64 | 65 | ```sh 66 | $ zeronet-tracker 67 | http server listening on 8000 68 | udp server listening on 8000 69 | ws server listening on 8000 70 | ``` 71 | 72 | Lots of options: 73 | 74 | ```sh 75 | $ zeronet-tracker --help 76 | zeronet-tracker - Start a bittorrent tracker server 77 | 78 | Usage: 79 | zeronet-tracker [OPTIONS] 80 | 81 | If no --http, --udp, or --ws option is supplied, all tracker types will be started. 82 | 83 | Options: 84 | -p, --port [number] change the port [default: 8000] 85 | --trust-proxy trust 'x-forwarded-for' header from reverse proxy 86 | --interval client announce interval (ms) [default: 600000] 87 | --http enable http server 88 | --udp enable udp server 89 | --ws enable websocket server 90 | --zero enable zeronet server 91 | -q, --quiet only show error output 92 | -s, --silent show no output 93 | -v, --version print the current version 94 | ``` 95 | 96 | ## [api](./api.md) 97 | 98 | ## license 99 | 100 | MIT. Copyright (c) [Feross Aboukhadijeh](https://feross.org), [WebTorrent, LLC](https://webtorrent.io) and [Maciej Krüger](https://mkg20001.github.io). 101 | 102 | -------------------------------------------------------------------------------- /api.md: -------------------------------------------------------------------------------- 1 | ## usage 2 | 3 | ### client 4 | 5 | To connect to a tracker, just do this: 6 | 7 | ```js 8 | var Client = require('zeronet-tracker') 9 | 10 | var requiredOpts = { 11 | infoHash: new Buffer('012345678901234567890'), // hex string or Buffer 12 | peerId: new Buffer('01234567890123456789'), // hex string or Buffer 13 | announce: [], // list of tracker server urls 14 | // zswarm: swarm optional zeronet swarm (will be created if empty) 15 | port: 6881 // torrent client port, (in browser, optional) 16 | } 17 | 18 | var optionalOpts = { 19 | getAnnounceOpts: function () { 20 | // Provide a callback that will be called whenever announce() is called 21 | // internally (on timer), or by the user 22 | return { 23 | uploaded: 0, 24 | downloaded: 0, 25 | left: 0, 26 | customParam: 'blah' // custom parameters supported 27 | } 28 | } 29 | // RTCPeerConnection config object (only used in browser) 30 | rtcConfig: {}, 31 | // User-Agent header for http requests 32 | userAgent: '', 33 | // Custom webrtc impl, useful in node to specify [wrtc](https://npmjs.com/package/wrtc) 34 | wrtc: {}, 35 | } 36 | 37 | var client = new Client(requiredOpts) 38 | 39 | client.on('error', function (err) { 40 | // fatal client error! 41 | console.log(err.message) 42 | }) 43 | 44 | client.on('warning', function (err) { 45 | // a tracker was unavailable or sent bad data to the client. you can probably ignore it 46 | console.log(err.message) 47 | }) 48 | 49 | // start getting peers from the tracker 50 | client.start() 51 | 52 | client.on('update', function (data) { 53 | console.log('got an announce response from tracker: ' + data.announce) 54 | console.log('number of seeders in the swarm: ' + data.complete) 55 | console.log('number of leechers in the swarm: ' + data.incomplete) 56 | }) 57 | 58 | client.once('peer', function (addr) { 59 | console.log('found a peer: ' + addr) // 85.10.239.191:48623 60 | }) 61 | 62 | // announce that download has completed (and you are now a seeder) 63 | client.complete() 64 | 65 | // force a tracker announce. will trigger more 'update' events and maybe more 'peer' events 66 | client.update() 67 | 68 | // provide parameters to the tracker 69 | client.update({ 70 | uploaded: 0, 71 | downloaded: 0, 72 | left: 0, 73 | customParam: 'blah' // custom parameters supported 74 | }) 75 | 76 | // stop getting peers from the tracker, gracefully leave the swarm 77 | client.stop() 78 | 79 | // ungracefully leave the swarm (without sending final 'stop' message) 80 | client.destroy() 81 | 82 | // scrape 83 | client.scrape() 84 | 85 | client.on('scrape', function (data) { 86 | console.log('got a scrape response from tracker: ' + data.announce) 87 | console.log('number of seeders in the swarm: ' + data.complete) 88 | console.log('number of leechers in the swarm: ' + data.incomplete) 89 | console.log('number of total downloads of this torrent: ' + data.downloaded) 90 | }) 91 | ``` 92 | 93 | ### server 94 | 95 | To start a BitTorrent tracker server to track swarms of peers: 96 | 97 | ```js 98 | var Server = require('zeronet-tracker').Server 99 | 100 | var server = new Server({ 101 | udp: true, // enable udp server? [default=true] 102 | http: true, // enable http server? [default=true] 103 | ws: true, // enable websocket server? [default=true] 104 | stats: true, // enable web-based statistics? [default=true] 105 | filter: function (infoHash, params, cb) { 106 | // Blacklist/whitelist function for allowing/disallowing torrents. If this option is 107 | // omitted, all torrents are allowed. It is possible to interface with a database or 108 | // external system before deciding to allow/deny, because this function is async. 109 | 110 | // It is possible to block by peer id (whitelisting torrent clients) or by secret 111 | // key (private trackers). Full access to the original HTTP/UDP request parameters 112 | // are available in `params`. 113 | 114 | // This example only allows one torrent. 115 | 116 | var allowed = (infoHash === 'aaa67059ed6bd08362da625b3ae77f6f4a075aaa') 117 | if (allowed) { 118 | // If the callback is passed `null`, the torrent will be allowed. 119 | cb(null) 120 | } else { 121 | // If the callback is passed an `Error` object, the torrent will be disallowed 122 | // and the error's `message` property will be given as the reason. 123 | cb(new Error('disallowed torrent')) 124 | } 125 | } 126 | }) 127 | 128 | // Internal http, udp, and websocket servers exposed as public properties. 129 | server.http 130 | server.udp 131 | server.ws 132 | 133 | server.on('error', function (err) { 134 | // fatal server error! 135 | console.log(err.message) 136 | }) 137 | 138 | server.on('warning', function (err) { 139 | // client sent bad data. probably not a problem, just a buggy client. 140 | console.log(err.message) 141 | }) 142 | 143 | server.on('listening', function () { 144 | // fired when all requested servers are listening 145 | console.log('listening on http port:' + server.http.address().port) 146 | console.log('listening on udp port:' + server.udp.address().port) 147 | }) 148 | 149 | // start tracker server listening! Use 0 to listen on a random free port. 150 | server.listen(port, hostname, onlistening) 151 | 152 | // listen for individual tracker messages from peers: 153 | 154 | server.on('start', function (addr) { 155 | console.log('got start message from ' + addr) 156 | }) 157 | 158 | server.on('complete', function (addr) {}) 159 | server.on('update', function (addr) {}) 160 | server.on('stop', function (addr) {}) 161 | 162 | // get info hashes for all torrents in the tracker server 163 | Object.keys(server.torrents) 164 | 165 | // get the number of seeders for a particular torrent 166 | server.torrents[infoHash].complete 167 | 168 | // get the number of leechers for a particular torrent 169 | server.torrents[infoHash].incomplete 170 | 171 | // get the peers who are in a particular torrent swarm 172 | server.torrents[infoHash].peers 173 | ``` 174 | 175 | The http server will handle requests for the following paths: `/announce`, `/scrape`. Requests for other paths will not be handled. 176 | 177 | ## multi scrape 178 | 179 | Scraping multiple torrent info is possible with a static `Client.scrape` method: 180 | 181 | ```js 182 | var Client = require('zeronet-tracker') 183 | Client.scrape({ announce: announceUrl, infoHash: [ infoHash1, infoHash2 ]}, function (err, results) { 184 | results[infoHash1].announce 185 | results[infoHash1].infoHash 186 | results[infoHash1].complete 187 | results[infoHash1].incomplete 188 | results[infoHash1].downloaded 189 | 190 | // ... 191 | }) 192 | ```` 193 | 194 | 195 | -------------------------------------------------------------------------------- /bin/cmd.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var minimist = require('minimist') 4 | var Server = require('../').Server 5 | 6 | var argv = minimist(process.argv.slice(2), { 7 | alias: { 8 | h: 'help', 9 | p: 'port', 10 | q: 'quiet', 11 | s: 'silent', 12 | v: 'version' 13 | }, 14 | boolean: [ 15 | 'help', 16 | 'http', 17 | 'quiet', 18 | 'silent', 19 | 'trust-proxy', 20 | 'udp', 21 | 'version', 22 | 'ws', 23 | 'stats', 24 | 'zero' 25 | ], 26 | string: [ 27 | 'http-hostname', 28 | 'udp-hostname', 29 | 'udp6-hostname', 30 | 'zero-hostname' 31 | ], 32 | default: { 33 | port: 8000, 34 | stats: true 35 | } 36 | }) 37 | 38 | if (argv.version) { 39 | console.log(require('../package.json').version) 40 | process.exit(0) 41 | } 42 | 43 | if (argv.help) { 44 | console.log(function () { 45 | /* 46 | bittorrent-tracker - Start a bittorrent tracker server 47 | 48 | Usage: 49 | bittorrent-tracker [OPTIONS] 50 | 51 | If no --http, --udp, or --ws option is supplied, all tracker types will be started. 52 | 53 | Options: 54 | -p, --port [number] change the port [default: 8000] 55 | --http-hostname [string] change the http server hostname [default: '::'] 56 | --udp-hostname [string] change the udp hostname [default: '0.0.0.0'] 57 | --udp6-hostname [string] change the udp6 hostname [default: '::'] 58 | --trust-proxy trust 'x-forwarded-for' header from reverse proxy 59 | --interval client announce interval (ms) [default: 600000] 60 | --http enable http server 61 | --udp enable udp server 62 | --ws enable websocket server 63 | --zero enable zero server 64 | --stats enable web-based statistics (default: true) 65 | -q, --quiet only show error output 66 | -s, --silent show no output 67 | -v, --version print the current version 68 | 69 | */ 70 | }.toString().split(/\n/).slice(2, -2).join('\n')) 71 | process.exit(0) 72 | } 73 | 74 | if (argv.silent) argv.quiet = true 75 | 76 | var allFalsy = !argv.http && !argv.udp && !argv.ws 77 | 78 | argv.http = allFalsy || argv.http 79 | argv.udp = allFalsy || argv.udp 80 | argv.ws = allFalsy || argv.ws 81 | 82 | var server = new Server({ 83 | http: argv.http, 84 | interval: argv.interval, 85 | stats: argv.stats, 86 | trustProxy: argv['trust-proxy'], 87 | udp: argv.udp, 88 | ws: argv.ws 89 | }) 90 | 91 | server.on('error', function (err) { 92 | if (!argv.silent) console.error('ERROR: ' + err.message) 93 | }) 94 | server.on('warning', function (err) { 95 | if (!argv.quiet) console.log('WARNING: ' + err.message) 96 | }) 97 | server.on('update', function (addr) { 98 | if (!argv.quiet) console.log('update: ' + addr) 99 | }) 100 | server.on('complete', function (addr) { 101 | if (!argv.quiet) console.log('complete: ' + addr) 102 | }) 103 | server.on('start', function (addr) { 104 | if (!argv.quiet) console.log('start: ' + addr) 105 | }) 106 | server.on('stop', function (addr) { 107 | if (!argv.quiet) console.log('stop: ' + addr) 108 | }) 109 | 110 | var hostname = { 111 | http: argv['http-hostname'], 112 | udp4: argv['udp-hostname'], 113 | udp6: argv['upd6-hostname'] 114 | } 115 | 116 | server.listen(argv.port, hostname, function () { 117 | if (server.http && argv.http && !argv.quiet) { 118 | var httpAddr = server.http.address() 119 | var httpHost = httpAddr.address !== '::' ? httpAddr.address : 'localhost' 120 | var httpPort = httpAddr.port 121 | console.log('HTTP tracker: http://' + httpHost + ':' + httpPort + '/announce') 122 | } 123 | if (server.udp && !argv.quiet) { 124 | var udpAddr = server.udp.address() 125 | var udpHost = udpAddr.address 126 | var udpPort = udpAddr.port 127 | console.log('UDP tracker: udp://' + udpHost + ':' + udpPort) 128 | } 129 | if (server.udp6 && !argv.quiet) { 130 | var udp6Addr = server.udp6.address() 131 | var udp6Host = udp6Addr.address !== '::' ? udp6Addr.address : 'localhost' 132 | var udp6Port = udp6Addr.port 133 | console.log('UDP6 tracker: udp://' + udp6Host + ':' + udp6Port) 134 | } 135 | if (server.ws && !argv.quiet) { 136 | var wsAddr = server.http.address() 137 | var wsHost = wsAddr.address !== '::' ? wsAddr.address : 'localhost' 138 | var wsPort = wsAddr.port 139 | console.log('WebSocket tracker: ws://' + wsHost + ':' + wsPort) 140 | } 141 | if (server.zswarm && !argv.quiet) { 142 | console.log('ZeroNet Tracker: zero://' + server.zswarm.zero.multiaddrs[0].nodeAddress().address + ':' + server.zswarm.zero.multiaddrs[0].nodeAddress().port) 143 | } 144 | if (server.http && argv.stats && !argv.quiet) { 145 | var statsAddr = server.http.address() 146 | var statsHost = statsAddr.address !== '::' ? statsAddr.address : 'localhost' 147 | var statsPort = statsAddr.port 148 | console.log('Tracker stats: http://' + statsHost + ':' + statsPort + '/stats') 149 | } 150 | }) 151 | -------------------------------------------------------------------------------- /bin/update-authors.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Update AUTHORS.md based on git history. 3 | 4 | git log --reverse --format='%aN (%aE)' | perl -we ' 5 | BEGIN { 6 | %seen = (), @authors = (); 7 | } 8 | while (<>) { 9 | next if $seen{$_}; 10 | next if /(support\@greenkeeper.io)/; 11 | next if /(yoann\@atacma.agency)/; 12 | next if /(yciabaud\@users.noreply.github.com)/; 13 | next if /(DiegoRBaquero\@users.noreply.github.com)/; 14 | next if /(gustavcaplan\@gmail.com)/; 15 | $seen{$_} = push @authors, "- ", $_; 16 | } 17 | END { 18 | print "# Authors\n\n"; 19 | print "#### Ordered by first contribution.\n\n"; 20 | print @authors, "\n"; 21 | print "#### Generated by bin/update-authors.sh.\n"; 22 | } 23 | ' > AUTHORS.md 24 | -------------------------------------------------------------------------------- /client.js: -------------------------------------------------------------------------------- 1 | module.exports = Client 2 | 3 | var Buffer = require('safe-buffer').Buffer 4 | var debug = require('debug')('bittorrent-tracker:client') 5 | var EventEmitter = require('events').EventEmitter 6 | var extend = require('xtend') 7 | var inherits = require('inherits') 8 | var once = require('once') 9 | var parallel = require('run-parallel') 10 | var Peer = require('simple-peer') 11 | var uniq = require('uniq') 12 | var url = require('url') 13 | 14 | var common = require('./lib/common') 15 | var HTTPTracker = require('./lib/client/http-tracker') // empty object in browser 16 | var UDPTracker = require('./lib/client/udp-tracker') // empty object in browser 17 | var WebSocketTracker = require('./lib/client/websocket-tracker') 18 | 19 | inherits(Client, EventEmitter) 20 | 21 | /** 22 | * BitTorrent tracker client. 23 | * 24 | * Find torrent peers, to help a torrent client participate in a torrent swarm. 25 | * 26 | * @param {Object} opts options object 27 | * @param {string|Buffer} opts.infoHash torrent info hash 28 | * @param {string|Buffer} opts.peerId peer id 29 | * @param {string|Array.} opts.announce announce 30 | * @param {number} opts.port torrent client listening port 31 | * @param {function} opts.getAnnounceOpts callback to provide data to tracker 32 | * @param {number} opts.rtcConfig RTCPeerConnection configuration object 33 | * @param {number} opts.userAgent User-Agent header for http requests 34 | * @param {number} opts.wrtc custom webrtc impl (useful in node.js) 35 | */ 36 | function Client (opts) { 37 | var self = this 38 | if (!(self instanceof Client)) return new Client(opts) 39 | EventEmitter.call(self) 40 | if (!opts) opts = {} 41 | 42 | if (!opts.peerId) throw new Error('Option `peerId` is required') 43 | if (!opts.infoHash) throw new Error('Option `infoHash` is required') 44 | if (!opts.announce) throw new Error('Option `announce` is required') 45 | if (!process.browser && !opts.port) throw new Error('Option `port` is required') 46 | 47 | self.peerId = typeof opts.peerId === 'string' 48 | ? opts.peerId 49 | : opts.peerId.toString('hex') 50 | self._peerIdBuffer = Buffer.from(self.peerId, 'hex') 51 | self._peerIdBinary = self._peerIdBuffer.toString('binary') 52 | 53 | self.infoHash = typeof opts.infoHash === 'string' 54 | ? opts.infoHash 55 | : opts.infoHash.toString('hex') 56 | self._infoHashBuffer = Buffer.from(self.infoHash, 'hex') 57 | self._infoHashBinary = self._infoHashBuffer.toString('binary') 58 | 59 | debug('new client %s', self.infoHash) 60 | 61 | self.destroyed = false 62 | 63 | self._port = opts.port 64 | self._getAnnounceOpts = opts.getAnnounceOpts 65 | self._rtcConfig = opts.rtcConfig 66 | self._userAgent = opts.userAgent 67 | 68 | // Support lazy 'wrtc' module initialization 69 | // See: https://github.com/webtorrent/webtorrent-hybrid/issues/46 70 | self._wrtc = typeof opts.wrtc === 'function' ? opts.wrtc() : opts.wrtc 71 | 72 | var announce = typeof opts.announce === 'string' 73 | ? [ opts.announce ] 74 | : opts.announce == null ? [] : opts.announce 75 | 76 | // Remove trailing slash from trackers to catch duplicates 77 | announce = announce.map(function (announceUrl) { 78 | announceUrl = announceUrl.toString() 79 | if (announceUrl[announceUrl.length - 1] === '/') { 80 | announceUrl = announceUrl.substring(0, announceUrl.length - 1) 81 | } 82 | return announceUrl 83 | }) 84 | announce = uniq(announce) 85 | 86 | var webrtcSupport = self._wrtc !== false && (!!self._wrtc || Peer.WEBRTC_SUPPORT) 87 | 88 | self._trackers = announce 89 | .map(function (announceUrl) { 90 | var protocol = url.parse(announceUrl).protocol 91 | if ((protocol === 'http:' || protocol === 'https:') && 92 | typeof HTTPTracker === 'function') { 93 | return new HTTPTracker(self, announceUrl) 94 | } else if (protocol === 'udp:' && typeof UDPTracker === 'function') { 95 | return new UDPTracker(self, announceUrl) 96 | } else if ((protocol === 'ws:' || protocol === 'wss:') && webrtcSupport) { 97 | // Skip ws:// trackers on https:// sites because they throw SecurityError 98 | if (protocol === 'ws:' && typeof window !== 'undefined' && 99 | window.location.protocol === 'https:') { 100 | nextTickWarn(new Error('Unsupported tracker protocol: ' + announceUrl)) 101 | return null 102 | } 103 | return new WebSocketTracker(self, announceUrl) 104 | } else { 105 | nextTickWarn(new Error('Unsupported tracker protocol: ' + announceUrl)) 106 | return null 107 | } 108 | }) 109 | .filter(Boolean) 110 | 111 | function nextTickWarn (err) { 112 | process.nextTick(function () { 113 | self.emit('warning', err) 114 | }) 115 | } 116 | } 117 | 118 | /** 119 | * Simple convenience function to scrape a tracker for an info hash without needing to 120 | * create a Client, pass it a parsed torrent, etc. Support scraping a tracker for multiple 121 | * torrents at the same time. 122 | * @params {Object} opts 123 | * @param {string|Array.} opts.infoHash 124 | * @param {string} opts.announce 125 | * @param {function} cb 126 | */ 127 | Client.scrape = function (opts, cb) { 128 | cb = once(cb) 129 | 130 | if (!opts.infoHash) throw new Error('Option `infoHash` is required') 131 | if (!opts.announce) throw new Error('Option `announce` is required') 132 | 133 | var clientOpts = extend(opts, { 134 | infoHash: Array.isArray(opts.infoHash) ? opts.infoHash[0] : opts.infoHash, 135 | peerId: Buffer.from('01234567890123456789'), // dummy value 136 | port: 6881 // dummy value 137 | }) 138 | 139 | var client = new Client(clientOpts) 140 | client.once('error', cb) 141 | client.once('warning', cb) 142 | 143 | var len = Array.isArray(opts.infoHash) ? opts.infoHash.length : 1 144 | var results = {} 145 | client.on('scrape', function (data) { 146 | len -= 1 147 | results[data.infoHash] = data 148 | if (len === 0) { 149 | client.destroy() 150 | var keys = Object.keys(results) 151 | if (keys.length === 1) { 152 | cb(null, results[keys[0]]) 153 | } else { 154 | cb(null, results) 155 | } 156 | } 157 | }) 158 | 159 | opts.infoHash = Array.isArray(opts.infoHash) 160 | ? opts.infoHash.map(function (infoHash) { 161 | return Buffer.from(infoHash, 'hex') 162 | }) 163 | : Buffer.from(opts.infoHash, 'hex') 164 | client.scrape({ infoHash: opts.infoHash }) 165 | return client 166 | } 167 | 168 | /** 169 | * Send a `start` announce to the trackers. 170 | * @param {Object} opts 171 | * @param {number=} opts.uploaded 172 | * @param {number=} opts.downloaded 173 | * @param {number=} opts.left (if not set, calculated automatically) 174 | */ 175 | Client.prototype.start = function (opts) { 176 | var self = this 177 | debug('send `start`') 178 | opts = self._defaultAnnounceOpts(opts) 179 | opts.event = 'started' 180 | self._announce(opts) 181 | 182 | // start announcing on intervals 183 | self._trackers.forEach(function (tracker) { 184 | tracker.setInterval() 185 | }) 186 | } 187 | 188 | /** 189 | * Send a `stop` announce to the trackers. 190 | * @param {Object} opts 191 | * @param {number=} opts.uploaded 192 | * @param {number=} opts.downloaded 193 | * @param {number=} opts.numwant 194 | * @param {number=} opts.left (if not set, calculated automatically) 195 | */ 196 | Client.prototype.stop = function (opts) { 197 | var self = this 198 | debug('send `stop`') 199 | opts = self._defaultAnnounceOpts(opts) 200 | opts.event = 'stopped' 201 | self._announce(opts) 202 | } 203 | 204 | /** 205 | * Send a `complete` announce to the trackers. 206 | * @param {Object} opts 207 | * @param {number=} opts.uploaded 208 | * @param {number=} opts.downloaded 209 | * @param {number=} opts.numwant 210 | * @param {number=} opts.left (if not set, calculated automatically) 211 | */ 212 | Client.prototype.complete = function (opts) { 213 | var self = this 214 | debug('send `complete`') 215 | if (!opts) opts = {} 216 | opts = self._defaultAnnounceOpts(opts) 217 | opts.event = 'completed' 218 | self._announce(opts) 219 | } 220 | 221 | /** 222 | * Send a `update` announce to the trackers. 223 | * @param {Object} opts 224 | * @param {number=} opts.uploaded 225 | * @param {number=} opts.downloaded 226 | * @param {number=} opts.numwant 227 | * @param {number=} opts.left (if not set, calculated automatically) 228 | */ 229 | Client.prototype.update = function (opts) { 230 | var self = this 231 | debug('send `update`') 232 | opts = self._defaultAnnounceOpts(opts) 233 | if (opts.event) delete opts.event 234 | self._announce(opts) 235 | } 236 | 237 | Client.prototype._announce = function (opts) { 238 | var self = this 239 | self._trackers.forEach(function (tracker) { 240 | // tracker should not modify `opts` object, it's passed to all trackers 241 | tracker.announce(opts) 242 | }) 243 | } 244 | 245 | /** 246 | * Send a scrape request to the trackers. 247 | * @param {Object} opts 248 | */ 249 | Client.prototype.scrape = function (opts) { 250 | var self = this 251 | debug('send `scrape`') 252 | if (!opts) opts = {} 253 | self._trackers.forEach(function (tracker) { 254 | // tracker should not modify `opts` object, it's passed to all trackers 255 | tracker.scrape(opts) 256 | }) 257 | } 258 | 259 | Client.prototype.setInterval = function (intervalMs) { 260 | var self = this 261 | debug('setInterval %d', intervalMs) 262 | self._trackers.forEach(function (tracker) { 263 | tracker.setInterval(intervalMs) 264 | }) 265 | } 266 | 267 | Client.prototype.destroy = function (cb) { 268 | var self = this 269 | if (self.destroyed) return 270 | self.destroyed = true 271 | debug('destroy') 272 | 273 | var tasks = self._trackers.map(function (tracker) { 274 | return function (cb) { 275 | tracker.destroy(cb) 276 | } 277 | }) 278 | 279 | parallel(tasks, cb) 280 | 281 | self._trackers = [] 282 | self._getAnnounceOpts = null 283 | } 284 | 285 | Client.prototype._defaultAnnounceOpts = function (opts) { 286 | var self = this 287 | if (!opts) opts = {} 288 | 289 | if (opts.numwant == null) opts.numwant = common.DEFAULT_ANNOUNCE_PEERS 290 | 291 | if (opts.uploaded == null) opts.uploaded = 0 292 | if (opts.downloaded == null) opts.downloaded = 0 293 | 294 | if (self._getAnnounceOpts) opts = extend(opts, self._getAnnounceOpts()) 295 | return opts 296 | } 297 | -------------------------------------------------------------------------------- /examples/express-embed/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bittorrent-tracker-example-express-embed", 3 | "version": "0.0.0", 4 | "description": "Example for embedding bittorrent-tracker server in express.js", 5 | "scripts": { 6 | "server": "./server.js" 7 | }, 8 | "author": "Astro ", 9 | "license": "MIT", 10 | "dependencies": { 11 | "express": "^4.10.5" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/express-embed/server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var Server = require('../..').Server 4 | var express = require('express') 5 | var app = express() 6 | 7 | // https://wiki.theory.org/BitTorrentSpecification#peer_id 8 | var whitelist = { 9 | UT: true // uTorrent 10 | } 11 | 12 | var server = new Server({ 13 | http: false, // we do our own 14 | udp: false, // not interested 15 | ws: false, // not interested 16 | filter: function (params) { 17 | // black/whitelist for disallowing/allowing specific clients [default=allow all] 18 | // this example only allows the uTorrent client 19 | var client = params.peer_id[1] + params.peer_id[2] 20 | return whitelist[client] 21 | } 22 | }) 23 | 24 | var onHttpRequest = server.onHttpRequest.bind(server) 25 | app.get('/announce', onHttpRequest) 26 | app.get('/scrape', onHttpRequest) 27 | 28 | app.listen(8080) 29 | -------------------------------------------------------------------------------- /examples/tracker-scrape.md: -------------------------------------------------------------------------------- 1 | # Count the number of peers using the `scrape` feature of torrent trackers 2 | 3 | Here's a full example with `browserify`. 4 | 5 | ``` 6 | npm install browserify parse-torrent bittorrent-tracker 7 | ``` 8 | 9 | `scrape.js`: 10 | 11 | ```js 12 | var Tracker = require('bittorrent-tracker') 13 | var magnet = require('magnet-uri') 14 | 15 | var magnetURI = "magnet:?xt=urn:btih:6a9759bffd5c0af65319979fb7832189f4f3c35d&dn=sintel.mp4&tr=udp%3A%2F%2Fexodus.desync.com%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.internetwarriors.net%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A80&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&tr=wss%3A%2F%2Ftracker.webtorrent.io&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2Fsintel-1024-surround.mp4" 16 | 17 | var parsedTorrent = magnet(magnetURI) 18 | 19 | var opts = { 20 | infoHash: parsedTorrent.infoHash, 21 | announce: parsedTorrent.announce, 22 | peerId: new Buffer('01234567890123456789'), // hex string or Buffer 23 | port: 6881 // torrent client port 24 | } 25 | 26 | var client = new Tracker(opts) 27 | 28 | client.scrape() 29 | 30 | client.on('scrape', function (data) { 31 | console.log(data) 32 | }) 33 | ``` 34 | 35 | Bundle up `scrape.js` and it's dependencies into a single file called `bundle.js`: 36 | 37 | ```bash 38 | browserify scrape.js -o bundle.js 39 | ``` 40 | 41 | `index.html`: 42 | 43 | ```js 44 | 45 | ``` 46 | 47 | Open `index.html` in your browser. 48 | -------------------------------------------------------------------------------- /img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroNetJS/zeronet-tracker/5e39a997491836c1679e42768e4811d9384ed122/img.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var Client = require('./client') 2 | var Server = require('./server') 3 | 4 | module.exports = Client 5 | module.exports.Client = Client 6 | module.exports.Server = Server 7 | -------------------------------------------------------------------------------- /lib/client/http-tracker.js: -------------------------------------------------------------------------------- 1 | module.exports = HTTPTracker 2 | 3 | var arrayRemove = require('unordered-array-remove') 4 | var bencode = require('bencode') 5 | var compact2string = require('compact2string') 6 | var debug = require('debug')('bittorrent-tracker:http-tracker') 7 | var extend = require('xtend') 8 | var get = require('simple-get') 9 | var inherits = require('inherits') 10 | 11 | var common = require('../common') 12 | var Tracker = require('./tracker') 13 | 14 | var HTTP_SCRAPE_SUPPORT = /\/(announce)[^/]*$/ 15 | 16 | inherits(HTTPTracker, Tracker) 17 | 18 | /** 19 | * HTTP torrent tracker client (for an individual tracker) 20 | * 21 | * @param {Client} client parent bittorrent tracker client 22 | * @param {string} announceUrl announce url of tracker 23 | * @param {Object} opts options object 24 | */ 25 | function HTTPTracker (client, announceUrl, opts) { 26 | var self = this 27 | Tracker.call(self, client, announceUrl) 28 | debug('new http tracker %s', announceUrl) 29 | 30 | // Determine scrape url (if http tracker supports it) 31 | self.scrapeUrl = null 32 | 33 | var match = self.announceUrl.match(HTTP_SCRAPE_SUPPORT) 34 | if (match) { 35 | var pre = self.announceUrl.slice(0, match.index) 36 | var post = self.announceUrl.slice(match.index + 9) 37 | self.scrapeUrl = pre + '/scrape' + post 38 | } 39 | 40 | self.cleanupFns = [] 41 | self.maybeDestroyCleanup = null 42 | } 43 | 44 | HTTPTracker.prototype.DEFAULT_ANNOUNCE_INTERVAL = 30 * 60 * 1000 // 30 minutes 45 | 46 | HTTPTracker.prototype.announce = function (opts) { 47 | var self = this 48 | if (self.destroyed) return 49 | 50 | var params = extend(opts, { 51 | compact: (opts.compact == null) ? 1 : opts.compact, 52 | info_hash: self.client._infoHashBinary, 53 | peer_id: self.client._peerIdBinary, 54 | port: self.client._port 55 | }) 56 | if (self._trackerId) params.trackerid = self._trackerId 57 | 58 | self._request(self.announceUrl, params, function (err, data) { 59 | if (err) return self.client.emit('warning', err) 60 | self._onAnnounceResponse(data) 61 | }) 62 | } 63 | 64 | HTTPTracker.prototype.scrape = function (opts) { 65 | var self = this 66 | if (self.destroyed) return 67 | 68 | if (!self.scrapeUrl) { 69 | self.client.emit('error', new Error('scrape not supported ' + self.announceUrl)) 70 | return 71 | } 72 | 73 | var infoHashes = (Array.isArray(opts.infoHash) && opts.infoHash.length > 0) 74 | ? opts.infoHash.map(function (infoHash) { 75 | return infoHash.toString('binary') 76 | }) 77 | : (opts.infoHash && opts.infoHash.toString('binary')) || self.client._infoHashBinary 78 | var params = { 79 | info_hash: infoHashes 80 | } 81 | self._request(self.scrapeUrl, params, function (err, data) { 82 | if (err) return self.client.emit('warning', err) 83 | self._onScrapeResponse(data) 84 | }) 85 | } 86 | 87 | HTTPTracker.prototype.destroy = function (cb) { 88 | var self = this 89 | if (self.destroyed) return cb(null) 90 | self.destroyed = true 91 | clearInterval(self.interval) 92 | 93 | // If there are no pending requests, destroy immediately. 94 | if (self.cleanupFns.length === 0) return destroyCleanup() 95 | 96 | // Otherwise, wait a short time for pending requests to complete, then force 97 | // destroy them. 98 | var timeout = setTimeout(destroyCleanup, common.DESTROY_TIMEOUT) 99 | 100 | // But, if all pending requests complete before the timeout fires, do cleanup 101 | // right away. 102 | self.maybeDestroyCleanup = function () { 103 | if (self.cleanupFns.length === 0) destroyCleanup() 104 | } 105 | 106 | function destroyCleanup () { 107 | if (timeout) { 108 | clearTimeout(timeout) 109 | timeout = null 110 | } 111 | self.maybeDestroyCleanup = null 112 | self.cleanupFns.slice(0).forEach(function (cleanup) { 113 | cleanup() 114 | }) 115 | self.cleanupFns = [] 116 | cb(null) 117 | } 118 | } 119 | 120 | HTTPTracker.prototype._request = function (requestUrl, params, cb) { 121 | var self = this 122 | var u = requestUrl + (requestUrl.indexOf('?') === -1 ? '?' : '&') + 123 | common.querystringStringify(params) 124 | 125 | self.cleanupFns.push(cleanup) 126 | 127 | var request = get.concat({ 128 | url: u, 129 | timeout: common.REQUEST_TIMEOUT, 130 | headers: { 131 | 'user-agent': self.client._userAgent || '' 132 | } 133 | }, onResponse) 134 | 135 | function cleanup () { 136 | if (request) { 137 | arrayRemove(self.cleanupFns, self.cleanupFns.indexOf(cleanup)) 138 | request.abort() 139 | request = null 140 | } 141 | if (self.maybeDestroyCleanup) self.maybeDestroyCleanup() 142 | } 143 | 144 | function onResponse (err, res, data) { 145 | cleanup() 146 | if (self.destroyed) return 147 | 148 | if (err) return cb(err) 149 | if (res.statusCode !== 200) { 150 | return cb(new Error('Non-200 response code ' + 151 | res.statusCode + ' from ' + self.announceUrl)) 152 | } 153 | if (!data || data.length === 0) { 154 | return cb(new Error('Invalid tracker response from' + 155 | self.announceUrl)) 156 | } 157 | 158 | try { 159 | data = bencode.decode(data) 160 | } catch (err) { 161 | return cb(new Error('Error decoding tracker response: ' + err.message)) 162 | } 163 | var failure = data['failure reason'] 164 | if (failure) { 165 | debug('failure from ' + requestUrl + ' (' + failure + ')') 166 | return cb(new Error(failure)) 167 | } 168 | 169 | var warning = data['warning message'] 170 | if (warning) { 171 | debug('warning from ' + requestUrl + ' (' + warning + ')') 172 | self.client.emit('warning', new Error(warning)) 173 | } 174 | 175 | debug('response from ' + requestUrl) 176 | 177 | cb(null, data) 178 | } 179 | } 180 | 181 | HTTPTracker.prototype._onAnnounceResponse = function (data) { 182 | var self = this 183 | 184 | var interval = data.interval || data['min interval'] 185 | if (interval) self.setInterval(interval * 1000) 186 | 187 | var trackerId = data['tracker id'] 188 | if (trackerId) { 189 | // If absent, do not discard previous trackerId value 190 | self._trackerId = trackerId 191 | } 192 | 193 | var response = Object.assign({}, data, { 194 | announce: self.announceUrl, 195 | infoHash: common.binaryToHex(data.info_hash) 196 | }) 197 | self.client.emit('update', response) 198 | 199 | var addrs 200 | if (Buffer.isBuffer(data.peers)) { 201 | // tracker returned compact response 202 | try { 203 | addrs = compact2string.multi(data.peers) 204 | } catch (err) { 205 | return self.client.emit('warning', err) 206 | } 207 | addrs.forEach(function (addr) { 208 | self.client.emit('peer', addr) 209 | }) 210 | } else if (Array.isArray(data.peers)) { 211 | // tracker returned normal response 212 | data.peers.forEach(function (peer) { 213 | self.client.emit('peer', peer.ip + ':' + peer.port) 214 | }) 215 | } 216 | 217 | if (Buffer.isBuffer(data.peers6)) { 218 | // tracker returned compact response 219 | try { 220 | addrs = compact2string.multi6(data.peers6) 221 | } catch (err) { 222 | return self.client.emit('warning', err) 223 | } 224 | addrs.forEach(function (addr) { 225 | self.client.emit('peer', addr) 226 | }) 227 | } else if (Array.isArray(data.peers6)) { 228 | // tracker returned normal response 229 | data.peers6.forEach(function (peer) { 230 | var ip = /^\[/.test(peer.ip) || !/:/.test(peer.ip) 231 | ? peer.ip /* ipv6 w/ brackets or domain name */ 232 | : '[' + peer.ip + ']' /* ipv6 without brackets */ 233 | self.client.emit('peer', ip + ':' + peer.port) 234 | }) 235 | } 236 | } 237 | 238 | HTTPTracker.prototype._onScrapeResponse = function (data) { 239 | var self = this 240 | // NOTE: the unofficial spec says to use the 'files' key, 'host' has been 241 | // seen in practice 242 | data = data.files || data.host || {} 243 | 244 | var keys = Object.keys(data) 245 | if (keys.length === 0) { 246 | self.client.emit('warning', new Error('invalid scrape response')) 247 | return 248 | } 249 | 250 | keys.forEach(function (infoHash) { 251 | // TODO: optionally handle data.flags.min_request_interval 252 | // (separate from announce interval) 253 | var response = Object.assign(data[infoHash], { 254 | announce: self.announceUrl, 255 | infoHash: common.binaryToHex(infoHash) 256 | }) 257 | self.client.emit('scrape', response) 258 | }) 259 | } 260 | -------------------------------------------------------------------------------- /lib/client/tracker.js: -------------------------------------------------------------------------------- 1 | module.exports = Tracker 2 | 3 | var EventEmitter = require('events').EventEmitter 4 | var inherits = require('inherits') 5 | 6 | inherits(Tracker, EventEmitter) 7 | 8 | function Tracker (client, announceUrl) { 9 | var self = this 10 | EventEmitter.call(self) 11 | self.client = client 12 | self.announceUrl = announceUrl 13 | 14 | self.interval = null 15 | self.destroyed = false 16 | } 17 | 18 | Tracker.prototype.setInterval = function (intervalMs) { 19 | var self = this 20 | if (intervalMs == null) intervalMs = self.DEFAULT_ANNOUNCE_INTERVAL 21 | 22 | clearInterval(self.interval) 23 | 24 | if (intervalMs) { 25 | self.interval = setInterval(function () { 26 | self.announce(self.client._defaultAnnounceOpts()) 27 | }, intervalMs) 28 | if (self.interval.unref) self.interval.unref() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/client/udp-tracker.js: -------------------------------------------------------------------------------- 1 | module.exports = UDPTracker 2 | 3 | var arrayRemove = require('unordered-array-remove') 4 | var BN = require('bn.js') 5 | var Buffer = require('safe-buffer').Buffer 6 | var compact2string = require('compact2string') 7 | var debug = require('debug')('bittorrent-tracker:udp-tracker') 8 | var dgram = require('dgram') 9 | var inherits = require('inherits') 10 | var randombytes = require('randombytes') 11 | var url = require('url') 12 | 13 | var common = require('../common') 14 | var Tracker = require('./tracker') 15 | 16 | inherits(UDPTracker, Tracker) 17 | 18 | /** 19 | * UDP torrent tracker client (for an individual tracker) 20 | * 21 | * @param {Client} client parent bittorrent tracker client 22 | * @param {string} announceUrl announce url of tracker 23 | * @param {Object} opts options object 24 | */ 25 | function UDPTracker (client, announceUrl, opts) { 26 | var self = this 27 | Tracker.call(self, client, announceUrl) 28 | debug('new udp tracker %s', announceUrl) 29 | 30 | self.cleanupFns = [] 31 | self.maybeDestroyCleanup = null 32 | } 33 | 34 | UDPTracker.prototype.DEFAULT_ANNOUNCE_INTERVAL = 30 * 60 * 1000 // 30 minutes 35 | 36 | UDPTracker.prototype.announce = function (opts) { 37 | var self = this 38 | if (self.destroyed) return 39 | self._request(opts) 40 | } 41 | 42 | UDPTracker.prototype.scrape = function (opts) { 43 | var self = this 44 | if (self.destroyed) return 45 | opts._scrape = true 46 | self._request(opts) // udp scrape uses same announce url 47 | } 48 | 49 | UDPTracker.prototype.destroy = function (cb) { 50 | var self = this 51 | if (self.destroyed) return cb(null) 52 | self.destroyed = true 53 | clearInterval(self.interval) 54 | 55 | // If there are no pending requests, destroy immediately. 56 | if (self.cleanupFns.length === 0) return destroyCleanup() 57 | 58 | // Otherwise, wait a short time for pending requests to complete, then force 59 | // destroy them. 60 | var timeout = setTimeout(destroyCleanup, common.DESTROY_TIMEOUT) 61 | 62 | // But, if all pending requests complete before the timeout fires, do cleanup 63 | // right away. 64 | self.maybeDestroyCleanup = function () { 65 | if (self.cleanupFns.length === 0) destroyCleanup() 66 | } 67 | 68 | function destroyCleanup () { 69 | if (timeout) { 70 | clearTimeout(timeout) 71 | timeout = null 72 | } 73 | self.maybeDestroyCleanup = null 74 | self.cleanupFns.slice(0).forEach(function (cleanup) { 75 | cleanup() 76 | }) 77 | self.cleanupFns = [] 78 | cb(null) 79 | } 80 | } 81 | 82 | UDPTracker.prototype._request = function (opts) { 83 | var self = this 84 | if (!opts) opts = {} 85 | var parsedUrl = url.parse(self.announceUrl) 86 | var transactionId = genTransactionId() 87 | var socket = dgram.createSocket('udp4') 88 | 89 | var timeout = setTimeout(function () { 90 | // does not matter if `stopped` event arrives, so supress errors 91 | if (opts.event === 'stopped') cleanup() 92 | else onError(new Error('tracker request timed out (' + opts.event + ')')) 93 | timeout = null 94 | }, common.REQUEST_TIMEOUT) 95 | if (timeout.unref) timeout.unref() 96 | 97 | self.cleanupFns.push(cleanup) 98 | 99 | send(Buffer.concat([ 100 | common.CONNECTION_ID, 101 | common.toUInt32(common.ACTIONS.CONNECT), 102 | transactionId 103 | ])) 104 | 105 | socket.once('error', onError) 106 | socket.on('message', onSocketMessage) 107 | 108 | function cleanup () { 109 | if (timeout) { 110 | clearTimeout(timeout) 111 | timeout = null 112 | } 113 | if (socket) { 114 | arrayRemove(self.cleanupFns, self.cleanupFns.indexOf(cleanup)) 115 | socket.removeListener('error', onError) 116 | socket.removeListener('message', onSocketMessage) 117 | socket.on('error', noop) // ignore all future errors 118 | try { socket.close() } catch (err) {} 119 | socket = null 120 | } 121 | if (self.maybeDestroyCleanup) self.maybeDestroyCleanup() 122 | } 123 | 124 | function onError (err) { 125 | cleanup() 126 | if (self.destroyed) return 127 | 128 | if (err.message) err.message += ' (' + self.announceUrl + ')' 129 | // errors will often happen if a tracker is offline, so don't treat it as fatal 130 | self.client.emit('warning', err) 131 | } 132 | 133 | function onSocketMessage (msg) { 134 | if (msg.length < 8 || msg.readUInt32BE(4) !== transactionId.readUInt32BE(0)) { 135 | return onError(new Error('tracker sent invalid transaction id')) 136 | } 137 | 138 | var action = msg.readUInt32BE(0) 139 | debug('UDP response %s, action %s', self.announceUrl, action) 140 | switch (action) { 141 | case 0: // handshake 142 | // Note: no check for `self.destroyed` so that pending messages to the 143 | // tracker can still be sent/received even after destroy() is called 144 | 145 | if (msg.length < 16) return onError(new Error('invalid udp handshake')) 146 | 147 | if (opts._scrape) scrape(msg.slice(8, 16)) 148 | else announce(msg.slice(8, 16), opts) 149 | 150 | break 151 | 152 | case 1: // announce 153 | cleanup() 154 | if (self.destroyed) return 155 | 156 | if (msg.length < 20) return onError(new Error('invalid announce message')) 157 | 158 | var interval = msg.readUInt32BE(8) 159 | if (interval) self.setInterval(interval * 1000) 160 | 161 | self.client.emit('update', { 162 | announce: self.announceUrl, 163 | complete: msg.readUInt32BE(16), 164 | incomplete: msg.readUInt32BE(12) 165 | }) 166 | 167 | var addrs 168 | try { 169 | addrs = compact2string.multi(msg.slice(20)) 170 | } catch (err) { 171 | return self.client.emit('warning', err) 172 | } 173 | addrs.forEach(function (addr) { 174 | self.client.emit('peer', addr) 175 | }) 176 | 177 | break 178 | 179 | case 2: // scrape 180 | cleanup() 181 | if (self.destroyed) return 182 | 183 | if (msg.length < 20 || (msg.length - 8) % 12 !== 0) { 184 | return onError(new Error('invalid scrape message')) 185 | } 186 | var infoHashes = (Array.isArray(opts.infoHash) && opts.infoHash.length > 0) 187 | ? opts.infoHash.map(function (infoHash) { return infoHash.toString('hex') }) 188 | : [ (opts.infoHash && opts.infoHash.toString('hex')) || self.client.infoHash ] 189 | 190 | for (var i = 0, len = (msg.length - 8) / 12; i < len; i += 1) { 191 | self.client.emit('scrape', { 192 | announce: self.announceUrl, 193 | infoHash: infoHashes[i], 194 | complete: msg.readUInt32BE(8 + (i * 12)), 195 | downloaded: msg.readUInt32BE(12 + (i * 12)), 196 | incomplete: msg.readUInt32BE(16 + (i * 12)) 197 | }) 198 | } 199 | 200 | break 201 | 202 | case 3: // error 203 | cleanup() 204 | if (self.destroyed) return 205 | 206 | if (msg.length < 8) return onError(new Error('invalid error message')) 207 | self.client.emit('warning', new Error(msg.slice(8).toString())) 208 | 209 | break 210 | 211 | default: 212 | onError(new Error('tracker sent invalid action')) 213 | break 214 | } 215 | } 216 | 217 | function send (message) { 218 | if (!parsedUrl.port) { 219 | parsedUrl.port = 80 220 | } 221 | socket.send(message, 0, message.length, parsedUrl.port, parsedUrl.hostname) 222 | } 223 | 224 | function announce (connectionId, opts) { 225 | transactionId = genTransactionId() 226 | 227 | send(Buffer.concat([ 228 | connectionId, 229 | common.toUInt32(common.ACTIONS.ANNOUNCE), 230 | transactionId, 231 | self.client._infoHashBuffer, 232 | self.client._peerIdBuffer, 233 | toUInt64(opts.downloaded), 234 | opts.left != null ? toUInt64(opts.left) : Buffer.from('FFFFFFFFFFFFFFFF', 'hex'), 235 | toUInt64(opts.uploaded), 236 | common.toUInt32(common.EVENTS[opts.event] || 0), 237 | common.toUInt32(0), // ip address (optional) 238 | common.toUInt32(0), // key (optional) 239 | common.toUInt32(opts.numwant), 240 | toUInt16(self.client._port) 241 | ])) 242 | } 243 | 244 | function scrape (connectionId) { 245 | transactionId = genTransactionId() 246 | 247 | var infoHash = (Array.isArray(opts.infoHash) && opts.infoHash.length > 0) 248 | ? Buffer.concat(opts.infoHash) 249 | : (opts.infoHash || self.client._infoHashBuffer) 250 | 251 | send(Buffer.concat([ 252 | connectionId, 253 | common.toUInt32(common.ACTIONS.SCRAPE), 254 | transactionId, 255 | infoHash 256 | ])) 257 | } 258 | } 259 | 260 | function genTransactionId () { 261 | return randombytes(4) 262 | } 263 | 264 | function toUInt16 (n) { 265 | var buf = Buffer.allocUnsafe(2) 266 | buf.writeUInt16BE(n, 0) 267 | return buf 268 | } 269 | 270 | var MAX_UINT = 4294967295 271 | 272 | function toUInt64 (n) { 273 | if (n > MAX_UINT || typeof n === 'string') { 274 | var bytes = new BN(n).toArray() 275 | while (bytes.length < 8) { 276 | bytes.unshift(0) 277 | } 278 | return Buffer.from(bytes) 279 | } 280 | return Buffer.concat([common.toUInt32(0), common.toUInt32(n)]) 281 | } 282 | 283 | function noop () {} 284 | -------------------------------------------------------------------------------- /lib/client/websocket-tracker.js: -------------------------------------------------------------------------------- 1 | module.exports = WebSocketTracker 2 | 3 | var debug = require('debug')('bittorrent-tracker:websocket-tracker') 4 | var extend = require('xtend') 5 | var inherits = require('inherits') 6 | var Peer = require('simple-peer') 7 | var randombytes = require('randombytes') 8 | var Socket = require('simple-websocket') 9 | 10 | var common = require('../common') 11 | var Tracker = require('./tracker') 12 | 13 | // Use a socket pool, so tracker clients share WebSocket objects for the same server. 14 | // In practice, WebSockets are pretty slow to establish, so this gives a nice performance 15 | // boost, and saves browser resources. 16 | var socketPool = {} 17 | 18 | var RECONNECT_MINIMUM = 15 * 1000 19 | var RECONNECT_MAXIMUM = 30 * 60 * 1000 20 | var RECONNECT_VARIANCE = 30 * 1000 21 | var OFFER_TIMEOUT = 50 * 1000 22 | 23 | inherits(WebSocketTracker, Tracker) 24 | 25 | function WebSocketTracker (client, announceUrl, opts) { 26 | var self = this 27 | Tracker.call(self, client, announceUrl) 28 | debug('new websocket tracker %s', announceUrl) 29 | 30 | self.peers = {} // peers (offer id -> peer) 31 | self.socket = null 32 | 33 | self.reconnecting = false 34 | self.retries = 0 35 | self.reconnectTimer = null 36 | 37 | // Simple boolean flag to track whether the socket has received data from 38 | // the websocket server since the last time socket.send() was called. 39 | self.expectingResponse = false 40 | 41 | self._openSocket() 42 | } 43 | 44 | WebSocketTracker.prototype.DEFAULT_ANNOUNCE_INTERVAL = 30 * 1000 // 30 seconds 45 | 46 | WebSocketTracker.prototype.announce = function (opts) { 47 | var self = this 48 | if (self.destroyed || self.reconnecting) return 49 | if (!self.socket.connected) { 50 | self.socket.once('connect', function () { 51 | self.announce(opts) 52 | }) 53 | return 54 | } 55 | 56 | var params = extend(opts, { 57 | action: 'announce', 58 | info_hash: self.client._infoHashBinary, 59 | peer_id: self.client._peerIdBinary 60 | }) 61 | if (self._trackerId) params.trackerid = self._trackerId 62 | 63 | if (opts.event === 'stopped' || opts.event === 'completed') { 64 | // Don't include offers with 'stopped' or 'completed' event 65 | self._send(params) 66 | } else { 67 | // Limit the number of offers that are generated, since it can be slow 68 | var numwant = Math.min(opts.numwant, 10) 69 | 70 | self._generateOffers(numwant, function (offers) { 71 | params.numwant = numwant 72 | params.offers = offers 73 | self._send(params) 74 | }) 75 | } 76 | } 77 | 78 | WebSocketTracker.prototype.scrape = function (opts) { 79 | var self = this 80 | if (self.destroyed || self.reconnecting) return 81 | if (!self.socket.connected) { 82 | self.socket.once('connect', function () { 83 | self.scrape(opts) 84 | }) 85 | return 86 | } 87 | 88 | var infoHashes = (Array.isArray(opts.infoHash) && opts.infoHash.length > 0) 89 | ? opts.infoHash.map(function (infoHash) { 90 | return infoHash.toString('binary') 91 | }) 92 | : (opts.infoHash && opts.infoHash.toString('binary')) || self.client._infoHashBinary 93 | var params = { 94 | action: 'scrape', 95 | info_hash: infoHashes 96 | } 97 | 98 | self._send(params) 99 | } 100 | 101 | WebSocketTracker.prototype.destroy = function (cb) { 102 | var self = this 103 | if (!cb) cb = noop 104 | if (self.destroyed) return cb(null) 105 | 106 | self.destroyed = true 107 | 108 | clearInterval(self.interval) 109 | clearTimeout(self.reconnectTimer) 110 | 111 | // Destroy peers 112 | for (var peerId in self.peers) { 113 | var peer = self.peers[peerId] 114 | clearTimeout(peer.trackerTimeout) 115 | peer.destroy() 116 | } 117 | self.peers = null 118 | 119 | if (self.socket) { 120 | self.socket.removeListener('connect', self._onSocketConnectBound) 121 | self.socket.removeListener('data', self._onSocketDataBound) 122 | self.socket.removeListener('close', self._onSocketCloseBound) 123 | self.socket.removeListener('error', self._onSocketErrorBound) 124 | self.socket = null 125 | } 126 | 127 | self._onSocketConnectBound = null 128 | self._onSocketErrorBound = null 129 | self._onSocketDataBound = null 130 | self._onSocketCloseBound = null 131 | 132 | if (socketPool[self.announceUrl]) { 133 | socketPool[self.announceUrl].consumers -= 1 134 | } 135 | 136 | // Other instances are using the socket, so there's nothing left to do here 137 | if (socketPool[self.announceUrl].consumers > 0) return cb() 138 | 139 | var socket = socketPool[self.announceUrl] 140 | delete socketPool[self.announceUrl] 141 | socket.on('error', noop) // ignore all future errors 142 | socket.once('close', cb) 143 | 144 | // If there is no data response expected, destroy immediately. 145 | if (!self.expectingResponse) return destroyCleanup() 146 | 147 | // Otherwise, wait a short time for potential responses to come in from the 148 | // server, then force close the socket. 149 | var timeout = setTimeout(destroyCleanup, common.DESTROY_TIMEOUT) 150 | 151 | // But, if a response comes from the server before the timeout fires, do cleanup 152 | // right away. 153 | socket.once('data', destroyCleanup) 154 | 155 | function destroyCleanup () { 156 | if (timeout) { 157 | clearTimeout(timeout) 158 | timeout = null 159 | } 160 | socket.removeListener('data', destroyCleanup) 161 | socket.destroy() 162 | socket = null 163 | } 164 | } 165 | 166 | WebSocketTracker.prototype._openSocket = function () { 167 | var self = this 168 | self.destroyed = false 169 | 170 | if (!self.peers) self.peers = {} 171 | 172 | self._onSocketConnectBound = function () { 173 | self._onSocketConnect() 174 | } 175 | self._onSocketErrorBound = function (err) { 176 | self._onSocketError(err) 177 | } 178 | self._onSocketDataBound = function (data) { 179 | self._onSocketData(data) 180 | } 181 | self._onSocketCloseBound = function () { 182 | self._onSocketClose() 183 | } 184 | 185 | self.socket = socketPool[self.announceUrl] 186 | if (self.socket) { 187 | socketPool[self.announceUrl].consumers += 1 188 | } else { 189 | self.socket = socketPool[self.announceUrl] = new Socket(self.announceUrl) 190 | self.socket.consumers = 1 191 | self.socket.once('connect', self._onSocketConnectBound) 192 | } 193 | 194 | self.socket.on('data', self._onSocketDataBound) 195 | self.socket.once('close', self._onSocketCloseBound) 196 | self.socket.once('error', self._onSocketErrorBound) 197 | } 198 | 199 | WebSocketTracker.prototype._onSocketConnect = function () { 200 | var self = this 201 | if (self.destroyed) return 202 | 203 | if (self.reconnecting) { 204 | self.reconnecting = false 205 | self.retries = 0 206 | self.announce(self.client._defaultAnnounceOpts()) 207 | } 208 | } 209 | 210 | WebSocketTracker.prototype._onSocketData = function (data) { 211 | var self = this 212 | if (self.destroyed) return 213 | 214 | self.expectingResponse = false 215 | 216 | try { 217 | data = JSON.parse(data) 218 | } catch (err) { 219 | self.client.emit('warning', new Error('Invalid tracker response')) 220 | return 221 | } 222 | 223 | if (data.action === 'announce') { 224 | self._onAnnounceResponse(data) 225 | } else if (data.action === 'scrape') { 226 | self._onScrapeResponse(data) 227 | } else { 228 | self._onSocketError(new Error('invalid action in WS response: ' + data.action)) 229 | } 230 | } 231 | 232 | WebSocketTracker.prototype._onAnnounceResponse = function (data) { 233 | var self = this 234 | 235 | if (data.info_hash !== self.client._infoHashBinary) { 236 | debug( 237 | 'ignoring websocket data from %s for %s (looking for %s: reused socket)', 238 | self.announceUrl, common.binaryToHex(data.info_hash), self.client.infoHash 239 | ) 240 | return 241 | } 242 | 243 | if (data.peer_id && data.peer_id === self.client._peerIdBinary) { 244 | // ignore offers/answers from this client 245 | return 246 | } 247 | 248 | debug( 249 | 'received %s from %s for %s', 250 | JSON.stringify(data), self.announceUrl, self.client.infoHash 251 | ) 252 | 253 | var failure = data['failure reason'] 254 | if (failure) return self.client.emit('warning', new Error(failure)) 255 | 256 | var warning = data['warning message'] 257 | if (warning) self.client.emit('warning', new Error(warning)) 258 | 259 | var interval = data.interval || data['min interval'] 260 | if (interval) self.setInterval(interval * 1000) 261 | 262 | var trackerId = data['tracker id'] 263 | if (trackerId) { 264 | // If absent, do not discard previous trackerId value 265 | self._trackerId = trackerId 266 | } 267 | 268 | if (data.complete != null) { 269 | var response = Object.assign({}, data, { 270 | announce: self.announceUrl, 271 | infoHash: common.binaryToHex(data.info_hash) 272 | }) 273 | self.client.emit('update', response) 274 | } 275 | 276 | var peer 277 | if (data.offer && data.peer_id) { 278 | debug('creating peer (from remote offer)') 279 | peer = self._createPeer() 280 | peer.id = common.binaryToHex(data.peer_id) 281 | peer.once('signal', function (answer) { 282 | var params = { 283 | action: 'announce', 284 | info_hash: self.client._infoHashBinary, 285 | peer_id: self.client._peerIdBinary, 286 | to_peer_id: data.peer_id, 287 | answer: answer, 288 | offer_id: data.offer_id 289 | } 290 | if (self._trackerId) params.trackerid = self._trackerId 291 | self._send(params) 292 | }) 293 | peer.signal(data.offer) 294 | self.client.emit('peer', peer) 295 | } 296 | 297 | if (data.answer && data.peer_id) { 298 | var offerId = common.binaryToHex(data.offer_id) 299 | peer = self.peers[offerId] 300 | if (peer) { 301 | peer.id = common.binaryToHex(data.peer_id) 302 | peer.signal(data.answer) 303 | self.client.emit('peer', peer) 304 | 305 | clearTimeout(peer.trackerTimeout) 306 | peer.trackerTimeout = null 307 | delete self.peers[offerId] 308 | } else { 309 | debug('got unexpected answer: ' + JSON.stringify(data.answer)) 310 | } 311 | } 312 | } 313 | 314 | WebSocketTracker.prototype._onScrapeResponse = function (data) { 315 | var self = this 316 | data = data.files || {} 317 | 318 | var keys = Object.keys(data) 319 | if (keys.length === 0) { 320 | self.client.emit('warning', new Error('invalid scrape response')) 321 | return 322 | } 323 | 324 | keys.forEach(function (infoHash) { 325 | // TODO: optionally handle data.flags.min_request_interval 326 | // (separate from announce interval) 327 | var response = Object.assign(data[infoHash], { 328 | announce: self.announceUrl, 329 | infoHash: common.binaryToHex(infoHash) 330 | }) 331 | self.client.emit('scrape', response) 332 | }) 333 | } 334 | 335 | WebSocketTracker.prototype._onSocketClose = function () { 336 | var self = this 337 | if (self.destroyed) return 338 | self.destroy() 339 | self._startReconnectTimer() 340 | } 341 | 342 | WebSocketTracker.prototype._onSocketError = function (err) { 343 | var self = this 344 | if (self.destroyed) return 345 | self.destroy() 346 | // errors will often happen if a tracker is offline, so don't treat it as fatal 347 | self.client.emit('warning', err) 348 | self._startReconnectTimer() 349 | } 350 | 351 | WebSocketTracker.prototype._startReconnectTimer = function () { 352 | var self = this 353 | var ms = Math.floor(Math.random() * RECONNECT_VARIANCE) + Math.min(Math.pow(2, self.retries) * RECONNECT_MINIMUM, RECONNECT_MAXIMUM) 354 | 355 | self.reconnecting = true 356 | clearTimeout(self.reconnectTimer) 357 | self.reconnectTimer = setTimeout(function () { 358 | self.retries++ 359 | self._openSocket() 360 | }, ms) 361 | if (self.reconnectTimer.unref) self.reconnectTimer.unref() 362 | 363 | debug('reconnecting socket in %s ms', ms) 364 | } 365 | 366 | WebSocketTracker.prototype._send = function (params) { 367 | var self = this 368 | if (self.destroyed) return 369 | self.expectingResponse = true 370 | var message = JSON.stringify(params) 371 | debug('send %s', message) 372 | self.socket.send(message) 373 | } 374 | 375 | WebSocketTracker.prototype._generateOffers = function (numwant, cb) { 376 | var self = this 377 | var offers = [] 378 | debug('generating %s offers', numwant) 379 | 380 | for (var i = 0; i < numwant; ++i) { 381 | generateOffer() 382 | } 383 | checkDone() 384 | 385 | function generateOffer () { 386 | var offerId = randombytes(20).toString('hex') 387 | debug('creating peer (from _generateOffers)') 388 | var peer = self.peers[offerId] = self._createPeer({ initiator: true }) 389 | peer.once('signal', function (offer) { 390 | offers.push({ 391 | offer: offer, 392 | offer_id: common.hexToBinary(offerId) 393 | }) 394 | checkDone() 395 | }) 396 | peer.trackerTimeout = setTimeout(function () { 397 | debug('tracker timeout: destroying peer') 398 | peer.trackerTimeout = null 399 | delete self.peers[offerId] 400 | peer.destroy() 401 | }, OFFER_TIMEOUT) 402 | if (peer.trackerTimeout.unref) peer.trackerTimeout.unref() 403 | } 404 | 405 | function checkDone () { 406 | if (offers.length === numwant) { 407 | debug('generated %s offers', numwant) 408 | cb(offers) 409 | } 410 | } 411 | } 412 | 413 | WebSocketTracker.prototype._createPeer = function (opts) { 414 | var self = this 415 | 416 | opts = Object.assign({ 417 | trickle: false, 418 | config: self.client._rtcConfig, 419 | wrtc: self.client._wrtc 420 | }, opts) 421 | 422 | var peer = new Peer(opts) 423 | 424 | peer.once('error', onError) 425 | peer.once('connect', onConnect) 426 | 427 | return peer 428 | 429 | // Handle peer 'error' events that are fired *before* the peer is emitted in 430 | // a 'peer' event. 431 | function onError (err) { 432 | self.client.emit('warning', new Error('Connection error: ' + err.message)) 433 | peer.destroy() 434 | } 435 | 436 | // Once the peer is emitted in a 'peer' event, then it's the consumer's 437 | // responsibility to listen for errors, so the listeners are removed here. 438 | function onConnect () { 439 | peer.removeListener('error', onError) 440 | peer.removeListener('connect', onConnect) 441 | } 442 | } 443 | 444 | function noop () {} 445 | -------------------------------------------------------------------------------- /lib/common-node.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Functions/constants needed by both the client and server (but only in node). 3 | * These are separate from common.js so they can be skipped when bundling for the browser. 4 | */ 5 | 6 | var Buffer = require('safe-buffer').Buffer 7 | var querystring = require('querystring') 8 | 9 | exports.IPV4_RE = /^[\d.]+$/ 10 | exports.IPV6_RE = /^[\da-fA-F:]+$/ 11 | exports.REMOVE_IPV4_MAPPED_IPV6_RE = /^::ffff:/ 12 | 13 | exports.CONNECTION_ID = Buffer.concat([ toUInt32(0x417), toUInt32(0x27101980) ]) 14 | exports.ACTIONS = { CONNECT: 0, ANNOUNCE: 1, SCRAPE: 2, ERROR: 3 } 15 | exports.EVENTS = { update: 0, completed: 1, started: 2, stopped: 3 } 16 | exports.EVENT_IDS = { 17 | 0: 'update', 18 | 1: 'completed', 19 | 2: 'started', 20 | 3: 'stopped' 21 | } 22 | exports.EVENT_NAMES = { 23 | update: 'update', 24 | completed: 'complete', 25 | started: 'start', 26 | stopped: 'stop' 27 | } 28 | 29 | /** 30 | * Client request timeout. How long to wait before considering a request to a 31 | * tracker server to have timed out. 32 | */ 33 | exports.REQUEST_TIMEOUT = 15000 34 | 35 | /** 36 | * Client destroy timeout. How long to wait before forcibly cleaning up all 37 | * pending requests, open sockets, etc. 38 | */ 39 | exports.DESTROY_TIMEOUT = 1000 40 | 41 | function toUInt32 (n) { 42 | var buf = Buffer.allocUnsafe(4) 43 | buf.writeUInt32BE(n, 0) 44 | return buf 45 | } 46 | exports.toUInt32 = toUInt32 47 | 48 | /** 49 | * `querystring.parse` using `unescape` instead of decodeURIComponent, since bittorrent 50 | * clients send non-UTF8 querystrings 51 | * @param {string} q 52 | * @return {Object} 53 | */ 54 | exports.querystringParse = function (q) { 55 | return querystring.parse(q, null, null, { decodeURIComponent: unescape }) 56 | } 57 | 58 | /** 59 | * `querystring.stringify` using `escape` instead of encodeURIComponent, since bittorrent 60 | * clients send non-UTF8 querystrings 61 | * @param {Object} obj 62 | * @return {string} 63 | */ 64 | exports.querystringStringify = function (obj) { 65 | var ret = querystring.stringify(obj, null, null, { encodeURIComponent: escape }) 66 | ret = ret.replace(/[@*/+]/g, function (char) { 67 | // `escape` doesn't encode the characters @*/+ so we do it manually 68 | return '%' + char.charCodeAt(0).toString(16).toUpperCase() 69 | }) 70 | return ret 71 | } 72 | -------------------------------------------------------------------------------- /lib/common-zero.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const arrayOrNull = v => Array.isArray(v) || v == null 4 | const delta = (a, b) => a - b === 0 ? 0 : a - b < 0 ? b - a : a - b 5 | 6 | module.exports.def = { 7 | in: { 8 | protobuf: { 9 | 1: 'repeated string hashes', 10 | 2: 'repeated bytes onion_signs', 11 | 3: 'repeated string onions', 12 | 4: 'int64 onion_sign_this', 13 | 5: 'repeated string add', 14 | 6: 'int32 need_num', 15 | 7: 'repeated string need_types', 16 | 8: 'int32 port', 17 | 9: 'bool delete' 18 | }, 19 | strict: { 20 | onions: arrayOrNull, 21 | onion_signs: arrayOrNull, 22 | onion_sign_this: [v => v == null, v => delta(Date.getTime(), v * 1000) < 3 * 60 * 1000 && typeof v === 'number'], 23 | add: arrayOrNull, 24 | need_num: 'number', 25 | need_types: arrayOrNull, 26 | port: 'number', 27 | delete: ['boolean', v => v == null] 28 | } 29 | } 30 | } 31 | 32 | module.exports.fakeZeroNet = () => { 33 | return { 34 | rev: '0', 35 | version: 'v0', 36 | swarm: {} 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/common.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Functions/constants needed by both the client and server. 3 | */ 4 | 5 | var Buffer = require('safe-buffer').Buffer 6 | var extend = require('xtend/mutable') 7 | 8 | exports.DEFAULT_ANNOUNCE_PEERS = 50 9 | exports.MAX_ANNOUNCE_PEERS = 82 10 | 11 | exports.binaryToHex = function (str) { 12 | if (typeof str !== 'string') { 13 | str = String(str) 14 | } 15 | return Buffer.from(str, 'binary').toString('hex') 16 | } 17 | 18 | exports.hexToBinary = function (str) { 19 | if (typeof str !== 'string') { 20 | str = String(str) 21 | } 22 | return Buffer.from(str, 'hex').toString('binary') 23 | } 24 | 25 | var config = require('./common-node') 26 | var zero = require('./common-zero') 27 | extend(exports, config) 28 | extend(exports, zero) 29 | -------------------------------------------------------------------------------- /lib/server/parse-http.js: -------------------------------------------------------------------------------- 1 | module.exports = parseHttpRequest 2 | 3 | var common = require('../common') 4 | 5 | function parseHttpRequest (req, opts) { 6 | if (!opts) opts = {} 7 | var s = req.url.split('?') 8 | var params = common.querystringParse(s[1]) 9 | params.type = 'http' 10 | 11 | if (opts.action === 'announce' || s[0] === '/announce') { 12 | params.action = common.ACTIONS.ANNOUNCE 13 | 14 | if (typeof params.info_hash !== 'string' || params.info_hash.length !== 20) { 15 | throw new Error('invalid info_hash') 16 | } 17 | params.info_hash = common.binaryToHex(params.info_hash) 18 | 19 | if (typeof params.peer_id !== 'string' || params.peer_id.length !== 20) { 20 | throw new Error('invalid peer_id') 21 | } 22 | params.peer_id = common.binaryToHex(params.peer_id) 23 | 24 | params.port = Number(params.port) 25 | if (!params.port) throw new Error('invalid port') 26 | 27 | params.left = Number(params.left) 28 | if (Number.isNaN(params.left)) params.left = Infinity 29 | 30 | params.compact = Number(params.compact) || 0 31 | params.numwant = Math.min( 32 | Number(params.numwant) || common.DEFAULT_ANNOUNCE_PEERS, 33 | common.MAX_ANNOUNCE_PEERS 34 | ) 35 | 36 | params.ip = opts.trustProxy 37 | ? req.headers['x-forwarded-for'] || req.connection.remoteAddress 38 | : req.connection.remoteAddress.replace(common.REMOVE_IPV4_MAPPED_IPV6_RE, '') // force ipv4 39 | params.addr = (common.IPV6_RE.test(params.ip) ? '[' + params.ip + ']' : params.ip) + ':' + params.port 40 | 41 | params.headers = req.headers 42 | } else if (opts.action === 'scrape' || s[0] === '/scrape') { 43 | params.action = common.ACTIONS.SCRAPE 44 | 45 | if (typeof params.info_hash === 'string') params.info_hash = [ params.info_hash ] 46 | if (Array.isArray(params.info_hash)) { 47 | params.info_hash = params.info_hash.map(function (binaryInfoHash) { 48 | if (typeof binaryInfoHash !== 'string' || binaryInfoHash.length !== 20) { 49 | throw new Error('invalid info_hash') 50 | } 51 | return common.binaryToHex(binaryInfoHash) 52 | }) 53 | } 54 | } else { 55 | throw new Error('invalid action in HTTP request: ' + req.url) 56 | } 57 | 58 | return params 59 | } 60 | -------------------------------------------------------------------------------- /lib/server/parse-udp.js: -------------------------------------------------------------------------------- 1 | module.exports = parseUdpRequest 2 | 3 | var ipLib = require('ip') 4 | var common = require('../common') 5 | 6 | function parseUdpRequest (msg, rinfo) { 7 | if (msg.length < 16) throw new Error('received packet is too short') 8 | 9 | var params = { 10 | connectionId: msg.slice(0, 8), // 64-bit 11 | action: msg.readUInt32BE(8), 12 | transactionId: msg.readUInt32BE(12), 13 | type: 'udp' 14 | } 15 | 16 | if (!common.CONNECTION_ID.equals(params.connectionId)) { 17 | throw new Error('received packet with invalid connection id') 18 | } 19 | 20 | if (params.action === common.ACTIONS.CONNECT) { 21 | // No further params 22 | } else if (params.action === common.ACTIONS.ANNOUNCE) { 23 | params.info_hash = msg.slice(16, 36).toString('hex') // 20 bytes 24 | params.peer_id = msg.slice(36, 56).toString('hex') // 20 bytes 25 | params.downloaded = fromUInt64(msg.slice(56, 64)) // TODO: track this? 26 | params.left = fromUInt64(msg.slice(64, 72)) 27 | params.uploaded = fromUInt64(msg.slice(72, 80)) // TODO: track this? 28 | 29 | params.event = common.EVENT_IDS[msg.readUInt32BE(80)] 30 | if (!params.event) throw new Error('invalid event') // early return 31 | 32 | var ip = msg.readUInt32BE(84) // optional 33 | params.ip = ip 34 | ? ipLib.toString(ip) 35 | : rinfo.address 36 | 37 | params.key = msg.readUInt32BE(88) // Optional: unique random key from client 38 | 39 | // never send more than MAX_ANNOUNCE_PEERS or else the UDP packet will get bigger than 40 | // 512 bytes which is not safe 41 | params.numwant = Math.min( 42 | msg.readUInt32BE(92) || common.DEFAULT_ANNOUNCE_PEERS, // optional 43 | common.MAX_ANNOUNCE_PEERS 44 | ) 45 | 46 | params.port = msg.readUInt16BE(96) || rinfo.port // optional 47 | params.addr = params.ip + ':' + params.port // TODO: ipv6 brackets 48 | params.compact = 1 // udp is always compact 49 | } else if (params.action === common.ACTIONS.SCRAPE) { // scrape message 50 | if ((msg.length - 16) % 20 !== 0) throw new Error('invalid scrape message') 51 | params.info_hash = [] 52 | for (var i = 0, len = (msg.length - 16) / 20; i < len; i += 1) { 53 | var infoHash = msg.slice(16 + (i * 20), 36 + (i * 20)).toString('hex') // 20 bytes 54 | params.info_hash.push(infoHash) 55 | } 56 | } else { 57 | throw new Error('Invalid action in UDP packet: ' + params.action) 58 | } 59 | 60 | return params 61 | } 62 | 63 | var TWO_PWR_32 = (1 << 16) * 2 64 | 65 | /** 66 | * Return the closest floating-point representation to the buffer value. Precision will be 67 | * lost for big numbers. 68 | */ 69 | function fromUInt64 (buf) { 70 | var high = buf.readUInt32BE(0) | 0 // force 71 | var low = buf.readUInt32BE(4) | 0 72 | var lowUnsigned = (low >= 0) ? low : TWO_PWR_32 + low 73 | 74 | return (high * TWO_PWR_32) + lowUnsigned 75 | } 76 | -------------------------------------------------------------------------------- /lib/server/parse-websocket.js: -------------------------------------------------------------------------------- 1 | module.exports = parseWebSocketRequest 2 | 3 | var common = require('../common') 4 | 5 | function parseWebSocketRequest (socket, opts, params) { 6 | if (!opts) opts = {} 7 | params = JSON.parse(params) // may throw 8 | 9 | params.type = 'ws' 10 | params.socket = socket 11 | if (params.action === 'announce') { 12 | params.action = common.ACTIONS.ANNOUNCE 13 | 14 | if (typeof params.info_hash !== 'string' || params.info_hash.length !== 20) { 15 | throw new Error('invalid info_hash') 16 | } 17 | params.info_hash = common.binaryToHex(params.info_hash) 18 | 19 | if (typeof params.peer_id !== 'string' || params.peer_id.length !== 20) { 20 | throw new Error('invalid peer_id') 21 | } 22 | params.peer_id = common.binaryToHex(params.peer_id) 23 | 24 | if (params.answer) { 25 | if (typeof params.to_peer_id !== 'string' || params.to_peer_id.length !== 20) { 26 | throw new Error('invalid `to_peer_id` (required with `answer`)') 27 | } 28 | params.to_peer_id = common.binaryToHex(params.to_peer_id) 29 | } 30 | 31 | params.left = Number(params.left) 32 | if (Number.isNaN(params.left)) params.left = Infinity 33 | 34 | params.numwant = Math.min( 35 | Number(params.offers && params.offers.length) || 0, // no default - explicit only 36 | common.MAX_ANNOUNCE_PEERS 37 | ) 38 | params.compact = -1 // return full peer objects (used for websocket responses) 39 | } else if (params.action === 'scrape') { 40 | params.action = common.ACTIONS.SCRAPE 41 | 42 | if (typeof params.info_hash === 'string') params.info_hash = [ params.info_hash ] 43 | if (Array.isArray(params.info_hash)) { 44 | params.info_hash = params.info_hash.map(function (binaryInfoHash) { 45 | if (typeof binaryInfoHash !== 'string' || binaryInfoHash.length !== 20) { 46 | throw new Error('invalid info_hash') 47 | } 48 | return common.binaryToHex(binaryInfoHash) 49 | }) 50 | } 51 | } else { 52 | throw new Error('invalid action in WS request: ' + params.action) 53 | } 54 | 55 | // On first parse, save important data from `socket.upgradeReq` and delete it 56 | // to reduce memory usage. 57 | if (socket.upgradeReq) { 58 | socket.ip = opts.trustProxy 59 | ? socket.upgradeReq.headers['x-forwarded-for'] || socket.upgradeReq.connection.remoteAddress 60 | : socket.upgradeReq.connection.remoteAddress.replace(common.REMOVE_IPV4_MAPPED_IPV6_RE, '') // force ipv4 61 | socket.port = socket.upgradeReq.connection.remotePort 62 | if (socket.port) { 63 | socket.addr = (common.IPV6_RE.test(socket.ip) ? '[' + socket.ip + ']' : socket.ip) + ':' + socket.port 64 | } 65 | 66 | socket.headers = socket.upgradeReq.headers 67 | 68 | // Delete `socket.upgradeReq` when it is no longer needed to reduce memory usage 69 | socket.upgradeReq = null 70 | } 71 | 72 | params.ip = socket.ip 73 | params.port = socket.port 74 | params.addr = socket.addr 75 | params.headers = socket.headers 76 | 77 | return params 78 | } 79 | -------------------------------------------------------------------------------- /lib/server/parse-zero.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const common = require('../common') 4 | const {parallel} = require('async') 5 | const mafmt = require('mafmt') 6 | const equal = require('assert').deepEqual 7 | const noop = () => {} 8 | const randomIterate = require('random-iterate') 9 | const debug = require('debug') 10 | const log = debug('zeronet-tracker:zero') 11 | const helper = require('zeronet-fileserver/src/pack') 12 | 13 | module.exports = function parseZero (swarm, server) { 14 | function getSwarm (hash, cb) { 15 | const self = server 16 | self.getSwarm(hash, function (err, swarm) { 17 | if (err) return cb(err) 18 | if (swarm) { 19 | cb(null, swarm) 20 | } else { 21 | createSwarm() 22 | } 23 | }) 24 | 25 | function createSwarm () { 26 | if (self._filter) { 27 | self._filter(hash, {}, function (err) { 28 | // Precense of err means that this info_hash is disallowed 29 | if (err) { 30 | cb(err) 31 | } else { 32 | self.createSwarm(hash, function (err, swarm) { 33 | if (err) return cb(err) 34 | cb(null, swarm) 35 | }) 36 | } 37 | }) 38 | } else { 39 | self.createSwarm(hash, function (err, swarm) { 40 | if (err) return cb(err) 41 | cb(null, swarm) 42 | }) 43 | } 44 | } 45 | } 46 | 47 | function getPeers (swarm, zPeer, needTypes, numwant) { 48 | var peers = [] 49 | var ite = randomIterate(swarm.peers.keys) 50 | var peerId 51 | while ((peerId = ite()) && peers.length < numwant) { 52 | // Don't mark the peer as most recently used on announce 53 | var peer = swarm.peers.peek(peerId) 54 | if (!peer) continue 55 | if (peer.peerId === zPeer.peerId) continue // don't send peer to itself 56 | // console.log(peer) 57 | if (common.IPV4_RE.test(peer.ip)) { 58 | if (needTypes.indexOf('ip4') === -1) return 59 | } 60 | if (peer.ip.endsWith('.onion')) { 61 | if (needTypes.indexOf('onion') === -1) return 62 | } 63 | peers.push(peer) 64 | } 65 | return peers 66 | } 67 | 68 | function announce (swarm, peer, needTypes, needNum, cb) { 69 | let ignored = 0 70 | const scrape = needTypes === false 71 | peer.addrs.forEach(({addr, type, validated}) => { 72 | if (!peer.port) return 73 | const btPeer = swarm.peers.get(addr) 74 | if ((!validated && !btPeer) || (!validated && btPeer.peerId !== peer.peerId)) return ignored++ 75 | 76 | if (scrape) { 77 | if (!btPeer) return 78 | swarm._onAnnounceStopped({}, btPeer, peer.peerId) 79 | } 80 | 81 | if (!btPeer) { 82 | swarm._onAnnounceStarted({ 83 | type: 'zero', 84 | left: 0, 85 | peerId: peer.peerId, // as hex 86 | ip: addr, 87 | port: peer.port 88 | }, btPeer, peer.peerId) 89 | } else { 90 | swarm._onAnnounceUpdate({}, btPeer, peer.peerId) 91 | } 92 | }) 93 | 94 | if (scrape) return cb() 95 | return cb(null, getPeers(swarm, peer, needTypes, needNum), !!ignored) 96 | } 97 | 98 | swarm.protocol.handle('announce', common.def, (data, cb) => { 99 | const client = data._client 100 | // console.log(data) 101 | 102 | let peer = { 103 | peerId: client.handshake.remote.peer_id, 104 | port: data.port || 0, 105 | addrs: [] 106 | } 107 | 108 | const hashes = data.hashes.map(h => Buffer.from(h).toString('hex')) 109 | const needNum = Math.min(hashes.length >= 500 ? 5 : 30, data.need_num || 0) 110 | 111 | const validators = { 112 | ip4: cb => { 113 | client.getObservedAddrs((err, addrs) => { 114 | if (err) return cb(err) 115 | try { 116 | let addr = addrs.filter(a => mafmt.TCP.matches(a)).filter(a => !equal(a.protoNames(), [ 'ip4', 'tcp' ])).filter(a => Boolean(a.stringTuples()[1][1]) /* port !== 0 */)[0] // use first valid ip4 117 | if (!addr) return cb() 118 | addr = addr.stringTuples()[0][1] 119 | if (addr === '127.0.0.1') return cb(new Error('localhost not allowed!')) 120 | peer.addrs.push({type: 'ip4', addr, validated: true}) 121 | cb() 122 | } catch (e) { 123 | cb(e) 124 | } 125 | }) 126 | }, 127 | onion: cb => { 128 | cb() // TODO: add 129 | } 130 | } 131 | 132 | let onionNeedSign = false 133 | let hash2peer = {} 134 | 135 | parallel((data.port && data.add ? data.add : []).map(type => validators[type] || noop), err => { 136 | if (err) log(err) 137 | if (err) return cb(err) 138 | parallel(hashes.map(h => cb => { 139 | getSwarm(h, (err, swarm) => { 140 | if (err) return cb(err) 141 | announce(swarm, peer, data.need_types || [], needNum, (err, peers, needSign) => { 142 | if (err) return cb(err) 143 | onionNeedSign = onionNeedSign || needSign 144 | hash2peer[h] = peers 145 | cb() 146 | }) 147 | }) 148 | }), err => { 149 | if (err) log(err) 150 | if (err) return cb(err) 151 | let res = { 152 | peers: hashes.map(h => { 153 | let r = { 154 | ip4: [], 155 | onion: [] 156 | } 157 | hash2peer[h].forEach(peer => { 158 | if (peer.ip.endsWith('.onion')) { 159 | r.onion.push(Buffer.from(helper.onion.pack(peer.ip, peer.port), 'binary')) 160 | } else { 161 | r.ip4.push(Buffer.from(helper.ip4.pack(peer.ip, peer.port), 'binary')) 162 | } 163 | }) 164 | return r 165 | }) 166 | } 167 | // console.log(res) 168 | cb(null, res) 169 | }) 170 | }) 171 | }) 172 | } 173 | -------------------------------------------------------------------------------- /lib/server/swarm.js: -------------------------------------------------------------------------------- 1 | module.exports = Swarm 2 | 3 | var arrayRemove = require('unordered-array-remove') 4 | var debug = require('debug')('bittorrent-tracker:swarm') 5 | var LRU = require('lru') 6 | var randomIterate = require('random-iterate') 7 | 8 | // Regard this as the default implementation of an interface that you 9 | // need to support when overriding Server.createSwarm() and Server.getSwarm() 10 | function Swarm (infoHash, server) { 11 | var self = this 12 | self.infoHash = infoHash 13 | self.complete = 0 14 | self.incomplete = 0 15 | 16 | self.peers = new LRU({ 17 | max: server.peersCacheLength || 1000, 18 | maxAge: server.peersCacheTtl || 20 * 60 * 1000 // 20 minutes 19 | }) 20 | 21 | // When a peer is evicted from the LRU store, send a synthetic 'stopped' event 22 | // so the stats get updated correctly. 23 | self.peers.on('evict', function (data) { 24 | var peer = data.value 25 | var params = { 26 | type: peer.type, 27 | event: 'stopped', 28 | numwant: 0, 29 | peer_id: peer.peerId 30 | } 31 | self._onAnnounceStopped(params, peer, peer.peerId) 32 | peer.socket = null 33 | }) 34 | } 35 | 36 | Swarm.prototype.announce = function (params, cb) { 37 | var self = this 38 | var id = params.type === 'ws' ? params.peer_id : params.addr 39 | // Mark the source peer as recently used in cache 40 | var peer = self.peers.get(id) 41 | 42 | if (params.event === 'started') { 43 | self._onAnnounceStarted(params, peer, id) 44 | } else if (params.event === 'stopped') { 45 | self._onAnnounceStopped(params, peer, id) 46 | } else if (params.event === 'completed') { 47 | self._onAnnounceCompleted(params, peer, id) 48 | } else if (params.event === 'update') { 49 | self._onAnnounceUpdate(params, peer, id) 50 | } else { 51 | cb(new Error('invalid event')) 52 | return 53 | } 54 | cb(null, { 55 | complete: self.complete, 56 | incomplete: self.incomplete, 57 | peers: self._getPeers(params.numwant, params.peer_id, !!params.socket) 58 | }) 59 | } 60 | 61 | Swarm.prototype.scrape = function (params, cb) { 62 | cb(null, { 63 | complete: this.complete, 64 | incomplete: this.incomplete 65 | }) 66 | } 67 | 68 | Swarm.prototype._onAnnounceStarted = function (params, peer, id) { 69 | if (peer) { 70 | debug('unexpected `started` event from peer that is already in swarm') 71 | return this._onAnnounceUpdate(params, peer, id) // treat as an update 72 | } 73 | 74 | if (params.left === 0) this.complete += 1 75 | else this.incomplete += 1 76 | peer = this.peers.set(id, { 77 | type: params.type, 78 | complete: params.left === 0, 79 | peerId: params.peer_id, // as hex 80 | ip: params.ip, 81 | port: params.port, 82 | socket: params.socket // only websocket 83 | }) 84 | } 85 | 86 | Swarm.prototype._onAnnounceStopped = function (params, peer, id) { 87 | if (!peer) { 88 | debug('unexpected `stopped` event from peer that is not in swarm') 89 | return // do nothing 90 | } 91 | 92 | if (peer.complete) this.complete -= 1 93 | else this.incomplete -= 1 94 | 95 | // If it's a websocket, remove this swarm's infohash from the list of active 96 | // swarms that this peer is participating in. 97 | if (peer.socket && !peer.socket.destroyed) { 98 | var index = peer.socket.infoHashes.indexOf(this.infoHash) 99 | arrayRemove(peer.socket.infoHashes, index) 100 | } 101 | 102 | this.peers.remove(id) 103 | } 104 | 105 | Swarm.prototype._onAnnounceCompleted = function (params, peer, id) { 106 | if (!peer) { 107 | debug('unexpected `completed` event from peer that is not in swarm') 108 | return this._onAnnounceStarted(params, peer, id) // treat as a start 109 | } 110 | if (peer.complete) { 111 | debug('unexpected `completed` event from peer that is already completed') 112 | return this._onAnnounceUpdate(params, peer, id) // treat as an update 113 | } 114 | 115 | this.complete += 1 116 | this.incomplete -= 1 117 | peer.complete = true 118 | this.peers.set(id, peer) 119 | } 120 | 121 | Swarm.prototype._onAnnounceUpdate = function (params, peer, id) { 122 | if (!peer) { 123 | debug('unexpected `update` event from peer that is not in swarm') 124 | return this._onAnnounceStarted(params, peer, id) // treat as a start 125 | } 126 | 127 | if (!peer.complete && params.left === 0) { 128 | this.complete += 1 129 | this.incomplete -= 1 130 | peer.complete = true 131 | } 132 | this.peers.set(id, peer) 133 | } 134 | 135 | Swarm.prototype._getPeers = function (numwant, ownPeerId, isWebRTC) { 136 | var peers = [] 137 | var ite = randomIterate(this.peers.keys) 138 | var peerId 139 | while ((peerId = ite()) && peers.length < numwant) { 140 | // Don't mark the peer as most recently used on announce 141 | var peer = this.peers.peek(peerId) 142 | if (!peer) continue 143 | if (isWebRTC && peer.peerId === ownPeerId) continue // don't send peer to itself 144 | if ((isWebRTC && peer.type !== 'ws') || (!isWebRTC && peer.type === 'ws')) continue // send proper peer type 145 | peers.push(peer) 146 | } 147 | return peers 148 | } 149 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zeronet-tracker", 3 | "description": "Simple, robust, ZeroNet tracker (client & server) implementation", 4 | "version": "0.1.1-zn", 5 | "author": { 6 | "name": "Maciej Krüger", 7 | "email": "mkg20001@gmail.com", 8 | "url": "https://mkg20001.github.io" 9 | }, 10 | "bin": { 11 | "zeronet-tracker": "./bin/cmd.js" 12 | }, 13 | "browser": { 14 | "./lib/common-node.js": false, 15 | "./lib/client/http-tracker.js": false, 16 | "./lib/client/udp-tracker.js": false, 17 | "./server.js": false 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/ZeroNetJS/zeronet-tracker/issues" 21 | }, 22 | "dependencies": { 23 | "bencode": "^1.0.0", 24 | "bittorrent-peerid": "^1.2.0", 25 | "bn.js": "^4.11.8", 26 | "clone": "^2.1.1", 27 | "compact2string": "^1.4.0", 28 | "debug": "^3.1.0", 29 | "inherits": "^2.0.3", 30 | "ip": "^1.1.5", 31 | "libp2p-tcp": "^0.11.2", 32 | "lru": "^3.1.0", 33 | "minimist": "^1.2.0", 34 | "once": "^1.4.0", 35 | "random-iterate": "^1.0.1", 36 | "randombytes": "^2.0.6", 37 | "run-parallel": "^1.1.6", 38 | "run-series": "^1.1.4", 39 | "safe-buffer": "^5.1.1", 40 | "simple-get": "^2.7.0", 41 | "simple-peer": "^8.2.0", 42 | "simple-websocket": "^5.1.1", 43 | "string2compact": "^1.2.2", 44 | "uniq": "^1.0.1", 45 | "unordered-array-remove": "^1.0.2", 46 | "ws": "^4.0.0", 47 | "xtend": "^4.0.1", 48 | "zeronet-fileserver": "0.0.7", 49 | "zeronet-swarm": "0.2.0" 50 | }, 51 | "devDependencies": { 52 | "electron-webrtc": "^0.3.0", 53 | "magnet-uri": "^5.1.7", 54 | "standard": "*", 55 | "tape": "^4.8.0", 56 | "webtorrent-fixtures": "^1.5.1" 57 | }, 58 | "keywords": [ 59 | "bittorrent", 60 | "p2p", 61 | "peer", 62 | "peer-to-peer", 63 | "stream", 64 | "tracker", 65 | "zeronet" 66 | ], 67 | "license": "MIT", 68 | "main": "index.js", 69 | "repository": { 70 | "type": "git", 71 | "url": "git://github.com/ZeroNetJS/zeronet-tracker.git" 72 | }, 73 | "scripts": { 74 | "update-authors": "./bin/update-authors.sh", 75 | "test": "standard && tape test/*.js" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = Server 4 | 5 | var Buffer = require('safe-buffer').Buffer 6 | var bencode = require('bencode') 7 | var debug = require('debug')('bittorrent-tracker:server') 8 | var dgram = require('dgram') 9 | var EventEmitter = require('events').EventEmitter 10 | var http = require('http') 11 | var inherits = require('inherits') 12 | var peerid = require('bittorrent-peerid') 13 | var series = require('run-series') 14 | var string2compact = require('string2compact') 15 | var WebSocketServer = require('ws').Server 16 | 17 | var common = require('./lib/common') 18 | var Swarm = require('./lib/server/swarm') 19 | var parseHttpRequest = require('./lib/server/parse-http') 20 | var parseUdpRequest = require('./lib/server/parse-udp') 21 | var parseWebSocketRequest = require('./lib/server/parse-websocket') 22 | var parseZero = require('./lib/server/parse-zero') 23 | const ZSwarm = require('zeronet-swarm') 24 | const TCP = require('libp2p-tcp') 25 | 26 | inherits(Server, EventEmitter) 27 | 28 | /** 29 | * BitTorrent tracker server. 30 | * 31 | * HTTP service which responds to GET requests from torrent clients. Requests include 32 | * metrics from clients that help the tracker keep overall statistics about the torrent. 33 | * Responses include a peer list that helps the client participate in the torrent. 34 | * 35 | * @param {Object} opts options object 36 | * @param {Number} opts.interval tell clients to announce on this interval (ms) 37 | * @param {Number} opts.trustProxy trust 'x-forwarded-for' header from reverse proxy 38 | * @param {boolean} opts.http start an http server? (default: true) 39 | * @param {boolean} opts.udp start a udp server? (default: true) 40 | * @param {boolean} opts.ws start a websocket server? (default: true) 41 | * @param {boolean} opts.stats enable web-based statistics? (default: true) 42 | * @param {function} opts.filter black/whitelist fn for disallowing/allowing torrents 43 | */ 44 | function Server (opts) { 45 | var self = this 46 | if (!(self instanceof Server)) return new Server(opts) 47 | EventEmitter.call(self) 48 | if (!opts) opts = {} 49 | 50 | debug('new server %s', JSON.stringify(opts)) 51 | 52 | self.intervalMs = opts.interval 53 | ? opts.interval 54 | : 10 * 60 * 1000 // 10 min 55 | 56 | self._trustProxy = !!opts.trustProxy 57 | if (typeof opts.filter === 'function') self._filter = opts.filter 58 | 59 | self.peersCacheLength = opts.peersCacheLength 60 | self.peersCacheTtl = opts.peersCacheTtl 61 | 62 | self._listenCalled = false 63 | self.listening = false 64 | self.destroyed = false 65 | self.torrents = {} 66 | 67 | self.http = null 68 | self.udp4 = null 69 | self.udp6 = null 70 | self.ws = null 71 | self.zswarm = null 72 | 73 | // create a zeronet swarm and apply the tracker commands 74 | if (opts.zero !== false) { 75 | self.zswarm = opts.zswarm 76 | if (self.zswarm) { 77 | self.zswarm_custom = true 78 | } else { 79 | self.zswarm = true 80 | } 81 | } 82 | 83 | // start an http tracker unless the user explictly says no 84 | if (opts.http !== false) { 85 | self.http = http.createServer() 86 | self.http.on('error', function (err) { self._onError(err) }) 87 | self.http.on('listening', onListening) 88 | 89 | // Add default http request handler on next tick to give user the chance to add 90 | // their own handler first. Handle requests untouched by user's handler. 91 | process.nextTick(function () { 92 | self.http.on('request', function (req, res) { 93 | if (res.headersSent) return 94 | self.onHttpRequest(req, res) 95 | }) 96 | }) 97 | } 98 | 99 | // start a udp tracker unless the user explicitly says no 100 | if (opts.udp !== false) { 101 | var isNode10 = /^v0.10./.test(process.version) 102 | 103 | self.udp4 = self.udp = dgram.createSocket( 104 | isNode10 ? 'udp4' : { type: 'udp4', reuseAddr: true } 105 | ) 106 | self.udp4.on('message', function (msg, rinfo) { self.onUdpRequest(msg, rinfo) }) 107 | self.udp4.on('error', function (err) { self._onError(err) }) 108 | self.udp4.on('listening', onListening) 109 | 110 | self.udp6 = dgram.createSocket( 111 | isNode10 ? 'udp6' : { type: 'udp6', reuseAddr: true } 112 | ) 113 | self.udp6.on('message', function (msg, rinfo) { self.onUdpRequest(msg, rinfo) }) 114 | self.udp6.on('error', function (err) { self._onError(err) }) 115 | self.udp6.on('listening', onListening) 116 | } 117 | 118 | // start a websocket tracker (for WebTorrent) unless the user explicitly says no 119 | if (opts.ws !== false) { 120 | if (!self.http) { 121 | self.http = http.createServer() 122 | self.http.on('error', function (err) { self._onError(err) }) 123 | self.http.on('listening', onListening) 124 | 125 | // Add default http request handler on next tick to give user the chance to add 126 | // their own handler first. Handle requests untouched by user's handler. 127 | process.nextTick(function () { 128 | self.http.on('request', function (req, res) { 129 | if (res.headersSent) return 130 | // For websocket trackers, we only need to handle the UPGRADE http method. 131 | // Return 404 for all other request types. 132 | res.statusCode = 404 133 | res.end('404 Not Found') 134 | }) 135 | }) 136 | } 137 | self.ws = new WebSocketServer({ 138 | server: self.http, 139 | perMessageDeflate: false, 140 | clientTracking: false 141 | }) 142 | self.ws.address = function () { 143 | return self.http.address() 144 | } 145 | self.ws.on('error', function (err) { self._onError(err) }) 146 | self.ws.on('connection', function (socket, req) { 147 | // Note: socket.upgradeReq was removed in ws@3.0.0, so re-add it. 148 | // https://github.com/websockets/ws/pull/1099 149 | socket.upgradeReq = req 150 | self.onWebSocketConnection(socket) 151 | }) 152 | } 153 | 154 | if (opts.stats !== false) { 155 | if (!self.http) { 156 | self.http = http.createServer() 157 | self.http.on('error', function (err) { self._onError(err) }) 158 | self.http.on('listening', onListening) 159 | } 160 | 161 | // Http handler for '/stats' route 162 | self.http.on('request', function (req, res) { 163 | if (res.headersSent) return 164 | 165 | var infoHashes = Object.keys(self.torrents) 166 | var activeTorrents = 0 167 | var allPeers = {} 168 | 169 | function countPeers (filterFunction) { 170 | var count = 0 171 | var key 172 | 173 | for (key in allPeers) { 174 | if (allPeers.hasOwnProperty(key) && filterFunction(allPeers[key])) { 175 | count++ 176 | } 177 | } 178 | 179 | return count 180 | } 181 | 182 | function groupByClient () { 183 | var clients = {} 184 | for (var key in allPeers) { 185 | if (allPeers.hasOwnProperty(key)) { 186 | var peer = allPeers[key] 187 | 188 | if (!clients[peer.client.client]) { 189 | clients[peer.client.client] = {} 190 | } 191 | var client = clients[peer.client.client] 192 | // If the client is not known show 8 chars from peerId as version 193 | var version = peer.client.version || new Buffer(peer.peerId, 'hex').toString().substring(0, 8) 194 | if (!client[version]) { 195 | client[version] = 0 196 | } 197 | client[version]++ 198 | } 199 | } 200 | return clients 201 | } 202 | 203 | function printClients (clients) { 204 | var html = '
    \n' 205 | for (var name in clients) { 206 | if (clients.hasOwnProperty(name)) { 207 | var client = clients[name] 208 | for (var version in client) { 209 | if (client.hasOwnProperty(version)) { 210 | html += '
  • ' + name + ' ' + version + ' : ' + client[version] + '
  • \n' 211 | } 212 | } 213 | } 214 | } 215 | html += '
' 216 | return html 217 | } 218 | 219 | if (req.method === 'GET' && (req.url === '/stats' || req.url === '/stats.json')) { 220 | infoHashes.forEach(function (infoHash) { 221 | var peers = self.torrents[infoHash].peers 222 | var keys = peers.keys 223 | if (keys.length > 0) activeTorrents++ 224 | 225 | keys.forEach(function (peerId) { 226 | // Don't mark the peer as most recently used for stats 227 | var peer = peers.peek(peerId) 228 | if (peer == null) return // peers.peek() can evict the peer 229 | 230 | if (!allPeers.hasOwnProperty(peerId)) { 231 | allPeers[peerId] = { 232 | ipv4: false, 233 | ipv6: false, 234 | seeder: false, 235 | leecher: false 236 | } 237 | } 238 | 239 | if (peer.ip.indexOf(':') >= 0) { 240 | allPeers[peerId].ipv6 = true 241 | } else { 242 | allPeers[peerId].ipv4 = true 243 | } 244 | 245 | if (peer.complete) { 246 | allPeers[peerId].seeder = true 247 | } else { 248 | allPeers[peerId].leecher = true 249 | } 250 | 251 | allPeers[peerId].peerId = peer.peerId 252 | allPeers[peerId].client = peerid(peer.peerId) 253 | }) 254 | }) 255 | 256 | var isSeederOnly = function (peer) { return peer.seeder && peer.leecher === false } 257 | var isLeecherOnly = function (peer) { return peer.leecher && peer.seeder === false } 258 | var isSeederAndLeecher = function (peer) { return peer.seeder && peer.leecher } 259 | var isIPv4 = function (peer) { return peer.ipv4 } 260 | var isIPv6 = function (peer) { return peer.ipv6 } 261 | 262 | var stats = { 263 | torrents: infoHashes.length, 264 | activeTorrents: activeTorrents, 265 | peersAll: Object.keys(allPeers).length, 266 | peersSeederOnly: countPeers(isSeederOnly), 267 | peersLeecherOnly: countPeers(isLeecherOnly), 268 | peersSeederAndLeecher: countPeers(isSeederAndLeecher), 269 | peersIPv4: countPeers(isIPv4), 270 | peersIPv6: countPeers(isIPv6), 271 | clients: groupByClient() 272 | } 273 | 274 | if (req.url === '/stats.json' || req.headers['accept'] === 'application/json') { 275 | res.write(JSON.stringify(stats)) 276 | res.end() 277 | } else if (req.url === '/stats') { 278 | res.end('

' + stats.torrents + ' torrents (' + stats.activeTorrents + ' active)

\n' + 279 | '

Connected Peers: ' + stats.peersAll + '

\n' + 280 | '

Peers Seeding Only: ' + stats.peersSeederOnly + '

\n' + 281 | '

Peers Leeching Only: ' + stats.peersLeecherOnly + '

\n' + 282 | '

Peers Seeding & Leeching: ' + stats.peersSeederAndLeecher + '

\n' + 283 | '

IPv4 Peers: ' + stats.peersIPv4 + '

\n' + 284 | '

IPv6 Peers: ' + stats.peersIPv6 + '

\n' + 285 | '

Clients:

\n' + 286 | printClients(stats.clients) 287 | ) 288 | } 289 | } 290 | }) 291 | } 292 | 293 | var num = !!self.http + !!self.udp4 + !!self.udp6 294 | function onListening () { 295 | num -= 1 296 | if (num === 0) { 297 | self.listening = true 298 | debug('listening') 299 | self.emit('listening') 300 | } 301 | } 302 | } 303 | 304 | Server.Swarm = Swarm 305 | 306 | Server.prototype._onError = function (err) { 307 | var self = this 308 | self.emit('error', err) 309 | } 310 | 311 | Server.prototype.listen = function (/* port, hostname, onlistening */) { 312 | var self = this 313 | 314 | if (self._listenCalled || self.listening) throw new Error('server already listening') 315 | self._listenCalled = true 316 | 317 | var lastArg = arguments[arguments.length - 1] 318 | if (typeof lastArg === 'function') self.once('listening', lastArg) 319 | 320 | var port = toNumber(arguments[0]) || arguments[0] || 0 321 | var hostname = typeof arguments[1] !== 'function' ? arguments[1] : undefined 322 | 323 | debug('listen (port: %o hostname: %o)', port, hostname) 324 | 325 | function isObject (obj) { 326 | return typeof obj === 'object' && obj !== null 327 | } 328 | 329 | var httpPort = isObject(port) ? (port.http || 0) : port 330 | var udpPort = isObject(port) ? (port.udp || 0) : port 331 | var zeroPort = isObject(port) ? (port.zero || 0) : port 332 | if (zeroPort === httpPort) zeroPort++ 333 | 334 | // binding to :: only receives IPv4 connections if the bindv6only sysctl is set 0, 335 | // which is the default on many operating systems 336 | var httpHostname = isObject(hostname) ? hostname.http : hostname 337 | var udp4Hostname = isObject(hostname) ? hostname.udp : hostname 338 | var udp6Hostname = isObject(hostname) ? hostname.udp6 : hostname 339 | 340 | if (self.http) self.http.listen(httpPort, httpHostname) 341 | if (self.udp4) self.udp4.bind(udpPort, udp4Hostname) 342 | if (self.udp6) self.udp6.bind(udpPort, udp6Hostname) 343 | if (self.zswarm && !self.zswarm_custom) { 344 | let zn = common.fakeZeroNet() 345 | self.zswarm = new ZSwarm({ 346 | zero: { 347 | listen: [ 348 | '/ip4/0.0.0.0/tcp/' + zeroPort 349 | ], 350 | transports: [ 351 | new TCP() 352 | ] 353 | }, 354 | libp2p: false 355 | }, zn) 356 | self.zswarm.start(() => {}) 357 | zn.swarm = self.zswarm 358 | } 359 | if (self.zswarm) parseZero(self.zswarm, self) 360 | } 361 | 362 | Server.prototype.close = function (cb) { 363 | var self = this 364 | if (!cb) cb = noop 365 | debug('close') 366 | 367 | self.listening = false 368 | self.destroyed = true 369 | 370 | if (self.udp4) { 371 | try { 372 | self.udp4.close() 373 | } catch (err) {} 374 | } 375 | 376 | if (self.udp6) { 377 | try { 378 | self.udp6.close() 379 | } catch (err) {} 380 | } 381 | 382 | if (self.ws) { 383 | try { 384 | self.ws.close() 385 | } catch (err) {} 386 | } 387 | 388 | if (self.http) self.http.close(cb) 389 | else cb(null) 390 | } 391 | 392 | Server.prototype.createSwarm = function (infoHash, cb) { 393 | var self = this 394 | if (Buffer.isBuffer(infoHash)) infoHash = infoHash.toString('hex') 395 | 396 | process.nextTick(function () { 397 | var swarm = self.torrents[infoHash] = new Server.Swarm(infoHash, self) 398 | cb(null, swarm) 399 | }) 400 | } 401 | 402 | Server.prototype.getSwarm = function (infoHash, cb) { 403 | var self = this 404 | if (Buffer.isBuffer(infoHash)) infoHash = infoHash.toString('hex') 405 | 406 | process.nextTick(function () { 407 | cb(null, self.torrents[infoHash]) 408 | }) 409 | } 410 | 411 | Server.prototype.onHttpRequest = function (req, res, opts) { 412 | var self = this 413 | if (!opts) opts = {} 414 | opts.trustProxy = opts.trustProxy || self._trustProxy 415 | 416 | var params 417 | try { 418 | params = parseHttpRequest(req, opts) 419 | params.httpReq = req 420 | params.httpRes = res 421 | } catch (err) { 422 | res.end(bencode.encode({ 423 | 'failure reason': err.message 424 | })) 425 | 426 | // even though it's an error for the client, it's just a warning for the server. 427 | // don't crash the server because a client sent bad data :) 428 | self.emit('warning', err) 429 | return 430 | } 431 | 432 | self._onRequest(params, function (err, response) { 433 | if (err) { 434 | self.emit('warning', err) 435 | response = { 436 | 'failure reason': err.message 437 | } 438 | } 439 | if (self.destroyed) return res.end() 440 | 441 | delete response.action // only needed for UDP encoding 442 | res.end(bencode.encode(response)) 443 | 444 | if (params.action === common.ACTIONS.ANNOUNCE) { 445 | self.emit(common.EVENT_NAMES[params.event], params.addr, params) 446 | } 447 | }) 448 | } 449 | 450 | Server.prototype.onUdpRequest = function (msg, rinfo) { 451 | var self = this 452 | 453 | var params 454 | try { 455 | params = parseUdpRequest(msg, rinfo) 456 | } catch (err) { 457 | self.emit('warning', err) 458 | // Do not reply for parsing errors 459 | return 460 | } 461 | 462 | self._onRequest(params, function (err, response) { 463 | if (err) { 464 | self.emit('warning', err) 465 | response = { 466 | action: common.ACTIONS.ERROR, 467 | 'failure reason': err.message 468 | } 469 | } 470 | if (self.destroyed) return 471 | 472 | response.transactionId = params.transactionId 473 | response.connectionId = params.connectionId 474 | 475 | var buf = makeUdpPacket(response) 476 | 477 | try { 478 | var udp = (rinfo.family === 'IPv4') ? self.udp4 : self.udp6 479 | udp.send(buf, 0, buf.length, rinfo.port, rinfo.address) 480 | } catch (err) { 481 | self.emit('warning', err) 482 | } 483 | 484 | if (params.action === common.ACTIONS.ANNOUNCE) { 485 | self.emit(common.EVENT_NAMES[params.event], params.addr, params) 486 | } 487 | }) 488 | } 489 | 490 | Server.prototype.onWebSocketConnection = function (socket, opts) { 491 | var self = this 492 | if (!opts) opts = {} 493 | opts.trustProxy = opts.trustProxy || self._trustProxy 494 | 495 | socket.peerId = null // as hex 496 | socket.infoHashes = [] // swarms that this socket is participating in 497 | socket.onSend = function (err) { 498 | self._onWebSocketSend(socket, err) 499 | } 500 | 501 | socket.onMessageBound = function (params) { 502 | self._onWebSocketRequest(socket, opts, params) 503 | } 504 | socket.on('message', socket.onMessageBound) 505 | 506 | socket.onErrorBound = function (err) { 507 | self._onWebSocketError(socket, err) 508 | } 509 | socket.on('error', socket.onErrorBound) 510 | 511 | socket.onCloseBound = function () { 512 | self._onWebSocketClose(socket) 513 | } 514 | socket.on('close', socket.onCloseBound) 515 | } 516 | 517 | Server.prototype._onWebSocketRequest = function (socket, opts, params) { 518 | var self = this 519 | 520 | try { 521 | params = parseWebSocketRequest(socket, opts, params) 522 | } catch (err) { 523 | socket.send(JSON.stringify({ 524 | 'failure reason': err.message 525 | }), socket.onSend) 526 | 527 | // even though it's an error for the client, it's just a warning for the server. 528 | // don't crash the server because a client sent bad data :) 529 | self.emit('warning', err) 530 | return 531 | } 532 | 533 | if (!socket.peerId) socket.peerId = params.peer_id // as hex 534 | 535 | self._onRequest(params, function (err, response) { 536 | if (self.destroyed || socket.destroyed) return 537 | if (err) { 538 | socket.send(JSON.stringify({ 539 | action: params.action === common.ACTIONS.ANNOUNCE ? 'announce' : 'scrape', 540 | 'failure reason': err.message, 541 | info_hash: common.hexToBinary(params.info_hash) 542 | }), socket.onSend) 543 | 544 | self.emit('warning', err) 545 | return 546 | } 547 | 548 | response.action = params.action === common.ACTIONS.ANNOUNCE ? 'announce' : 'scrape' 549 | 550 | var peers 551 | if (response.action === 'announce') { 552 | peers = response.peers 553 | delete response.peers 554 | 555 | if (socket.infoHashes.indexOf(params.info_hash) === -1) { 556 | socket.infoHashes.push(params.info_hash) 557 | } 558 | 559 | response.info_hash = common.hexToBinary(params.info_hash) 560 | 561 | // WebSocket tracker should have a shorter interval – default: 2 minutes 562 | response.interval = Math.ceil(self.intervalMs / 1000 / 5) 563 | } 564 | 565 | // Skip sending update back for 'answer' announce messages – not needed 566 | if (!params.answer) { 567 | socket.send(JSON.stringify(response), socket.onSend) 568 | debug('sent response %s to %s', JSON.stringify(response), params.peer_id) 569 | } 570 | 571 | if (Array.isArray(params.offers)) { 572 | debug('got %s offers from %s', params.offers.length, params.peer_id) 573 | debug('got %s peers from swarm %s', peers.length, params.info_hash) 574 | peers.forEach(function (peer, i) { 575 | peer.socket.send(JSON.stringify({ 576 | action: 'announce', 577 | offer: params.offers[i].offer, 578 | offer_id: params.offers[i].offer_id, 579 | peer_id: common.hexToBinary(params.peer_id), 580 | info_hash: common.hexToBinary(params.info_hash) 581 | }), peer.socket.onSend) 582 | debug('sent offer to %s from %s', peer.peerId, params.peer_id) 583 | }) 584 | } 585 | 586 | if (params.answer) { 587 | debug('got answer %s from %s', JSON.stringify(params.answer), params.peer_id) 588 | 589 | self.getSwarm(params.info_hash, function (err, swarm) { 590 | if (self.destroyed) return 591 | if (err) return self.emit('warning', err) 592 | if (!swarm) { 593 | return self.emit('warning', new Error('no swarm with that `info_hash`')) 594 | } 595 | // Mark the destination peer as recently used in cache 596 | var toPeer = swarm.peers.get(params.to_peer_id) 597 | if (!toPeer) { 598 | return self.emit('warning', new Error('no peer with that `to_peer_id`')) 599 | } 600 | 601 | toPeer.socket.send(JSON.stringify({ 602 | action: 'announce', 603 | answer: params.answer, 604 | offer_id: params.offer_id, 605 | peer_id: common.hexToBinary(params.peer_id), 606 | info_hash: common.hexToBinary(params.info_hash) 607 | }), toPeer.socket.onSend) 608 | debug('sent answer to %s from %s', toPeer.peerId, params.peer_id) 609 | 610 | done() 611 | }) 612 | } else { 613 | done() 614 | } 615 | 616 | function done () { 617 | // emit event once the announce is fully "processed" 618 | if (params.action === common.ACTIONS.ANNOUNCE) { 619 | self.emit(common.EVENT_NAMES[params.event], params.peer_id, params) 620 | } 621 | } 622 | }) 623 | } 624 | 625 | Server.prototype._onWebSocketSend = function (socket, err) { 626 | var self = this 627 | if (err) self._onWebSocketError(socket, err) 628 | } 629 | 630 | Server.prototype._onWebSocketClose = function (socket) { 631 | var self = this 632 | debug('websocket close %s', socket.peerId) 633 | socket.destroyed = true 634 | 635 | if (socket.peerId) { 636 | socket.infoHashes.slice(0).forEach(function (infoHash) { 637 | var swarm = self.torrents[infoHash] 638 | if (swarm) { 639 | swarm.announce({ 640 | type: 'ws', 641 | event: 'stopped', 642 | numwant: 0, 643 | peer_id: socket.peerId 644 | }, noop) 645 | } 646 | }) 647 | } 648 | 649 | // ignore all future errors 650 | socket.onSend = noop 651 | socket.on('error', noop) 652 | 653 | socket.peerId = null 654 | socket.infoHashes = null 655 | 656 | if (typeof socket.onMessageBound === 'function') { 657 | socket.removeListener('message', socket.onMessageBound) 658 | } 659 | socket.onMessageBound = null 660 | 661 | if (typeof socket.onErrorBound === 'function') { 662 | socket.removeListener('error', socket.onErrorBound) 663 | } 664 | socket.onErrorBound = null 665 | 666 | if (typeof socket.onCloseBound === 'function') { 667 | socket.removeListener('close', socket.onCloseBound) 668 | } 669 | socket.onCloseBound = null 670 | } 671 | 672 | Server.prototype._onWebSocketError = function (socket, err) { 673 | var self = this 674 | debug('websocket error %s', err.message || err) 675 | self.emit('warning', err) 676 | self._onWebSocketClose(socket) 677 | } 678 | 679 | Server.prototype._onRequest = function (params, cb) { 680 | var self = this 681 | if (params && params.action === common.ACTIONS.CONNECT) { 682 | cb(null, { action: common.ACTIONS.CONNECT }) 683 | } else if (params && params.action === common.ACTIONS.ANNOUNCE) { 684 | self._onAnnounce(params, cb) 685 | } else if (params && params.action === common.ACTIONS.SCRAPE) { 686 | self._onScrape(params, cb) 687 | } else { 688 | cb(new Error('Invalid action')) 689 | } 690 | } 691 | 692 | Server.prototype._onAnnounce = function (params, cb) { 693 | var self = this 694 | 695 | self.getSwarm(params.info_hash, function (err, swarm) { 696 | if (err) return cb(err) 697 | if (swarm) { 698 | announce(swarm) 699 | } else { 700 | createSwarm() 701 | } 702 | }) 703 | 704 | function createSwarm () { 705 | if (self._filter) { 706 | self._filter(params.info_hash, params, function (err) { 707 | // Precense of err means that this info_hash is disallowed 708 | if (err) { 709 | cb(err) 710 | } else { 711 | self.createSwarm(params.info_hash, function (err, swarm) { 712 | if (err) return cb(err) 713 | announce(swarm) 714 | }) 715 | } 716 | }) 717 | } else { 718 | self.createSwarm(params.info_hash, function (err, swarm) { 719 | if (err) return cb(err) 720 | announce(swarm) 721 | }) 722 | } 723 | } 724 | 725 | function announce (swarm) { 726 | if (!params.event || params.event === 'empty') params.event = 'update' 727 | swarm.announce(params, function (err, response) { 728 | if (err) return cb(err) 729 | 730 | if (!response.action) response.action = common.ACTIONS.ANNOUNCE 731 | if (!response.interval) response.interval = Math.ceil(self.intervalMs / 1000) 732 | 733 | if (params.compact === 1) { 734 | var peers = response.peers 735 | 736 | // Find IPv4 peers 737 | response.peers = string2compact(peers.filter(function (peer) { 738 | return common.IPV4_RE.test(peer.ip) 739 | }).map(function (peer) { 740 | return peer.ip + ':' + peer.port 741 | })) 742 | // Find IPv6 peers 743 | response.peers6 = string2compact(peers.filter(function (peer) { 744 | return common.IPV6_RE.test(peer.ip) 745 | }).map(function (peer) { 746 | return '[' + peer.ip + ']:' + peer.port 747 | })) 748 | } else if (params.compact === 0) { 749 | // IPv6 peers are not separate for non-compact responses 750 | response.peers = response.peers.map(function (peer) { 751 | return { 752 | 'peer id': common.hexToBinary(peer.peerId), 753 | ip: peer.ip, 754 | port: peer.port 755 | } 756 | }) 757 | } // else, return full peer objects (used for websocket responses) 758 | 759 | cb(null, response) 760 | }) 761 | } 762 | } 763 | 764 | Server.prototype._onScrape = function (params, cb) { 765 | var self = this 766 | 767 | if (params.info_hash == null) { 768 | // if info_hash param is omitted, stats for all torrents are returned 769 | // TODO: make this configurable! 770 | params.info_hash = Object.keys(self.torrents) 771 | } 772 | 773 | series(params.info_hash.map(function (infoHash) { 774 | return function (cb) { 775 | self.getSwarm(infoHash, function (err, swarm) { 776 | if (err) return cb(err) 777 | if (swarm) { 778 | swarm.scrape(params, function (err, scrapeInfo) { 779 | if (err) return cb(err) 780 | cb(null, { 781 | infoHash: infoHash, 782 | complete: (scrapeInfo && scrapeInfo.complete) || 0, 783 | incomplete: (scrapeInfo && scrapeInfo.incomplete) || 0 784 | }) 785 | }) 786 | } else { 787 | cb(null, { infoHash: infoHash, complete: 0, incomplete: 0 }) 788 | } 789 | }) 790 | } 791 | }), function (err, results) { 792 | if (err) return cb(err) 793 | 794 | var response = { 795 | action: common.ACTIONS.SCRAPE, 796 | files: {}, 797 | flags: { min_request_interval: Math.ceil(self.intervalMs / 1000) } 798 | } 799 | 800 | results.forEach(function (result) { 801 | response.files[common.hexToBinary(result.infoHash)] = { 802 | complete: result.complete || 0, 803 | incomplete: result.incomplete || 0, 804 | downloaded: result.complete || 0 // TODO: this only provides a lower-bound 805 | } 806 | }) 807 | 808 | cb(null, response) 809 | }) 810 | } 811 | 812 | function makeUdpPacket (params) { 813 | var packet 814 | switch (params.action) { 815 | case common.ACTIONS.CONNECT: 816 | packet = Buffer.concat([ 817 | common.toUInt32(common.ACTIONS.CONNECT), 818 | common.toUInt32(params.transactionId), 819 | params.connectionId 820 | ]) 821 | break 822 | case common.ACTIONS.ANNOUNCE: 823 | packet = Buffer.concat([ 824 | common.toUInt32(common.ACTIONS.ANNOUNCE), 825 | common.toUInt32(params.transactionId), 826 | common.toUInt32(params.interval), 827 | common.toUInt32(params.incomplete), 828 | common.toUInt32(params.complete), 829 | params.peers 830 | ]) 831 | break 832 | case common.ACTIONS.SCRAPE: 833 | var scrapeResponse = [ 834 | common.toUInt32(common.ACTIONS.SCRAPE), 835 | common.toUInt32(params.transactionId) 836 | ] 837 | for (var infoHash in params.files) { 838 | var file = params.files[infoHash] 839 | scrapeResponse.push( 840 | common.toUInt32(file.complete), 841 | common.toUInt32(file.downloaded), // TODO: this only provides a lower-bound 842 | common.toUInt32(file.incomplete) 843 | ) 844 | } 845 | packet = Buffer.concat(scrapeResponse) 846 | break 847 | case common.ACTIONS.ERROR: 848 | packet = Buffer.concat([ 849 | common.toUInt32(common.ACTIONS.ERROR), 850 | common.toUInt32(params.transactionId || 0), 851 | Buffer.from(String(params['failure reason'])) 852 | ]) 853 | break 854 | default: 855 | throw new Error('Action not implemented: ' + params.action) 856 | } 857 | return packet 858 | } 859 | 860 | function toNumber (x) { 861 | x = Number(x) 862 | return x >= 0 ? x : false 863 | } 864 | 865 | function noop () {} 866 | -------------------------------------------------------------------------------- /test/client-large-torrent.js: -------------------------------------------------------------------------------- 1 | var Buffer = require('safe-buffer').Buffer 2 | var Client = require('../') 3 | var common = require('./common') 4 | var fixtures = require('webtorrent-fixtures') 5 | var test = require('tape') 6 | 7 | var peerId = Buffer.from('01234567890123456789') 8 | 9 | function testLargeTorrent (t, serverType) { 10 | t.plan(9) 11 | 12 | common.createServer(t, serverType, function (server, announceUrl) { 13 | var client = new Client({ 14 | infoHash: fixtures.sintel.parsedTorrent.infoHash, 15 | peerId: peerId, 16 | port: 6881, 17 | announce: announceUrl, 18 | wrtc: {} 19 | }) 20 | 21 | if (serverType === 'ws') common.mockWebsocketTracker(client) 22 | client.on('error', function (err) { t.error(err) }) 23 | client.on('warning', function (err) { t.error(err) }) 24 | 25 | client.once('update', function (data) { 26 | t.equal(data.announce, announceUrl) 27 | t.equal(typeof data.complete, 'number') 28 | t.equal(typeof data.incomplete, 'number') 29 | 30 | client.update() 31 | 32 | client.once('update', function (data) { 33 | t.equal(data.announce, announceUrl) 34 | t.equal(typeof data.complete, 'number') 35 | t.equal(typeof data.incomplete, 'number') 36 | 37 | client.stop() 38 | 39 | client.once('update', function (data) { 40 | t.equal(data.announce, announceUrl) 41 | t.equal(typeof data.complete, 'number') 42 | t.equal(typeof data.incomplete, 'number') 43 | 44 | server.close() 45 | client.destroy() 46 | }) 47 | }) 48 | }) 49 | 50 | client.start() 51 | }) 52 | } 53 | 54 | test('http: large torrent: client.start()', function (t) { 55 | testLargeTorrent(t, 'http') 56 | }) 57 | 58 | test('udp: large torrent: client.start()', function (t) { 59 | testLargeTorrent(t, 'udp') 60 | }) 61 | 62 | test('ws: large torrent: client.start()', function (t) { 63 | testLargeTorrent(t, 'ws') 64 | }) 65 | -------------------------------------------------------------------------------- /test/client-magnet.js: -------------------------------------------------------------------------------- 1 | var Buffer = require('safe-buffer').Buffer 2 | var Client = require('../') 3 | var common = require('./common') 4 | var fixtures = require('webtorrent-fixtures') 5 | var magnet = require('magnet-uri') 6 | var test = require('tape') 7 | 8 | var peerId = Buffer.from('01234567890123456789') 9 | 10 | function testMagnet (t, serverType) { 11 | t.plan(9) 12 | 13 | var parsedTorrent = magnet(fixtures.leaves.magnetURI) 14 | 15 | common.createServer(t, serverType, function (server, announceUrl) { 16 | var client = new Client({ 17 | infoHash: parsedTorrent.infoHash, 18 | announce: announceUrl, 19 | peerId: peerId, 20 | port: 6881, 21 | wrtc: {} 22 | }) 23 | 24 | if (serverType === 'ws') common.mockWebsocketTracker(client) 25 | client.on('error', function (err) { t.error(err) }) 26 | client.on('warning', function (err) { t.error(err) }) 27 | 28 | client.once('update', function (data) { 29 | t.equal(data.announce, announceUrl) 30 | t.equal(typeof data.complete, 'number') 31 | t.equal(typeof data.incomplete, 'number') 32 | 33 | client.update() 34 | 35 | client.once('update', function (data) { 36 | t.equal(data.announce, announceUrl) 37 | t.equal(typeof data.complete, 'number') 38 | t.equal(typeof data.incomplete, 'number') 39 | 40 | client.stop() 41 | 42 | client.once('update', function (data) { 43 | t.equal(data.announce, announceUrl) 44 | t.equal(typeof data.complete, 'number') 45 | t.equal(typeof data.incomplete, 'number') 46 | 47 | server.close() 48 | client.destroy() 49 | }) 50 | }) 51 | }) 52 | 53 | client.start() 54 | }) 55 | } 56 | 57 | test('http: magnet: client.start/update/stop()', function (t) { 58 | testMagnet(t, 'http') 59 | }) 60 | 61 | test('udp: magnet: client.start/update/stop()', function (t) { 62 | testMagnet(t, 'udp') 63 | }) 64 | 65 | test('ws: magnet: client.start/update/stop()', function (t) { 66 | testMagnet(t, 'ws') 67 | }) 68 | -------------------------------------------------------------------------------- /test/client-ws-socket-pool.js: -------------------------------------------------------------------------------- 1 | var Buffer = require('safe-buffer').Buffer 2 | var Client = require('../') 3 | var common = require('./common') 4 | var fixtures = require('webtorrent-fixtures') 5 | var test = require('tape') 6 | 7 | var peerId = Buffer.from('01234567890123456789') 8 | var port = 6681 9 | 10 | test('ensure client.destroy() callback is called with re-used websockets in socketPool', function (t) { 11 | t.plan(4) 12 | 13 | common.createServer(t, 'ws', function (server, announceUrl) { 14 | var client1 = new Client({ 15 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 16 | announce: announceUrl, 17 | peerId: peerId, 18 | port: port, 19 | wrtc: {} 20 | }) 21 | 22 | common.mockWebsocketTracker(client1) 23 | client1.on('error', function (err) { t.error(err) }) 24 | client1.on('warning', function (err) { t.error(err) }) 25 | 26 | client1.start() 27 | 28 | client1.once('update', function () { 29 | t.pass('got client1 update') 30 | // second ws client using same announce url will re-use the same websocket 31 | var client2 = new Client({ 32 | infoHash: fixtures.alice.parsedTorrent.infoHash, // different info hash 33 | announce: announceUrl, 34 | peerId: peerId, 35 | port: port, 36 | wrtc: {} 37 | }) 38 | 39 | common.mockWebsocketTracker(client2) 40 | client2.on('error', function (err) { t.error(err) }) 41 | client2.on('warning', function (err) { t.error(err) }) 42 | 43 | client2.start() 44 | 45 | client2.once('update', function () { 46 | t.pass('got client2 update') 47 | client1.destroy(function (err) { 48 | t.error(err, 'got client1 destroy callback') 49 | client2.destroy(function (err) { 50 | t.error(err, 'got client2 destroy callback') 51 | server.close() 52 | }) 53 | }) 54 | }) 55 | }) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /test/client.js: -------------------------------------------------------------------------------- 1 | var Buffer = require('safe-buffer').Buffer 2 | var Client = require('../') 3 | var common = require('./common') 4 | var fixtures = require('webtorrent-fixtures') 5 | var test = require('tape') 6 | 7 | var peerId1 = Buffer.from('01234567890123456789') 8 | var peerId2 = Buffer.from('12345678901234567890') 9 | var peerId3 = Buffer.from('23456789012345678901') 10 | var port = 6881 11 | 12 | function testClientStart (t, serverType) { 13 | t.plan(4) 14 | 15 | common.createServer(t, serverType, function (server, announceUrl) { 16 | var client = new Client({ 17 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 18 | announce: announceUrl, 19 | peerId: peerId1, 20 | port: port, 21 | wrtc: {} 22 | }) 23 | 24 | if (serverType === 'ws') common.mockWebsocketTracker(client) 25 | client.on('error', function (err) { t.error(err) }) 26 | client.on('warning', function (err) { t.error(err) }) 27 | 28 | client.once('update', function (data) { 29 | t.equal(data.announce, announceUrl) 30 | t.equal(typeof data.complete, 'number') 31 | t.equal(typeof data.incomplete, 'number') 32 | 33 | client.stop() 34 | 35 | client.once('update', function () { 36 | t.pass('got response to stop') 37 | server.close() 38 | client.destroy() 39 | }) 40 | }) 41 | 42 | client.start() 43 | }) 44 | } 45 | 46 | test('http: client.start()', function (t) { 47 | testClientStart(t, 'http') 48 | }) 49 | 50 | test('udp: client.start()', function (t) { 51 | testClientStart(t, 'udp') 52 | }) 53 | 54 | test('ws: client.start()', function (t) { 55 | testClientStart(t, 'ws') 56 | }) 57 | 58 | function testClientStop (t, serverType) { 59 | t.plan(4) 60 | 61 | common.createServer(t, serverType, function (server, announceUrl) { 62 | var client = new Client({ 63 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 64 | announce: announceUrl, 65 | peerId: peerId1, 66 | port: port, 67 | wrtc: {} 68 | }) 69 | 70 | if (serverType === 'ws') common.mockWebsocketTracker(client) 71 | client.on('error', function (err) { t.error(err) }) 72 | client.on('warning', function (err) { t.error(err) }) 73 | 74 | client.start() 75 | 76 | client.once('update', function () { 77 | t.pass('client received response to "start" message') 78 | 79 | client.stop() 80 | 81 | client.once('update', function (data) { 82 | // receive one final update after calling stop 83 | t.equal(data.announce, announceUrl) 84 | t.equal(typeof data.complete, 'number') 85 | t.equal(typeof data.incomplete, 'number') 86 | 87 | server.close() 88 | client.destroy() 89 | }) 90 | }) 91 | }) 92 | } 93 | 94 | test('http: client.stop()', function (t) { 95 | testClientStop(t, 'http') 96 | }) 97 | 98 | test('udp: client.stop()', function (t) { 99 | testClientStop(t, 'udp') 100 | }) 101 | 102 | test('ws: client.stop()', function (t) { 103 | testClientStop(t, 'ws') 104 | }) 105 | 106 | function testClientStopDestroy (t, serverType) { 107 | t.plan(2) 108 | 109 | common.createServer(t, serverType, function (server, announceUrl) { 110 | var client = new Client({ 111 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 112 | announce: announceUrl, 113 | peerId: peerId1, 114 | port: port, 115 | wrtc: {} 116 | }) 117 | 118 | if (serverType === 'ws') common.mockWebsocketTracker(client) 119 | client.on('error', function (err) { t.error(err) }) 120 | client.on('warning', function (err) { t.error(err) }) 121 | 122 | client.start() 123 | 124 | client.once('update', function () { 125 | t.pass('client received response to "start" message') 126 | 127 | client.stop() 128 | 129 | client.on('update', function () { t.fail('client should not receive update after destroy is called') }) 130 | 131 | // Call destroy() in the same tick as stop(), but the message should still 132 | // be received by the server, though obviously the client won't receive the 133 | // response. 134 | client.destroy() 135 | 136 | server.once('stop', function (peer, params) { 137 | t.pass('server received "stop" message') 138 | setTimeout(function () { 139 | // give the websocket server time to finish in progress (stream) messages 140 | // to peers 141 | server.close() 142 | }, 100) 143 | }) 144 | }) 145 | }) 146 | } 147 | 148 | test('http: client.stop(); client.destroy()', function (t) { 149 | testClientStopDestroy(t, 'http') 150 | }) 151 | 152 | test('udp: client.stop(); client.destroy()', function (t) { 153 | testClientStopDestroy(t, 'udp') 154 | }) 155 | 156 | test('ws: client.stop(); client.destroy()', function (t) { 157 | testClientStopDestroy(t, 'ws') 158 | }) 159 | 160 | function testClientUpdate (t, serverType) { 161 | t.plan(4) 162 | 163 | common.createServer(t, serverType, function (server, announceUrl) { 164 | var client = new Client({ 165 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 166 | announce: announceUrl, 167 | peerId: peerId1, 168 | port: port, 169 | wrtc: {} 170 | }) 171 | 172 | if (serverType === 'ws') common.mockWebsocketTracker(client) 173 | client.on('error', function (err) { t.error(err) }) 174 | client.on('warning', function (err) { t.error(err) }) 175 | 176 | client.setInterval(500) 177 | 178 | client.start() 179 | 180 | client.once('update', function () { 181 | client.setInterval(500) 182 | 183 | // after interval, we should get another update 184 | client.once('update', function (data) { 185 | // received an update! 186 | t.equal(data.announce, announceUrl) 187 | t.equal(typeof data.complete, 'number') 188 | t.equal(typeof data.incomplete, 'number') 189 | client.stop() 190 | 191 | client.once('update', function () { 192 | t.pass('got response to stop') 193 | server.close() 194 | client.destroy() 195 | }) 196 | }) 197 | }) 198 | }) 199 | } 200 | 201 | test('http: client.update()', function (t) { 202 | testClientUpdate(t, 'http') 203 | }) 204 | 205 | test('udp: client.update()', function (t) { 206 | testClientUpdate(t, 'udp') 207 | }) 208 | 209 | test('ws: client.update()', function (t) { 210 | testClientUpdate(t, 'ws') 211 | }) 212 | 213 | function testClientScrape (t, serverType) { 214 | t.plan(4) 215 | 216 | common.createServer(t, serverType, function (server, announceUrl) { 217 | var client = new Client({ 218 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 219 | announce: announceUrl, 220 | peerId: peerId1, 221 | port: port, 222 | wrtc: {} 223 | }) 224 | 225 | if (serverType === 'ws') common.mockWebsocketTracker(client) 226 | client.on('error', function (err) { t.error(err) }) 227 | client.on('warning', function (err) { t.error(err) }) 228 | 229 | client.once('scrape', function (data) { 230 | t.equal(data.announce, announceUrl) 231 | t.equal(typeof data.complete, 'number') 232 | t.equal(typeof data.incomplete, 'number') 233 | t.equal(typeof data.downloaded, 'number') 234 | 235 | server.close() 236 | client.destroy() 237 | }) 238 | 239 | client.scrape() 240 | }) 241 | } 242 | 243 | test('http: client.scrape()', function (t) { 244 | testClientScrape(t, 'http') 245 | }) 246 | 247 | test('udp: client.scrape()', function (t) { 248 | testClientScrape(t, 'udp') 249 | }) 250 | 251 | test('ws: client.scrape()', function (t) { 252 | testClientScrape(t, 'ws') 253 | }) 254 | 255 | function testClientAnnounceWithParams (t, serverType) { 256 | t.plan(5) 257 | 258 | common.createServer(t, serverType, function (server, announceUrl) { 259 | var client = new Client({ 260 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 261 | announce: announceUrl, 262 | peerId: peerId1, 263 | port: port, 264 | wrtc: {} 265 | }) 266 | 267 | server.on('start', function (peer, params) { 268 | t.equal(params.testParam, 'this is a test') 269 | }) 270 | 271 | if (serverType === 'ws') common.mockWebsocketTracker(client) 272 | client.on('error', function (err) { t.error(err) }) 273 | client.on('warning', function (err) { t.error(err) }) 274 | 275 | client.once('update', function (data) { 276 | t.equal(data.announce, announceUrl) 277 | t.equal(typeof data.complete, 'number') 278 | t.equal(typeof data.incomplete, 'number') 279 | 280 | client.stop() 281 | 282 | client.once('update', function () { 283 | t.pass('got response to stop') 284 | server.close() 285 | client.destroy() 286 | }) 287 | }) 288 | 289 | client.start({ 290 | testParam: 'this is a test' 291 | }) 292 | }) 293 | } 294 | 295 | test('http: client.announce() with params', function (t) { 296 | testClientAnnounceWithParams(t, 'http') 297 | }) 298 | 299 | test('ws: client.announce() with params', function (t) { 300 | testClientAnnounceWithParams(t, 'ws') 301 | }) 302 | 303 | function testClientGetAnnounceOpts (t, serverType) { 304 | t.plan(5) 305 | 306 | common.createServer(t, serverType, function (server, announceUrl) { 307 | var client = new Client({ 308 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 309 | announce: announceUrl, 310 | peerId: peerId1, 311 | port: port, 312 | getAnnounceOpts: function () { 313 | return { 314 | testParam: 'this is a test' 315 | } 316 | }, 317 | wrtc: {} 318 | }) 319 | 320 | server.on('start', function (peer, params) { 321 | t.equal(params.testParam, 'this is a test') 322 | }) 323 | 324 | if (serverType === 'ws') common.mockWebsocketTracker(client) 325 | client.on('error', function (err) { t.error(err) }) 326 | client.on('warning', function (err) { t.error(err) }) 327 | 328 | client.once('update', function (data) { 329 | t.equal(data.announce, announceUrl) 330 | t.equal(typeof data.complete, 'number') 331 | t.equal(typeof data.incomplete, 'number') 332 | 333 | client.stop() 334 | 335 | client.once('update', function () { 336 | t.pass('got response to stop') 337 | server.close() 338 | client.destroy() 339 | }) 340 | }) 341 | 342 | client.start() 343 | }) 344 | } 345 | 346 | test('http: client `opts.getAnnounceOpts`', function (t) { 347 | testClientGetAnnounceOpts(t, 'http') 348 | }) 349 | 350 | test('ws: client `opts.getAnnounceOpts`', function (t) { 351 | testClientGetAnnounceOpts(t, 'ws') 352 | }) 353 | 354 | function testClientAnnounceWithNumWant (t, serverType) { 355 | t.plan(4) 356 | 357 | common.createServer(t, serverType, function (server, announceUrl) { 358 | var client1 = new Client({ 359 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 360 | announce: [ announceUrl ], 361 | peerId: peerId1, 362 | port: port, 363 | wrtc: {} 364 | }) 365 | 366 | if (serverType === 'ws') common.mockWebsocketTracker(client1) 367 | client1.on('error', function (err) { t.error(err) }) 368 | client1.on('warning', function (err) { t.error(err) }) 369 | 370 | client1.start() 371 | client1.once('update', function () { 372 | var client2 = new Client({ 373 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 374 | announce: announceUrl, 375 | peerId: peerId2, 376 | port: port + 1, 377 | wrtc: {} 378 | }) 379 | 380 | if (serverType === 'ws') common.mockWebsocketTracker(client2) 381 | client2.on('error', function (err) { t.error(err) }) 382 | client2.on('warning', function (err) { t.error(err) }) 383 | 384 | client2.start() 385 | client2.once('update', function () { 386 | var client3 = new Client({ 387 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 388 | announce: announceUrl, 389 | peerId: peerId3, 390 | port: port + 2, 391 | wrtc: {} 392 | }) 393 | 394 | if (serverType === 'ws') common.mockWebsocketTracker(client3) 395 | client3.on('error', function (err) { t.error(err) }) 396 | client3.on('warning', function (err) { t.error(err) }) 397 | 398 | client3.start({ numwant: 1 }) 399 | client3.on('peer', function () { 400 | t.pass('got one peer (this should only fire once)') 401 | 402 | var num = 3 403 | function tryCloseServer () { 404 | num -= 1 405 | if (num === 0) server.close() 406 | } 407 | 408 | client1.stop() 409 | client1.once('update', function () { 410 | t.pass('got response to stop (client1)') 411 | client1.destroy() 412 | tryCloseServer() 413 | }) 414 | client2.stop() 415 | client2.once('update', function () { 416 | t.pass('got response to stop (client2)') 417 | client2.destroy() 418 | tryCloseServer() 419 | }) 420 | client3.stop() 421 | client3.once('update', function () { 422 | t.pass('got response to stop (client3)') 423 | client3.destroy() 424 | tryCloseServer() 425 | }) 426 | }) 427 | }) 428 | }) 429 | }) 430 | } 431 | 432 | test('http: client announce with numwant', function (t) { 433 | testClientAnnounceWithNumWant(t, 'http') 434 | }) 435 | 436 | test('udp: client announce with numwant', function (t) { 437 | testClientAnnounceWithNumWant(t, 'udp') 438 | }) 439 | 440 | test('http: userAgent', function (t) { 441 | t.plan(2) 442 | 443 | common.createServer(t, 'http', function (server, announceUrl) { 444 | // Confirm that user-agent header is set 445 | server.http.on('request', function (req, res) { 446 | t.ok(req.headers['user-agent'].indexOf('WebTorrent') !== -1) 447 | }) 448 | 449 | var client = new Client({ 450 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 451 | announce: announceUrl, 452 | peerId: peerId1, 453 | port: port, 454 | userAgent: 'WebTorrent/0.98.0 (https://webtorrent.io)', 455 | wrtc: {} 456 | }) 457 | 458 | client.on('error', function (err) { t.error(err) }) 459 | client.on('warning', function (err) { t.error(err) }) 460 | 461 | client.once('update', function (data) { 462 | t.equal(data.announce, announceUrl) 463 | 464 | server.close() 465 | client.destroy() 466 | }) 467 | 468 | client.start() 469 | }) 470 | }) 471 | -------------------------------------------------------------------------------- /test/common.js: -------------------------------------------------------------------------------- 1 | var Server = require('../').Server 2 | 3 | exports.createServer = function (t, opts, cb) { 4 | if (typeof opts === 'string') opts = { serverType: opts } 5 | 6 | opts.http = (opts.serverType === 'http') 7 | opts.udp = (opts.serverType === 'udp') 8 | opts.ws = (opts.serverType === 'ws') 9 | 10 | var server = new Server(opts) 11 | 12 | server.on('error', function (err) { t.error(err) }) 13 | server.on('warning', function (err) { t.error(err) }) 14 | 15 | server.listen(0, function () { 16 | var port = server[opts.serverType].address().port 17 | var announceUrl 18 | if (opts.serverType === 'http') { 19 | announceUrl = 'http://127.0.0.1:' + port + '/announce' 20 | } else if (opts.serverType === 'udp') { 21 | announceUrl = 'udp://127.0.0.1:' + port 22 | } else if (opts.serverType === 'ws') { 23 | announceUrl = 'ws://127.0.0.1:' + port 24 | } 25 | 26 | cb(server, announceUrl) 27 | }) 28 | } 29 | 30 | exports.mockWebsocketTracker = function (client) { 31 | client._trackers[0]._generateOffers = function (numwant, cb) { 32 | var offers = [] 33 | for (var i = 0; i < numwant; i++) { 34 | offers.push({ fake_offer: 'fake_offer_' + i }) 35 | } 36 | process.nextTick(function () { 37 | cb(offers) 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/destroy.js: -------------------------------------------------------------------------------- 1 | var Buffer = require('safe-buffer').Buffer 2 | var Client = require('../') 3 | var common = require('./common') 4 | var fixtures = require('webtorrent-fixtures') 5 | var test = require('tape') 6 | 7 | var peerId = Buffer.from('01234567890123456789') 8 | var port = 6881 9 | 10 | function testNoEventsAfterDestroy (t, serverType) { 11 | t.plan(1) 12 | 13 | common.createServer(t, serverType, function (server, announceUrl) { 14 | var client = new Client({ 15 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 16 | announce: announceUrl, 17 | peerId: peerId, 18 | port: port, 19 | wrtc: {} 20 | }) 21 | 22 | if (serverType === 'ws') common.mockWebsocketTracker(client) 23 | client.on('error', function (err) { t.error(err) }) 24 | client.on('warning', function (err) { t.error(err) }) 25 | 26 | client.once('update', function () { 27 | t.fail('no "update" event should fire, since client is destroyed') 28 | }) 29 | 30 | // announce, then immediately destroy 31 | client.update() 32 | client.destroy() 33 | 34 | setTimeout(function () { 35 | t.pass('wait to see if any events are fired') 36 | server.close() 37 | }, 1000) 38 | }) 39 | } 40 | 41 | test('http: no "update" events after destroy()', function (t) { 42 | testNoEventsAfterDestroy(t, 'http') 43 | }) 44 | 45 | test('udp: no "update" events after destroy()', function (t) { 46 | testNoEventsAfterDestroy(t, 'udp') 47 | }) 48 | 49 | test('ws: no "update" events after destroy()', function (t) { 50 | testNoEventsAfterDestroy(t, 'ws') 51 | }) 52 | -------------------------------------------------------------------------------- /test/evict.js: -------------------------------------------------------------------------------- 1 | var Buffer = require('safe-buffer').Buffer 2 | var Client = require('../') 3 | var common = require('./common') 4 | var test = require('tape') 5 | var electronWebrtc = require('electron-webrtc') 6 | 7 | var wrtc 8 | 9 | var infoHash = '4cb67059ed6bd08362da625b3ae77f6f4a075705' 10 | var peerId = Buffer.from('01234567890123456789') 11 | var peerId2 = Buffer.from('12345678901234567890') 12 | var peerId3 = Buffer.from('23456789012345678901') 13 | 14 | function serverTest (t, serverType, serverFamily) { 15 | t.plan(10) 16 | 17 | var hostname = serverFamily === 'inet6' 18 | ? '[::1]' 19 | : '127.0.0.1' 20 | 21 | var opts = { 22 | serverType: serverType, 23 | peersCacheLength: 2 // LRU cache can only contain a max of 2 peers 24 | } 25 | 26 | common.createServer(t, opts, function (server) { 27 | // Not using announceUrl param from `common.createServer()` since we 28 | // want to control IPv4 vs IPv6. 29 | var port = server[serverType].address().port 30 | var announceUrl = serverType + '://' + hostname + ':' + port + '/announce' 31 | 32 | var client1 = new Client({ 33 | infoHash: infoHash, 34 | announce: [ announceUrl ], 35 | peerId: peerId, 36 | port: 6881, 37 | wrtc: wrtc 38 | }) 39 | if (serverType === 'ws') common.mockWebsocketTracker(client1) 40 | 41 | client1.start() 42 | 43 | client1.once('update', function (data) { 44 | var client2 = new Client({ 45 | infoHash: infoHash, 46 | announce: [ announceUrl ], 47 | peerId: peerId2, 48 | port: 6882, 49 | wrtc: wrtc 50 | }) 51 | if (serverType === 'ws') common.mockWebsocketTracker(client2) 52 | 53 | client2.start() 54 | 55 | client2.once('update', function (data) { 56 | server.getSwarm(infoHash, function (err, swarm) { 57 | t.error(err) 58 | 59 | t.equal(swarm.complete + swarm.incomplete, 2) 60 | 61 | // Ensure that first peer is evicted when a third one is added 62 | var evicted = false 63 | swarm.peers.once('evict', function (evictedPeer) { 64 | t.equal(evictedPeer.value.peerId, peerId.toString('hex')) 65 | t.equal(swarm.complete + swarm.incomplete, 2) 66 | evicted = true 67 | }) 68 | 69 | var client3 = new Client({ 70 | infoHash: infoHash, 71 | announce: [ announceUrl ], 72 | peerId: peerId3, 73 | port: 6880, 74 | wrtc: wrtc 75 | }) 76 | if (serverType === 'ws') common.mockWebsocketTracker(client3) 77 | 78 | client3.start() 79 | 80 | client3.once('update', function (data) { 81 | t.ok(evicted, 'client1 was evicted from server before client3 gets response') 82 | t.equal(swarm.complete + swarm.incomplete, 2) 83 | 84 | client1.destroy(function () { 85 | t.pass('client1 destroyed') 86 | }) 87 | 88 | client2.destroy(function () { 89 | t.pass('client3 destroyed') 90 | }) 91 | 92 | client3.destroy(function () { 93 | t.pass('client3 destroyed') 94 | }) 95 | 96 | server.close(function () { 97 | t.pass('server destroyed') 98 | }) 99 | }) 100 | }) 101 | }) 102 | }) 103 | }) 104 | } 105 | 106 | test('evict: ipv4 server', function (t) { 107 | serverTest(t, 'http', 'inet') 108 | }) 109 | 110 | test('evict: http ipv6 server', function (t) { 111 | serverTest(t, 'http', 'inet6') 112 | }) 113 | 114 | test('evict: udp server', function (t) { 115 | serverTest(t, 'udp', 'inet') 116 | }) 117 | 118 | test('evict: ws server', function (t) { 119 | wrtc = electronWebrtc() 120 | wrtc.electronDaemon.once('ready', function () { 121 | serverTest(t, 'ws', 'inet') 122 | }) 123 | t.once('end', function () { 124 | wrtc.close() 125 | }) 126 | }) 127 | -------------------------------------------------------------------------------- /test/filter.js: -------------------------------------------------------------------------------- 1 | var Buffer = require('safe-buffer').Buffer 2 | var Client = require('../') 3 | var common = require('./common') 4 | var fixtures = require('webtorrent-fixtures') 5 | var test = require('tape') 6 | 7 | var peerId = Buffer.from('01234567890123456789') 8 | 9 | function testFilterOption (t, serverType) { 10 | t.plan(8) 11 | 12 | var opts = { serverType: serverType } // this is test-suite-only option 13 | opts.filter = function (infoHash, params, cb) { 14 | process.nextTick(function () { 15 | if (infoHash === fixtures.alice.parsedTorrent.infoHash) { 16 | cb(new Error('disallowed info_hash (Alice)')) 17 | } else { 18 | cb(null) 19 | } 20 | }) 21 | } 22 | 23 | common.createServer(t, opts, function (server, announceUrl) { 24 | var client1 = new Client({ 25 | infoHash: fixtures.alice.parsedTorrent.infoHash, 26 | announce: announceUrl, 27 | peerId: peerId, 28 | port: 6881, 29 | wrtc: {} 30 | }) 31 | 32 | client1.on('error', function (err) { t.error(err) }) 33 | if (serverType === 'ws') common.mockWebsocketTracker(client1) 34 | 35 | client1.once('warning', function (err) { 36 | t.ok(err.message.includes('disallowed info_hash (Alice)'), 'got client warning') 37 | 38 | client1.destroy(function () { 39 | t.pass('client1 destroyed') 40 | 41 | var client2 = new Client({ 42 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 43 | announce: announceUrl, 44 | peerId: peerId, 45 | port: 6881, 46 | wrtc: {} 47 | }) 48 | if (serverType === 'ws') common.mockWebsocketTracker(client2) 49 | 50 | client2.on('error', function (err) { t.error(err) }) 51 | client2.on('warning', function (err) { t.error(err) }) 52 | 53 | client2.on('update', function () { 54 | t.pass('got announce') 55 | client2.destroy(function () { t.pass('client2 destroyed') }) 56 | server.close(function () { t.pass('server closed') }) 57 | }) 58 | 59 | server.on('start', function () { 60 | t.equal(Object.keys(server.torrents).length, 1) 61 | }) 62 | 63 | client2.start() 64 | }) 65 | }) 66 | 67 | server.removeAllListeners('warning') 68 | server.once('warning', function (err) { 69 | t.ok(err.message.includes('disallowed info_hash (Alice)'), 'got server warning') 70 | t.equal(Object.keys(server.torrents).length, 0) 71 | }) 72 | 73 | client1.start() 74 | }) 75 | } 76 | 77 | test('http: filter option blocks tracker from tracking torrent', function (t) { 78 | testFilterOption(t, 'http') 79 | }) 80 | 81 | test('udp: filter option blocks tracker from tracking torrent', function (t) { 82 | testFilterOption(t, 'udp') 83 | }) 84 | 85 | test('ws: filter option blocks tracker from tracking torrent', function (t) { 86 | testFilterOption(t, 'ws') 87 | }) 88 | 89 | function testFilterCustomError (t, serverType) { 90 | t.plan(8) 91 | 92 | var opts = { serverType: serverType } // this is test-suite-only option 93 | opts.filter = function (infoHash, params, cb) { 94 | process.nextTick(function () { 95 | if (infoHash === fixtures.alice.parsedTorrent.infoHash) { 96 | cb(new Error('alice blocked')) 97 | } else { 98 | cb(null) 99 | } 100 | }) 101 | } 102 | 103 | common.createServer(t, opts, function (server, announceUrl) { 104 | var client1 = new Client({ 105 | infoHash: fixtures.alice.parsedTorrent.infoHash, 106 | announce: announceUrl, 107 | peerId: peerId, 108 | port: 6881, 109 | wrtc: {} 110 | }) 111 | 112 | client1.on('error', function (err) { t.error(err) }) 113 | if (serverType === 'ws') common.mockWebsocketTracker(client1) 114 | 115 | client1.once('warning', function (err) { 116 | t.ok(/alice blocked/.test(err.message), 'got client warning') 117 | 118 | client1.destroy(function () { 119 | t.pass('client1 destroyed') 120 | var client2 = new Client({ 121 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 122 | announce: announceUrl, 123 | peerId: peerId, 124 | port: 6881, 125 | wrtc: {} 126 | }) 127 | if (serverType === 'ws') common.mockWebsocketTracker(client2) 128 | 129 | client2.on('error', function (err) { t.error(err) }) 130 | client2.on('warning', function (err) { t.error(err) }) 131 | 132 | client2.on('update', function () { 133 | t.pass('got announce') 134 | client2.destroy(function () { t.pass('client2 destroyed') }) 135 | server.close(function () { t.pass('server closed') }) 136 | }) 137 | 138 | server.on('start', function () { 139 | t.equal(Object.keys(server.torrents).length, 1) 140 | }) 141 | 142 | client2.start() 143 | }) 144 | }) 145 | 146 | server.removeAllListeners('warning') 147 | server.once('warning', function (err) { 148 | t.ok(/alice blocked/.test(err.message), 'got server warning') 149 | t.equal(Object.keys(server.torrents).length, 0) 150 | }) 151 | 152 | client1.start() 153 | }) 154 | } 155 | 156 | test('http: filter option with custom error', function (t) { 157 | testFilterCustomError(t, 'http') 158 | }) 159 | 160 | test('udp: filter option filter option with custom error', function (t) { 161 | testFilterCustomError(t, 'udp') 162 | }) 163 | 164 | test('ws: filter option filter option with custom error', function (t) { 165 | testFilterCustomError(t, 'ws') 166 | }) 167 | -------------------------------------------------------------------------------- /test/querystring.js: -------------------------------------------------------------------------------- 1 | var Buffer = require('safe-buffer').Buffer 2 | var common = require('../lib/common') 3 | var test = require('tape') 4 | 5 | // https://github.com/webtorrent/webtorrent/issues/196 6 | test('encode special chars +* in http tracker urls', function (t) { 7 | var q = { 8 | info_hash: Buffer.from('a2a15537542b22925ad10486bf7a8b2a9c42f0d1', 'hex').toString('binary') 9 | } 10 | var encoded = 'info_hash=%A2%A1U7T%2B%22%92Z%D1%04%86%BFz%8B%2A%9CB%F0%D1' 11 | t.equal(common.querystringStringify(q), encoded) 12 | 13 | // sanity check that encode-decode matches up 14 | t.deepEqual(common.querystringParse(common.querystringStringify(q)), q) 15 | 16 | t.end() 17 | }) 18 | -------------------------------------------------------------------------------- /test/request-handler.js: -------------------------------------------------------------------------------- 1 | var Buffer = require('safe-buffer').Buffer 2 | var Client = require('../') 3 | var common = require('./common') 4 | var fixtures = require('webtorrent-fixtures') 5 | var test = require('tape') 6 | var Server = require('../server') 7 | 8 | var peerId = Buffer.from('01234567890123456789') 9 | 10 | function testRequestHandler (t, serverType) { 11 | t.plan(5) 12 | 13 | var opts = { serverType: serverType } // this is test-suite-only option 14 | 15 | class Swarm extends Server.Swarm { 16 | announce (params, cb) { 17 | super.announce(params, function (err, response) { 18 | if (err) return cb(response) 19 | response.complete = 246 20 | response.extraData = 'hi' 21 | cb(null, response) 22 | }) 23 | } 24 | } 25 | 26 | // Use a custom Swarm implementation for this test only 27 | var OldSwarm = Server.Swarm 28 | Server.Swarm = Swarm 29 | t.on('end', function () { 30 | Server.Swarm = OldSwarm 31 | }) 32 | 33 | common.createServer(t, opts, function (server, announceUrl) { 34 | var client1 = new Client({ 35 | infoHash: fixtures.alice.parsedTorrent.infoHash, 36 | announce: announceUrl, 37 | peerId: peerId, 38 | port: 6881, 39 | wrtc: {} 40 | }) 41 | 42 | client1.on('error', function (err) { t.error(err) }) 43 | if (serverType === 'ws') common.mockWebsocketTracker(client1) 44 | 45 | server.once('start', function () { 46 | t.pass('got start message from client1') 47 | }) 48 | 49 | client1.once('update', function (data) { 50 | t.equal(data.complete, 246) 51 | t.equal(data.extraData.toString(), 'hi') 52 | 53 | client1.destroy(function () { 54 | t.pass('client1 destroyed') 55 | }) 56 | 57 | server.close(function () { 58 | t.pass('server destroyed') 59 | }) 60 | }) 61 | 62 | client1.start() 63 | }) 64 | } 65 | 66 | test('http: request handler option intercepts announce requests and responses', function (t) { 67 | testRequestHandler(t, 'http') 68 | }) 69 | 70 | test('ws: request handler option intercepts announce requests and responses', function (t) { 71 | testRequestHandler(t, 'ws') 72 | }) 73 | 74 | // NOTE: it's not possible to include extra data in a UDP response, because it's compact and accepts only params that are in the spec! 75 | -------------------------------------------------------------------------------- /test/scrape.js: -------------------------------------------------------------------------------- 1 | var bencode = require('bencode') 2 | var Buffer = require('safe-buffer').Buffer 3 | var Client = require('../') 4 | var common = require('./common') 5 | var commonLib = require('../lib/common') 6 | var commonTest = require('./common') 7 | var fixtures = require('webtorrent-fixtures') 8 | var get = require('simple-get') 9 | var test = require('tape') 10 | 11 | var peerId = Buffer.from('01234567890123456789') 12 | 13 | function testSingle (t, serverType) { 14 | commonTest.createServer(t, serverType, function (server, announceUrl) { 15 | var client = new Client({ 16 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 17 | announce: announceUrl, 18 | peerId: peerId, 19 | port: 6881, 20 | wrtc: {} 21 | }) 22 | 23 | if (serverType === 'ws') common.mockWebsocketTracker(client) 24 | client.on('error', function (err) { t.error(err) }) 25 | client.on('warning', function (err) { t.error(err) }) 26 | 27 | client.scrape() 28 | 29 | client.on('scrape', function (data) { 30 | t.equal(data.announce, announceUrl) 31 | t.equal(data.infoHash, fixtures.leaves.parsedTorrent.infoHash) 32 | t.equal(typeof data.complete, 'number') 33 | t.equal(typeof data.incomplete, 'number') 34 | t.equal(typeof data.downloaded, 'number') 35 | client.destroy() 36 | server.close(function () { 37 | t.end() 38 | }) 39 | }) 40 | }) 41 | } 42 | 43 | test('http: single info_hash scrape', function (t) { 44 | testSingle(t, 'http') 45 | }) 46 | 47 | test('udp: single info_hash scrape', function (t) { 48 | testSingle(t, 'udp') 49 | }) 50 | 51 | test('ws: single info_hash scrape', function (t) { 52 | testSingle(t, 'ws') 53 | }) 54 | 55 | function clientScrapeStatic (t, serverType) { 56 | commonTest.createServer(t, serverType, function (server, announceUrl) { 57 | var client = Client.scrape({ 58 | announce: announceUrl, 59 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 60 | wrtc: {} 61 | }, function (err, data) { 62 | t.error(err) 63 | t.equal(data.announce, announceUrl) 64 | t.equal(data.infoHash, fixtures.leaves.parsedTorrent.infoHash) 65 | t.equal(typeof data.complete, 'number') 66 | t.equal(typeof data.incomplete, 'number') 67 | t.equal(typeof data.downloaded, 'number') 68 | server.close(function () { 69 | t.end() 70 | }) 71 | }) 72 | if (serverType === 'ws') common.mockWebsocketTracker(client) 73 | }) 74 | } 75 | 76 | test('http: scrape using Client.scrape static method', function (t) { 77 | clientScrapeStatic(t, 'http') 78 | }) 79 | 80 | test('udp: scrape using Client.scrape static method', function (t) { 81 | clientScrapeStatic(t, 'udp') 82 | }) 83 | 84 | test('ws: scrape using Client.scrape static method', function (t) { 85 | clientScrapeStatic(t, 'ws') 86 | }) 87 | 88 | // Ensure the callback function gets called when an invalid url is passed 89 | function clientScrapeStaticInvalid (t, serverType) { 90 | var announceUrl = serverType + '://invalid.lol' 91 | if (serverType === 'http') announceUrl += '/announce' 92 | 93 | var client = Client.scrape({ 94 | announce: announceUrl, 95 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 96 | wrtc: {} 97 | }, function (err, data) { 98 | t.ok(err instanceof Error) 99 | t.end() 100 | }) 101 | if (serverType === 'ws') common.mockWebsocketTracker(client) 102 | } 103 | 104 | test('http: scrape using Client.scrape static method (invalid url)', function (t) { 105 | clientScrapeStaticInvalid(t, 'http') 106 | }) 107 | 108 | test('udp: scrape using Client.scrape static method (invalid url)', function (t) { 109 | clientScrapeStaticInvalid(t, 'udp') 110 | }) 111 | 112 | test('ws: scrape using Client.scrape static method (invalid url)', function (t) { 113 | clientScrapeStaticInvalid(t, 'ws') 114 | }) 115 | 116 | function clientScrapeMulti (t, serverType) { 117 | var infoHash1 = fixtures.leaves.parsedTorrent.infoHash 118 | var infoHash2 = fixtures.alice.parsedTorrent.infoHash 119 | 120 | commonTest.createServer(t, serverType, function (server, announceUrl) { 121 | Client.scrape({ 122 | infoHash: [ infoHash1, infoHash2 ], 123 | announce: announceUrl 124 | }, function (err, results) { 125 | t.error(err) 126 | 127 | t.equal(results[infoHash1].announce, announceUrl) 128 | t.equal(results[infoHash1].infoHash, infoHash1) 129 | t.equal(typeof results[infoHash1].complete, 'number') 130 | t.equal(typeof results[infoHash1].incomplete, 'number') 131 | t.equal(typeof results[infoHash1].downloaded, 'number') 132 | 133 | t.equal(results[infoHash2].announce, announceUrl) 134 | t.equal(results[infoHash2].infoHash, infoHash2) 135 | t.equal(typeof results[infoHash2].complete, 'number') 136 | t.equal(typeof results[infoHash2].incomplete, 'number') 137 | t.equal(typeof results[infoHash2].downloaded, 'number') 138 | 139 | server.close(function () { 140 | t.end() 141 | }) 142 | }) 143 | }) 144 | } 145 | 146 | test('http: MULTI scrape using Client.scrape static method', function (t) { 147 | clientScrapeMulti(t, 'http') 148 | }) 149 | 150 | test('udp: MULTI scrape using Client.scrape static method', function (t) { 151 | clientScrapeMulti(t, 'udp') 152 | }) 153 | 154 | test('server: multiple info_hash scrape (manual http request)', function (t) { 155 | t.plan(13) 156 | 157 | var binaryInfoHash1 = commonLib.hexToBinary(fixtures.leaves.parsedTorrent.infoHash) 158 | var binaryInfoHash2 = commonLib.hexToBinary(fixtures.alice.parsedTorrent.infoHash) 159 | 160 | commonTest.createServer(t, 'http', function (server, announceUrl) { 161 | var scrapeUrl = announceUrl.replace('/announce', '/scrape') 162 | 163 | var url = scrapeUrl + '?' + commonLib.querystringStringify({ 164 | info_hash: [ binaryInfoHash1, binaryInfoHash2 ] 165 | }) 166 | 167 | get.concat(url, function (err, res, data) { 168 | t.error(err) 169 | 170 | t.equal(res.statusCode, 200) 171 | 172 | data = bencode.decode(data) 173 | t.ok(data.files) 174 | t.equal(Object.keys(data.files).length, 2) 175 | 176 | t.ok(data.files[binaryInfoHash1]) 177 | t.equal(typeof data.files[binaryInfoHash1].complete, 'number') 178 | t.equal(typeof data.files[binaryInfoHash1].incomplete, 'number') 179 | t.equal(typeof data.files[binaryInfoHash1].downloaded, 'number') 180 | 181 | t.ok(data.files[binaryInfoHash2]) 182 | t.equal(typeof data.files[binaryInfoHash2].complete, 'number') 183 | t.equal(typeof data.files[binaryInfoHash2].incomplete, 'number') 184 | t.equal(typeof data.files[binaryInfoHash2].downloaded, 'number') 185 | 186 | server.close(function () { t.pass('server closed') }) 187 | }) 188 | }) 189 | }) 190 | 191 | test('server: all info_hash scrape (manual http request)', function (t) { 192 | t.plan(10) 193 | 194 | var binaryInfoHash = commonLib.hexToBinary(fixtures.leaves.parsedTorrent.infoHash) 195 | 196 | commonTest.createServer(t, 'http', function (server, announceUrl) { 197 | var scrapeUrl = announceUrl.replace('/announce', '/scrape') 198 | 199 | // announce a torrent to the tracker 200 | var client = new Client({ 201 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 202 | announce: announceUrl, 203 | peerId: peerId, 204 | port: 6881 205 | }) 206 | client.on('error', function (err) { t.error(err) }) 207 | client.on('warning', function (err) { t.error(err) }) 208 | 209 | client.start() 210 | 211 | server.once('start', function () { 212 | // now do a scrape of everything by omitting the info_hash param 213 | get.concat(scrapeUrl, function (err, res, data) { 214 | t.error(err) 215 | 216 | t.equal(res.statusCode, 200) 217 | data = bencode.decode(data) 218 | t.ok(data.files) 219 | t.equal(Object.keys(data.files).length, 1) 220 | 221 | t.ok(data.files[binaryInfoHash]) 222 | t.equal(typeof data.files[binaryInfoHash].complete, 'number') 223 | t.equal(typeof data.files[binaryInfoHash].incomplete, 'number') 224 | t.equal(typeof data.files[binaryInfoHash].downloaded, 'number') 225 | 226 | client.destroy(function () { t.pass('client destroyed') }) 227 | server.close(function () { t.pass('server closed') }) 228 | }) 229 | }) 230 | }) 231 | }) 232 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | var Buffer = require('safe-buffer').Buffer 2 | var Client = require('../') 3 | var common = require('./common') 4 | var test = require('tape') 5 | var electronWebrtc = require('electron-webrtc') 6 | 7 | var wrtc 8 | 9 | var infoHash = '4cb67059ed6bd08362da625b3ae77f6f4a075705' 10 | var peerId = Buffer.from('01234567890123456789') 11 | var peerId2 = Buffer.from('12345678901234567890') 12 | var peerId3 = Buffer.from('23456789012345678901') 13 | 14 | function serverTest (t, serverType, serverFamily) { 15 | t.plan(40) 16 | 17 | var hostname = serverFamily === 'inet6' 18 | ? '[::1]' 19 | : '127.0.0.1' 20 | var clientIp = serverFamily === 'inet6' 21 | ? '::1' 22 | : '127.0.0.1' 23 | 24 | var opts = { 25 | serverType: serverType 26 | } 27 | 28 | common.createServer(t, opts, function (server) { 29 | // Not using announceUrl param from `common.createServer()` since we 30 | // want to control IPv4 vs IPv6. 31 | var port = server[serverType].address().port 32 | var announceUrl = serverType + '://' + hostname + ':' + port + '/announce' 33 | 34 | var client1 = new Client({ 35 | infoHash: infoHash, 36 | announce: [ announceUrl ], 37 | peerId: peerId, 38 | port: 6881, 39 | wrtc: wrtc 40 | }) 41 | if (serverType === 'ws') common.mockWebsocketTracker(client1) 42 | 43 | client1.start() 44 | 45 | server.once('start', function () { 46 | t.pass('got start message from client1') 47 | }) 48 | 49 | client1.once('update', function (data) { 50 | t.equal(data.announce, announceUrl) 51 | t.equal(data.complete, 0) 52 | t.equal(data.incomplete, 1) 53 | 54 | server.getSwarm(infoHash, function (err, swarm) { 55 | t.error(err) 56 | 57 | t.equal(Object.keys(server.torrents).length, 1) 58 | t.equal(swarm.complete, 0) 59 | t.equal(swarm.incomplete, 1) 60 | t.equal(swarm.peers.length, 1) 61 | 62 | var id = serverType === 'ws' 63 | ? peerId.toString('hex') 64 | : hostname + ':6881' 65 | 66 | var peer = swarm.peers.peek(id) 67 | t.equal(peer.type, serverType) 68 | t.equal(peer.ip, clientIp) 69 | t.equal(peer.peerId, peerId.toString('hex')) 70 | t.equal(peer.complete, false) 71 | if (serverType === 'ws') { 72 | t.equal(typeof peer.port, 'number') 73 | t.ok(peer.socket) 74 | } else { 75 | t.equal(peer.port, 6881) 76 | t.notOk(peer.socket) 77 | } 78 | 79 | client1.complete() 80 | 81 | client1.once('update', function (data) { 82 | t.equal(data.announce, announceUrl) 83 | t.equal(data.complete, 1) 84 | t.equal(data.incomplete, 0) 85 | 86 | client1.scrape() 87 | 88 | client1.once('scrape', function (data) { 89 | t.equal(data.announce, announceUrl) 90 | t.equal(data.complete, 1) 91 | t.equal(data.incomplete, 0) 92 | t.equal(typeof data.downloaded, 'number') 93 | 94 | var client2 = new Client({ 95 | infoHash: infoHash, 96 | announce: [ announceUrl ], 97 | peerId: peerId2, 98 | port: 6882, 99 | wrtc: wrtc 100 | }) 101 | if (serverType === 'ws') common.mockWebsocketTracker(client2) 102 | 103 | client2.start() 104 | 105 | server.once('start', function () { 106 | t.pass('got start message from client2') 107 | }) 108 | 109 | client2.once('update', function (data) { 110 | t.equal(data.announce, announceUrl) 111 | t.equal(data.complete, 1) 112 | t.equal(data.incomplete, 1) 113 | 114 | var client3 = new Client({ 115 | infoHash: infoHash, 116 | announce: [ announceUrl ], 117 | peerId: peerId3, 118 | port: 6880, 119 | wrtc: wrtc 120 | }) 121 | if (serverType === 'ws') common.mockWebsocketTracker(client3) 122 | 123 | client3.start() 124 | 125 | server.once('start', function () { 126 | t.pass('got start message from client3') 127 | }) 128 | 129 | client3.once('update', function (data) { 130 | t.equal(data.announce, announceUrl) 131 | t.equal(data.complete, 1) 132 | t.equal(data.incomplete, 2) 133 | 134 | client2.stop() 135 | client2.once('update', function (data) { 136 | t.equal(data.announce, announceUrl) 137 | t.equal(data.complete, 1) 138 | t.equal(data.incomplete, 1) 139 | 140 | client2.destroy(function () { 141 | t.pass('client2 destroyed') 142 | client3.stop() 143 | client3.once('update', function (data) { 144 | t.equal(data.announce, announceUrl) 145 | t.equal(data.complete, 1) 146 | t.equal(data.incomplete, 0) 147 | 148 | client1.destroy(function () { 149 | t.pass('client1 destroyed') 150 | }) 151 | 152 | client3.destroy(function () { 153 | t.pass('client3 destroyed') 154 | }) 155 | 156 | server.close(function () { 157 | t.pass('server destroyed') 158 | }) 159 | }) 160 | }) 161 | }) 162 | }) 163 | }) 164 | }) 165 | }) 166 | }) 167 | }) 168 | }) 169 | } 170 | 171 | test('http ipv4 server', function (t) { 172 | serverTest(t, 'http', 'inet') 173 | }) 174 | 175 | test('http ipv6 server', function (t) { 176 | serverTest(t, 'http', 'inet6') 177 | }) 178 | 179 | test('udp server', function (t) { 180 | serverTest(t, 'udp', 'inet') 181 | }) 182 | 183 | test('ws server', function (t) { 184 | wrtc = electronWebrtc() 185 | wrtc.electronDaemon.once('ready', function () { 186 | serverTest(t, 'ws', 'inet') 187 | }) 188 | t.once('end', function () { 189 | wrtc.close() 190 | }) 191 | }) 192 | -------------------------------------------------------------------------------- /test/stats.js: -------------------------------------------------------------------------------- 1 | var Buffer = require('safe-buffer').Buffer 2 | var Client = require('../') 3 | var commonTest = require('./common') 4 | var fixtures = require('webtorrent-fixtures') 5 | var get = require('simple-get') 6 | var test = require('tape') 7 | 8 | var peerId = Buffer.from('-WW0091-4ea5886ce160') 9 | var unknownPeerId = Buffer.from('01234567890123456789') 10 | 11 | function parseHtml (html) { 12 | var extractValue = new RegExp('[^v^h](\\d+)') 13 | var array = html.replace('torrents', '\n').split('\n').filter(function (line) { 14 | return line && line.trim().length > 0 15 | }).map(function (line) { 16 | var a = extractValue.exec(line) 17 | if (a) { 18 | return parseInt(a[1]) 19 | } 20 | }) 21 | var i = 0 22 | return { 23 | torrents: array[i++], 24 | activeTorrents: array[i++], 25 | peersAll: array[i++], 26 | peersSeederOnly: array[i++], 27 | peersLeecherOnly: array[i++], 28 | peersSeederAndLeecher: array[i++], 29 | peersIPv4: array[i++], 30 | peersIPv6: array[i] 31 | } 32 | } 33 | 34 | test('server: get empty stats', function (t) { 35 | t.plan(11) 36 | 37 | commonTest.createServer(t, 'http', function (server, announceUrl) { 38 | var url = announceUrl.replace('/announce', '/stats') 39 | 40 | get.concat(url, function (err, res, data) { 41 | t.error(err) 42 | 43 | var stats = parseHtml(data.toString()) 44 | t.equal(res.statusCode, 200) 45 | t.equal(stats.torrents, 0) 46 | t.equal(stats.activeTorrents, 0) 47 | t.equal(stats.peersAll, 0) 48 | t.equal(stats.peersSeederOnly, 0) 49 | t.equal(stats.peersLeecherOnly, 0) 50 | t.equal(stats.peersSeederAndLeecher, 0) 51 | t.equal(stats.peersIPv4, 0) 52 | t.equal(stats.peersIPv6, 0) 53 | 54 | server.close(function () { t.pass('server closed') }) 55 | }) 56 | }) 57 | }) 58 | 59 | test('server: get empty stats with json header', function (t) { 60 | t.plan(11) 61 | 62 | commonTest.createServer(t, 'http', function (server, announceUrl) { 63 | var opts = { 64 | url: announceUrl.replace('/announce', '/stats'), 65 | headers: { 66 | accept: 'application/json' 67 | }, 68 | json: true 69 | } 70 | 71 | get.concat(opts, function (err, res, stats) { 72 | t.error(err) 73 | 74 | t.equal(res.statusCode, 200) 75 | t.equal(stats.torrents, 0) 76 | t.equal(stats.activeTorrents, 0) 77 | t.equal(stats.peersAll, 0) 78 | t.equal(stats.peersSeederOnly, 0) 79 | t.equal(stats.peersLeecherOnly, 0) 80 | t.equal(stats.peersSeederAndLeecher, 0) 81 | t.equal(stats.peersIPv4, 0) 82 | t.equal(stats.peersIPv6, 0) 83 | 84 | server.close(function () { t.pass('server closed') }) 85 | }) 86 | }) 87 | }) 88 | 89 | test('server: get empty stats on stats.json', function (t) { 90 | t.plan(11) 91 | 92 | commonTest.createServer(t, 'http', function (server, announceUrl) { 93 | var opts = { 94 | url: announceUrl.replace('/announce', '/stats.json'), 95 | json: true 96 | } 97 | 98 | get.concat(opts, function (err, res, stats) { 99 | t.error(err) 100 | 101 | t.equal(res.statusCode, 200) 102 | t.equal(stats.torrents, 0) 103 | t.equal(stats.activeTorrents, 0) 104 | t.equal(stats.peersAll, 0) 105 | t.equal(stats.peersSeederOnly, 0) 106 | t.equal(stats.peersLeecherOnly, 0) 107 | t.equal(stats.peersSeederAndLeecher, 0) 108 | t.equal(stats.peersIPv4, 0) 109 | t.equal(stats.peersIPv6, 0) 110 | 111 | server.close(function () { t.pass('server closed') }) 112 | }) 113 | }) 114 | }) 115 | 116 | test('server: get leecher stats.json', function (t) { 117 | t.plan(11) 118 | 119 | commonTest.createServer(t, 'http', function (server, announceUrl) { 120 | // announce a torrent to the tracker 121 | var client = new Client({ 122 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 123 | announce: announceUrl, 124 | peerId: peerId, 125 | port: 6881 126 | }) 127 | client.on('error', function (err) { t.error(err) }) 128 | client.on('warning', function (err) { t.error(err) }) 129 | 130 | client.start() 131 | 132 | server.once('start', function () { 133 | var opts = { 134 | url: announceUrl.replace('/announce', '/stats.json'), 135 | json: true 136 | } 137 | 138 | get.concat(opts, function (err, res, stats) { 139 | t.error(err) 140 | 141 | t.equal(res.statusCode, 200) 142 | t.equal(stats.torrents, 1) 143 | t.equal(stats.activeTorrents, 1) 144 | t.equal(stats.peersAll, 1) 145 | t.equal(stats.peersSeederOnly, 0) 146 | t.equal(stats.peersLeecherOnly, 1) 147 | t.equal(stats.peersSeederAndLeecher, 0) 148 | t.equal(stats.clients['WebTorrent']['0.91'], 1) 149 | 150 | client.destroy(function () { t.pass('client destroyed') }) 151 | server.close(function () { t.pass('server closed') }) 152 | }) 153 | }) 154 | }) 155 | }) 156 | 157 | test('server: get leecher stats.json (unknown peerId)', function (t) { 158 | t.plan(11) 159 | 160 | commonTest.createServer(t, 'http', function (server, announceUrl) { 161 | // announce a torrent to the tracker 162 | var client = new Client({ 163 | infoHash: fixtures.leaves.parsedTorrent.infoHash, 164 | announce: announceUrl, 165 | peerId: unknownPeerId, 166 | port: 6881 167 | }) 168 | client.on('error', function (err) { t.error(err) }) 169 | client.on('warning', function (err) { t.error(err) }) 170 | 171 | client.start() 172 | 173 | server.once('start', function () { 174 | var opts = { 175 | url: announceUrl.replace('/announce', '/stats.json'), 176 | json: true 177 | } 178 | 179 | get.concat(opts, function (err, res, stats) { 180 | t.error(err) 181 | 182 | t.equal(res.statusCode, 200) 183 | t.equal(stats.torrents, 1) 184 | t.equal(stats.activeTorrents, 1) 185 | t.equal(stats.peersAll, 1) 186 | t.equal(stats.peersSeederOnly, 0) 187 | t.equal(stats.peersLeecherOnly, 1) 188 | t.equal(stats.peersSeederAndLeecher, 0) 189 | t.equal(stats.clients['unknown']['01234567'], 1) 190 | 191 | client.destroy(function () { t.pass('client destroyed') }) 192 | server.close(function () { t.pass('server closed') }) 193 | }) 194 | }) 195 | }) 196 | }) 197 | -------------------------------------------------------------------------------- /trackerStats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroNetJS/zeronet-tracker/5e39a997491836c1679e42768e4811d9384ed122/trackerStats.png --------------------------------------------------------------------------------